From dfb06ed4594ff83547e511144edd5c199b2c02f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:27:47 +0200 Subject: [PATCH 01/31] feat(app-shared): share Home widget status, freshness, and presentation rules --- packages/app-shared/package.json | 1 + packages/app-shared/src/home-widget.test.ts | 205 +++++++++++++++ packages/app-shared/src/home-widget.ts | 278 ++++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 packages/app-shared/src/home-widget.test.ts create mode 100644 packages/app-shared/src/home-widget.ts diff --git a/packages/app-shared/package.json b/packages/app-shared/package.json index a871f1e827..0c6c3c74e5 100644 --- a/packages/app-shared/package.json +++ b/packages/app-shared/package.json @@ -22,6 +22,7 @@ "./commerce": "./src/commerce/index.ts", "./moderation": "./src/moderation/index.ts", "./glanceable-agents-snapshot": "./src/glanceable-agents-snapshot.ts", + "./home-widget": "./src/home-widget.ts", "./tool-detail": "./src/tool-detail/index.ts" }, "scripts": { diff --git a/packages/app-shared/src/home-widget.test.ts b/packages/app-shared/src/home-widget.test.ts new file mode 100644 index 0000000000..9f1baca055 --- /dev/null +++ b/packages/app-shared/src/home-widget.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { + buildHomeWidgetData, + buildHomeWidgetDetails, + buildHomeWidgetPresentation, + buildHomeWidgetPresentationTimeline, + homeWidgetRefreshAt, + homeWidgetDataSchema, + HOME_WIDGET_TITLE_LIMIT, +} from './home-widget'; +import { GLANCEABLE_STALE_MS } from './glanceable-agents-snapshot'; + +const now = Date.parse('2026-10-09T10:00:00.000Z'); +const scope = { userId: 'oauth/private-user', organizationId: null, now }; + +describe('Home-only widget policy', () => { + it('ranks oldest waits and earliest schedules, bounds lists, and preserves ties', () => { + const details = buildHomeWidgetDetails([ + { status: 'question', title: 'No known wait' }, + { status: 'permission', title: 'Oldest', statusUpdatedAt: '2026-10-09T08:00:00Z' }, + { status: 'retry', title: 'Stable tie', statusUpdatedAt: '2026-10-09T08:00:00Z' }, + { status: 'permission', title: 'Later', statusUpdatedAt: '2026-10-09T09:00:00Z' }, + { status: 'scheduled', title: 'No wake', scheduledAt: 'invalid' }, + { status: 'scheduled', title: 'First', scheduledAt: '2026-10-09 11:00:00+00' }, + { status: 'scheduled', title: 'Second', scheduledAt: '2026-10-09T12:00:00Z' }, + { status: 'scheduled', title: 'Third', scheduledAt: '2026-10-09T13:00:00Z' }, + ]); + expect(details.primaryTitle).toBe('Oldest'); + expect(details.waitingAgents.map(row => row.title)).toEqual(['Oldest', 'Stable tie', 'Later']); + expect(details.scheduledAgents.map(row => row.title)).toEqual(['First', 'Second', 'Third']); + expect(details.scheduledAgents[0]?.scheduledAt).toBe('2026-10-09T11:00:00.000Z'); + }); + + it('keeps missing titles empty for local fallback and never leaks extra row fields', () => { + const row = { + status: 'busy', + title: `\n${'x'.repeat(HOME_WIDGET_TITLE_LIMIT + 1)}`, + id: 'private-session', + prompt: 'private-prompt', + repository: 'private-repository', + }; + const data = buildHomeWidgetData({ ...scope, sessions: [row] }); + expect(homeWidgetDataSchema.safeParse(data).success).toBe(true); + expect(data.details.primaryTitle).toHaveLength(HOME_WIDGET_TITLE_LIMIT); + expect(JSON.stringify(data.snapshot)).not.toContain('xxx'); + for (const forbidden of [row.id, row.prompt, row.repository, scope.userId]) { + expect(JSON.stringify(data)).not.toContain(forbidden); + } + expect(buildHomeWidgetDetails([{ status: 'permission' }]).waitingAgents[0]?.title).toBe(''); + }); + + it('uses one priority and excludes every zero-count secondary row', () => { + const data = buildHomeWidgetData({ + ...scope, + sessions: [ + { status: 'scheduled', title: 'Soon', scheduledAt: '2026-10-09T11:00:00Z' }, + { status: 'idle', title: 'Quiet' }, + ], + }); + const home = buildHomeWidgetPresentation(data, now); + expect(home.primaryKind).toBe('scheduled'); + expect(home.primaryTitle).toBe('Soon'); + expect(home.secondaryCounts).toEqual([{ kind: 'idle', count: 1 }]); + expect(home.scheduledAt).toBe('2026-10-09T11:00:00Z'); + const running = buildHomeWidgetData({ + ...scope, + sessions: [ + { status: 'busy', title: 'Running' }, + { status: 'scheduled', title: 'Soon' }, + ], + }); + expect(buildHomeWidgetPresentation(running, now).primaryKind).toBe('running'); + const waiting = buildHomeWidgetData({ + ...scope, + sessions: [ + { status: 'busy', title: 'Running' }, + { status: 'question', title: 'Older question', statusUpdatedAt: '2026-10-09T09:00:00Z' }, + { + status: 'permission', + title: 'Approve', + statusUpdatedAt: '2026-10-09T09:30:00Z', + approvalKey: 'a'.repeat(64), + }, + ], + }); + expect(buildHomeWidgetPresentation(waiting, now)).toMatchObject({ + primaryKind: 'needsInput', + primaryTitle: 'Approve', + canApprove: true, + approvalKey: 'a'.repeat(64), + }); + const unknownRequest = buildHomeWidgetData({ + ...scope, + sessions: [{ status: 'permission', title: 'Approve' }], + }); + expect(buildHomeWidgetPresentation(unknownRequest, now)).toMatchObject({ + canApprove: false, + approvalKey: null, + }); + }); + + it('retains expired counts and honest checkedAt without inventing activity at a missed wake', () => { + const data = buildHomeWidgetData({ + ...scope, + sessions: [{ status: 'scheduled', title: 'Wake', scheduledAt: '2026-10-09T10:15:00Z' }], + }); + expect(buildHomeWidgetPresentation(data, now).stale).toBe(false); + expect(buildHomeWidgetPresentation(data, now + GLANCEABLE_STALE_MS).stale).toBe(true); + data.snapshot.status = 'expired'; + const later = now + 10 * 60 * 60 * 1000; + expect(buildHomeWidgetPresentation(data, later)).toMatchObject({ + status: 'content', + primaryKind: 'scheduled', + primaryCount: 1, + checkedAt: new Date(now).toISOString(), + stale: true, + awaitingUpdate: true, + canCreate: true, + }); + expect(homeWidgetRefreshAt(data, later)).toBe(later + 30 * 60 * 1000); + const missing = buildHomeWidgetData({ ...scope, sessions: [{ status: 'scheduled' }] }); + expect(buildHomeWidgetPresentation(missing, later)).toMatchObject({ + primaryKind: 'scheduled', + scheduledAt: null, + awaitingUpdate: false, + }); + }); + + it('hides retained private work on explicit auth/privacy terminals and never manufactures work', () => { + const data = buildHomeWidgetData({ + ...scope, + sessions: [{ status: 'permission', title: 'Private' }], + }); + for (const status of ['signed_out', 'privacy'] as const) { + data.snapshot.status = status; + expect(buildHomeWidgetPresentation(data, now)).toMatchObject({ + status, + primaryKind: null, + primaryCount: 0, + secondaryCounts: [], + primaryTitle: null, + waitingAgents: [], + scheduledAgents: [], + canCreate: false, + canApprove: false, + }); + } + const expiredWithoutConfirmation = buildHomeWidgetData({ + ...scope, + sessions: [], + status: 'expired', + }); + expect(buildHomeWidgetPresentation(expiredWithoutConfirmation, now)).toMatchObject({ + status: 'unavailable', + primaryKind: null, + primaryCount: 0, + checkedAt: null, + }); + }); + + it('requests active/quiet cadence and an earlier future wake without tight overdue polling', () => { + const active = buildHomeWidgetData({ ...scope, sessions: [{ status: 'busy' }] }); + const idle = buildHomeWidgetData({ ...scope, sessions: [{ status: 'idle' }] }); + const empty = buildHomeWidgetData({ ...scope, sessions: [] }); + const scheduled = buildHomeWidgetData({ + ...scope, + sessions: [{ status: 'scheduled', scheduledAt: '2026-10-09T10:05:00Z' }], + }); + expect(homeWidgetRefreshAt(active, now)).toBe(now + 30 * 60 * 1000); + expect(homeWidgetRefreshAt(idle, now)).toBe(now + 2 * 60 * 60 * 1000); + expect(homeWidgetRefreshAt(empty, now)).toBe(now + 2 * 60 * 60 * 1000); + expect(homeWidgetRefreshAt(scheduled, now)).toBe(now + 5 * 60 * 1000); + expect(homeWidgetRefreshAt(scheduled, now + 5 * 60 * 1000)).toBe(now + 35 * 60 * 1000); + }); +}); + +describe('native Home presentation timeline', () => { + it('sorts and deduplicates stale/wake boundaries and uses the same presentation policy', () => { + const data = buildHomeWidgetData({ + ...scope, + sessions: [ + { + status: 'scheduled', + scheduledAt: '2026-10-09T10:05:00Z', + }, + ], + }); + const timeline = buildHomeWidgetPresentationTimeline(data, now); + expect(timeline.map(entry => entry.at)).toEqual([ + now, + now + 5 * 60 * 1000, + now + GLANCEABLE_STALE_MS, + ]); + expect(timeline[1]?.home.awaitingUpdate).toBe(true); + expect(timeline[2]?.home.stale).toBe(true); + for (const entry of timeline) { + expect(entry.home).toEqual(buildHomeWidgetPresentation(data, entry.at)); + } + data.snapshot.scheduledAt = new Date(now + GLANCEABLE_STALE_MS).toISOString(); + expect(buildHomeWidgetPresentationTimeline(data, now)).toHaveLength(2); + expect(buildHomeWidgetPresentationTimeline(data, now + GLANCEABLE_STALE_MS)).toHaveLength(1); + data.snapshot.status = 'privacy'; + expect(buildHomeWidgetPresentationTimeline(data, now)).toHaveLength(1); + }); +}); diff --git a/packages/app-shared/src/home-widget.ts b/packages/app-shared/src/home-widget.ts new file mode 100644 index 0000000000..6959448366 --- /dev/null +++ b/packages/app-shared/src/home-widget.ts @@ -0,0 +1,278 @@ +import { z } from 'zod'; +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, + glanceableAgentsSnapshotSchema, + glanceableStatusKind, + type BuildGlanceableSnapshotInput, + type GlanceableSessionRow, + type GlanceableStatusKind, +} from './glanceable-agents-snapshot'; + +export const HOME_WIDGET_DETAIL_LIMIT = 3; +export const HOME_WIDGET_TITLE_LIMIT = 120; +const titleSchema = z.string().max(HOME_WIDGET_TITLE_LIMIT); +const kindSchema = z.enum(['needsInput', 'running', 'scheduled', 'idle']); + +/** Private Home-only data. Never include this in Live Activity content-state. */ +export const homeWidgetDetailsSchema = z.object({ + /** SHA-256 hex of the exact visible approval target; grants no authority. */ + approvalKey: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .default(null), + primaryTitle: titleSchema.nullable(), + waitingAgents: z + .array( + z.object({ + title: titleSchema, + kind: z.enum(['permission', 'question', 'retry']), + }) + ) + .max(HOME_WIDGET_DETAIL_LIMIT), + scheduledAgents: z + .array( + z.object({ + title: titleSchema, + scheduledAt: z.string().nullable(), + }) + ) + .max(HOME_WIDGET_DETAIL_LIMIT), +}); +export type HomeWidgetDetails = z.infer; +export const EMPTY_HOME_WIDGET_DETAILS: HomeWidgetDetails = { + approvalKey: null, + primaryTitle: null, + waitingAgents: [], + scheduledAgents: [], +}; + +export const homeWidgetDataSchema = z.object({ + snapshot: glanceableAgentsSnapshotSchema, + details: homeWidgetDetailsSchema, +}); +export type HomeWidgetData = z.infer; + +export const homeWidgetPresentationSchema = z.object({ + status: z.enum(['waiting', 'unavailable', 'empty', 'signed_out', 'privacy', 'content']), + primaryKind: kindSchema.nullable(), + primaryCount: z.number().int().min(0), + secondaryCounts: z + .array(z.object({ kind: kindSchema, count: z.number().int().positive() })) + .max(3), + checkedAt: z.string().nullable(), + stale: z.boolean(), + scheduledAt: z.string().nullable(), + awaitingUpdate: z.boolean(), + primaryTitle: titleSchema.nullable(), + waitingAgents: homeWidgetDetailsSchema.shape.waitingAgents, + scheduledAgents: homeWidgetDetailsSchema.shape.scheduledAgents, + canCreate: z.boolean(), + canApprove: z.boolean(), + approvalKey: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable(), +}); +export type HomeWidgetPresentation = z.infer; +export type HomeWidgetTimelineEntry = { at: number; home: HomeWidgetPresentation }; +export const homeWidgetResponseSchema = homeWidgetDataSchema.extend({ + home: homeWidgetPresentationSchema, + refreshAt: z.number().finite(), + /** Optional for persisted older responses; current server producers always supply it. */ + presentationTimeline: z + .array( + z.object({ + at: z.number().finite(), + home: homeWidgetPresentationSchema, + }) + ) + .max(3) + .optional(), +}); +export type HomeWidgetResponse = z.infer; +export type HomeWidgetSessionRow = GlanceableSessionRow & { + title?: string | null; + approvalKey?: string | null; +}; + +const PRIORITY: readonly GlanceableStatusKind[] = ['needsInput', 'running', 'scheduled', 'idle']; +const ACTIVE_REFRESH_MS = 30 * 60 * 1000; +const QUIET_REFRESH_MS = 2 * 60 * 60 * 1000; + +function refreshInterval(snapshot: GlanceableAgentsSnapshot): number { + return snapshot.status === 'waiting' || + snapshot.status === 'stale' || + snapshot.status === 'expired' || + snapshot.needsInput + snapshot.running + snapshot.scheduled > 0 + ? ACTIVE_REFRESH_MS + : QUIET_REFRESH_MS; +} + +function usableTime(value: string | undefined | null): number | null { + const at = value == null ? NaN : Date.parse(value); + return Number.isFinite(at) ? at : null; +} + +function safeTitle(value: string | undefined | null): string { + // eslint-disable-next-line no-control-regex + const title = (value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim(); + const bounded = title.slice(0, HOME_WIDGET_TITLE_LIMIT); + // Do not end a truncated title in the middle of a UTF-16 surrogate pair. + const last = bounded.charCodeAt(bounded.length - 1); + return last >= 0xd800 && last <= 0xdbff ? bounded.slice(0, -1) : bounded; +} + +/** Same authorized rows as counts, with stable ties and unknown times ranked last. */ +export function buildHomeWidgetDetails( + sessions: readonly HomeWidgetSessionRow[] +): HomeWidgetDetails { + const waiting = sessions + .filter(row => glanceableStatusKind(row.status) === 'needsInput') + .sort( + (a, b) => + Number(b.approvalKey != null) - Number(a.approvalKey != null) || + (usableTime(a.statusUpdatedAt) ?? Infinity) - (usableTime(b.statusUpdatedAt) ?? Infinity) + ); + const scheduled = sessions + .filter(row => row.status === 'scheduled') + .sort( + (a, b) => (usableTime(a.scheduledAt) ?? Infinity) - (usableTime(b.scheduledAt) ?? Infinity) + ); + const primaryKind = PRIORITY.find(kind => + sessions.some(row => glanceableStatusKind(row.status) === kind) + ); + const primary = + primaryKind === 'needsInput' + ? waiting[0] + : primaryKind === 'scheduled' + ? scheduled[0] + : sessions + .filter(row => glanceableStatusKind(row.status) === primaryKind) + .sort( + (a, b) => + (usableTime(b.statusUpdatedAt) ?? -Infinity) - + (usableTime(a.statusUpdatedAt) ?? -Infinity) + )[0]; + return { + approvalKey: waiting.find(row => row.approvalKey != null)?.approvalKey ?? null, + primaryTitle: primary === undefined ? null : safeTitle(primary.title), + waitingAgents: waiting.slice(0, HOME_WIDGET_DETAIL_LIMIT).map(row => ({ + title: safeTitle(row.title), + // The status fold above admits exactly these three statuses. + kind: + row.status === 'permission' + ? 'permission' + : row.status === 'question' + ? 'question' + : 'retry', + })), + scheduledAgents: scheduled.slice(0, HOME_WIDGET_DETAIL_LIMIT).map(row => ({ + title: safeTitle(row.title), + scheduledAt: + usableTime(row.scheduledAt) === null ? null : new Date(row.scheduledAt ?? '').toISOString(), + })), + }; +} + +export function buildHomeWidgetData( + input: Omit & { + sessions: readonly HomeWidgetSessionRow[]; + } +): HomeWidgetData { + const snapshot = buildGlanceableSnapshot(input); + return { + snapshot, + details: + snapshot.status === 'signed_out' || snapshot.status === 'privacy' + ? { ...EMPTY_HOME_WIDGET_DETAILS } + : buildHomeWidgetDetails(input.sessions), + }; +} + +/** Pure policy: counts survive activity expiry; only explicit privacy/auth clears hide them. */ +export function buildHomeWidgetPresentation( + data: HomeWidgetData, + now: number +): HomeWidgetPresentation { + const { snapshot, details } = data; + const terminal = snapshot.status === 'signed_out' || snapshot.status === 'privacy'; + const primaryKind = terminal ? null : (PRIORITY.find(kind => snapshot[kind] > 0) ?? null); + const checkedAt = + terminal || (primaryKind === null && snapshot.status !== 'empty') ? null : snapshot.updatedAt; + const confirmedAt = usableTime(checkedAt); + const scheduledAt = + snapshot.scheduled > 0 && usableTime(snapshot.scheduledAt) !== null + ? snapshot.scheduledAt + : null; + return { + status: terminal + ? snapshot.status === 'privacy' + ? 'privacy' + : 'signed_out' + : primaryKind !== null + ? 'content' + : snapshot.status === 'waiting' + ? 'waiting' + : snapshot.status === 'empty' + ? 'empty' + : 'unavailable', + primaryKind, + primaryCount: primaryKind === null ? 0 : snapshot[primaryKind], + secondaryCounts: terminal + ? [] + : PRIORITY.filter(kind => kind !== primaryKind && snapshot[kind] > 0).map(kind => ({ + kind, + count: snapshot[kind], + })), + checkedAt, + stale: + !terminal && + (snapshot.status === 'stale' || + snapshot.status === 'expired' || + (confirmedAt !== null && now >= confirmedAt + refreshInterval(snapshot))), + scheduledAt: terminal ? null : scheduledAt, + approvalKey: terminal || snapshot.needsInput === 0 ? null : details.approvalKey, + awaitingUpdate: !terminal && scheduledAt !== null && now >= Date.parse(scheduledAt), + primaryTitle: terminal || primaryKind === null ? null : details.primaryTitle, + waitingAgents: terminal || snapshot.needsInput === 0 ? [] : details.waitingAgents, + scheduledAgents: terminal || snapshot.scheduled === 0 ? [] : details.scheduledAgents, + canCreate: !terminal && (primaryKind !== null || snapshot.status !== 'waiting'), + // This is visibility, not approval authority. Every action revalidates the exact current request. + canApprove: !terminal && (snapshot.needsApproval ?? 0) > 0 && details.approvalKey !== null, + }; +} + +/** Requested refresh only; native minimum intervals and OS budgets still apply. */ +export function homeWidgetRefreshAt(data: HomeWidgetData, now: number): number { + const { snapshot } = data; + const terminal = snapshot.status === 'signed_out' || snapshot.status === 'privacy'; + const active = !terminal && snapshot.needsInput + snapshot.running + snapshot.scheduled > 0; + const next = now + (active ? ACTIVE_REFRESH_MS : QUIET_REFRESH_MS); + const wake = terminal || snapshot.scheduled === 0 ? null : usableTime(snapshot.scheduledAt); + // An overdue wake is not proof of running and must not create a busy refresh loop. + return wake !== null && wake > now ? Math.min(next, wake) : next; +} + +/** Native offline aging uses these server/shared-derived boundaries, not a second policy. */ +export function buildHomeWidgetPresentationTimeline( + data: HomeWidgetData, + now: number +): HomeWidgetTimelineEntry[] { + const times = [now]; + const { snapshot } = data; + if (snapshot.status !== 'signed_out' && snapshot.status !== 'privacy') { + const checked = usableTime(buildHomeWidgetPresentation(data, now).checkedAt); + const staleAt = checked === null ? null : checked + refreshInterval(snapshot); + if (staleAt !== null && staleAt > now) times.push(staleAt); + const wake = snapshot.scheduled > 0 ? usableTime(snapshot.scheduledAt) : null; + if (wake !== null && wake > now && wake !== staleAt) times.push(wake); + } + return times + .sort((a, b) => a - b) + .map(at => ({ + at, + home: buildHomeWidgetPresentation(data, at), + })); +} From 1d5044f1b8fa4a1e6aa66418b0fad2e9386c8084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:27:50 +0200 Subject: [PATCH 02/31] feat(web): serve Home widget data with a dedicated read-only credential and APNs widget hints --- .../glanceable-agents-snapshot/route.ts | 7 +- .../api/mobile/widgets/push-token/route.ts | 76 ++++++++ apps/web/src/app/api/mobile/widgets/route.ts | 13 ++ apps/web/src/lib/active-sessions-list.test.ts | 11 ++ apps/web/src/lib/active-sessions-list.ts | 29 ++- .../lib/auth/home-widget-credential.test.ts | 160 +++++++++++++++++ .../src/lib/auth/home-widget-credential.ts | 161 +++++++++++++++++ .../src/lib/auth/resource-delegation.test.ts | 24 +++ apps/web/src/lib/auth/resource-delegation.ts | 1 + .../cloud-agent-client.test.ts | 57 +++++- .../cloud-agent-next/cloud-agent-client.ts | 45 +++++ .../glanceable-agents-snapshot-server.test.ts | 168 +++++++++++++++++- .../lib/glanceable-agents-snapshot-server.ts | 119 ++++++++++--- apps/web/src/lib/home-widget-http.ts | 39 ++++ apps/web/src/lib/home-widget-routes.test.ts | 125 +++++++++++++ .../active-sessions-router.list.test.ts | 97 +++++++++- .../web/src/routers/active-sessions-router.ts | 28 +++ docs/token-issuance-policy.md | 50 ++++++ packages/db/src/schema.ts | 6 +- packages/notifications/package.json | 1 + packages/notifications/src/push-data.test.ts | 25 +++ packages/notifications/src/push-data.ts | 11 +- services/cloud-agent-next/src/server.test.ts | 126 +++++++++++++ services/cloud-agent-next/src/server.ts | 27 +++ .../src/widget-approval-key.ts | 82 +++++++++ .../notifications/src/lib/apns-widget.test.ts | 72 ++++++++ services/notifications/src/lib/apns-widget.ts | 55 ++++++ .../src/lib/glanceable-delivery-deps.ts | 41 +++++ .../src/lib/glanceable-delivery.test.ts | 21 +++ .../src/lib/glanceable-delivery.ts | 8 + 30 files changed, 1631 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/app/api/mobile/widgets/push-token/route.ts create mode 100644 apps/web/src/app/api/mobile/widgets/route.ts create mode 100644 apps/web/src/lib/auth/home-widget-credential.test.ts create mode 100644 apps/web/src/lib/auth/home-widget-credential.ts create mode 100644 apps/web/src/lib/home-widget-http.ts create mode 100644 apps/web/src/lib/home-widget-routes.test.ts create mode 100644 services/cloud-agent-next/src/widget-approval-key.ts create mode 100644 services/notifications/src/lib/apns-widget.test.ts create mode 100644 services/notifications/src/lib/apns-widget.ts diff --git a/apps/web/src/app/api/internal/glanceable-agents-snapshot/route.ts b/apps/web/src/app/api/internal/glanceable-agents-snapshot/route.ts index 3fdadf0392..f0b0a39ade 100644 --- a/apps/web/src/app/api/internal/glanceable-agents-snapshot/route.ts +++ b/apps/web/src/app/api/internal/glanceable-agents-snapshot/route.ts @@ -3,7 +3,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { z } from 'zod'; import { INTERNAL_API_SECRET } from '@kilocode/web-shared/lib/config.server'; -import { buildGlanceableSnapshotForUser } from '@/lib/glanceable-agents-snapshot-server'; +import { buildHomeWidgetResponseForUser } from '@/lib/glanceable-agents-snapshot-server'; import { ensureOrganizationAccess } from '@kilocode/web-shared/routers/organizations/utils'; import type { TRPCContext } from '@kilocode/web-shared/lib/trpc/init'; @@ -56,6 +56,7 @@ export async function POST(req: NextRequest) { } } - const snapshot = await buildGlanceableSnapshotForUser({ userId, organizationId }); - return NextResponse.json(snapshot, { status: 200 }); + const data = await buildHomeWidgetResponseForUser({ userId, organizationId }); + // Separate Home-only envelope field; the snapshot itself stays privacy-minimal. + return NextResponse.json({ ...data.snapshot, homeWidgetDetails: data.details }, { status: 200 }); } diff --git a/apps/web/src/app/api/mobile/widgets/push-token/route.ts b/apps/web/src/app/api/mobile/widgets/push-token/route.ts new file mode 100644 index 0000000000..3b3304844b --- /dev/null +++ b/apps/web/src/app/api/mobile/widgets/push-token/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest } from 'next/server'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { user_activity_tokens } from '@kilocode/db/schema'; +import { buildOpaqueScopeKey } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { authenticateHomeWidget } from '@/lib/auth/home-widget-credential'; +import { homeWidgetJson, homeWidgetRequest } from '@/lib/home-widget-http'; + +const bodySchema = z + .object({ + token: z.string().regex(/^[a-fA-F0-9]{32,512}$/), + enabled: z.boolean(), + }) + .strict(); + +/** Bounded, widget-only registration. Account and organization come solely from the verified credential. */ +export async function POST(request: NextRequest) { + return homeWidgetRequest(async () => { + const principal = await authenticateHomeWidget(request.headers); + const text = await request.text(); + if (text.length > 2048) throw new TRPCError({ code: 'BAD_REQUEST' }); + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new TRPCError({ code: 'BAD_REQUEST' }); + } + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) throw new TRPCError({ code: 'BAD_REQUEST' }); + const { token, enabled } = parsed.data; + const orgPredicate = + principal.organizationId === null + ? isNull(user_activity_tokens.organization_id) + : eq(user_activity_tokens.organization_id, principal.organizationId); + if (!enabled) { + await db + .delete(user_activity_tokens) + .where( + and( + eq(user_activity_tokens.token, token), + eq(user_activity_tokens.user_id, principal.userId), + eq(user_activity_tokens.kind, 'ios_widget'), + orgPredicate + ) + ); + } else { + const registered = await db + .insert(user_activity_tokens) + .values({ + token, + user_id: principal.userId, + organization_id: principal.organizationId, + kind: 'ios_widget', + platform: 'ios', + }) + .onConflictDoUpdate({ + target: [user_activity_tokens.token], + set: { + user_id: principal.userId, + organization_id: principal.organizationId, + updated_at: sql`now()`, + superseded_at: null, + }, + // The installation token binds to the account that currently holds this + // device's widget credential; reload hints carry no data. Live Activity + // rows are never changed here. + setWhere: eq(user_activity_tokens.kind, 'ios_widget'), + }) + .returning({ id: user_activity_tokens.id }); + if (registered.length === 0) throw new TRPCError({ code: 'CONFLICT' }); + } + return homeWidgetJson({ success: true, scopeKey: buildOpaqueScopeKey(principal) }); + }); +} diff --git a/apps/web/src/app/api/mobile/widgets/route.ts b/apps/web/src/app/api/mobile/widgets/route.ts new file mode 100644 index 0000000000..3eba658687 --- /dev/null +++ b/apps/web/src/app/api/mobile/widgets/route.ts @@ -0,0 +1,13 @@ +import { type NextRequest } from 'next/server'; +import { authenticateHomeWidget } from '@/lib/auth/home-widget-credential'; +import { buildHomeWidgetResponseForUser } from '@/lib/glanceable-agents-snapshot-server'; +import { homeWidgetJson, homeWidgetRequest } from '@/lib/home-widget-http'; + +/** The read-only widget audience is accepted here, never by ordinary tRPC authentication. */ +export async function GET(request: NextRequest) { + return homeWidgetRequest(async () => { + const principal = await authenticateHomeWidget(request.headers); + const response = await buildHomeWidgetResponseForUser(principal); + return homeWidgetJson(response); + }); +} diff --git a/apps/web/src/lib/active-sessions-list.test.ts b/apps/web/src/lib/active-sessions-list.test.ts index bda564c6e0..2dc98503c6 100644 --- a/apps/web/src/lib/active-sessions-list.test.ts +++ b/apps/web/src/lib/active-sessions-list.test.ts @@ -68,6 +68,17 @@ beforeEach(() => { }); describe('listActiveSessions cloud candidates', () => { + it('rejects an unconfigured source instead of confirming empty Home work', async () => { + await expect( + listActiveSessions({ + userId: 'user-1', + organizationId: null, + includeCloudAgentSessions: true, + requireCompleteSnapshot: true, + }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect(selectMock).not.toHaveBeenCalled(); + }); it('applies a hard ceiling far above the live-agent target', async () => { const result = await listActiveSessions({ userId: 'user-1', diff --git a/apps/web/src/lib/active-sessions-list.ts b/apps/web/src/lib/active-sessions-list.ts index 01013d55bc..62d148716f 100644 --- a/apps/web/src/lib/active-sessions-list.ts +++ b/apps/web/src/lib/active-sessions-list.ts @@ -354,6 +354,8 @@ export type ListActiveSessionsInput = { organizationId: string | null | undefined; /** When true, also merge live cloud-agent root sessions from Postgres. */ includeCloudAgentSessions: boolean; + /** Snapshot refreshes must fail rather than confirm partial/empty work on an upstream outage. */ + requireCompleteSnapshot?: boolean; }; /** @@ -366,15 +368,19 @@ export async function listActiveSessions({ userId, organizationId, includeCloudAgentSessions, + requireCompleteSnapshot = false, }: ListActiveSessionsInput): Promise<{ sessions: ActiveSession[] }> { - // Phase 1: fetch + parse the worker response. Any failure here - // (HTTP error, malformed JSON, schema mismatch) degrades to an empty - // list exactly as before — these are "no data" outcomes from the - // mobile client's point of view. With includeCloudAgentSessions, all - // three early exits fall through to the cloud-candidates query (D11). + // Normal list callers retain best-effort behavior. Background snapshots + // require an authoritative read so outages never erase last-known work. let parsed: { sessions: ActiveSession[] } = { sessions: [] }; if (!SESSION_INGEST_WORKER_URL) { + if (requireCompleteSnapshot) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Session ingest is not configured', + }); + } if (!includeCloudAgentSessions) { return { sessions: [] as ActiveSession[] }; } @@ -395,6 +401,12 @@ export async function listActiveSessions({ }); if (!response.ok) { + if (requireCompleteSnapshot) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch active sessions', + }); + } console.warn( `[active-sessions] fetch failed: ${response.status} ${response.statusText}`, await response.text().catch(() => '') @@ -407,6 +419,13 @@ export async function listActiveSessions({ parsed = activeSessionsResponseSchema.parse(raw); } } catch (error) { + if (requireCompleteSnapshot) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch active sessions', + cause: error, + }); + } console.warn('[active-sessions] error:', error); if (!includeCloudAgentSessions) { return { sessions: [] as ActiveSession[] }; diff --git a/apps/web/src/lib/auth/home-widget-credential.test.ts b/apps/web/src/lib/auth/home-widget-credential.test.ts new file mode 100644 index 0000000000..05df613a0a --- /dev/null +++ b/apps/web/src/lib/auth/home-widget-credential.test.ts @@ -0,0 +1,160 @@ +import type { + User, + DeviceSession, + DeviceRefreshToken, + Organization, + OrganizationMembership, +} from '@kilocode/db/schema'; +import jwt from 'jsonwebtoken'; +import { getResourceDelegationAuthority } from './resource-delegation'; +import { validateAuthorizationHeader } from '@kilocode/web-shared/lib/tokens'; +import { + authenticateHomeWidget, + issueHomeWidgetCredential, + HOME_WIDGET_AUDIENCE, + HOME_WIDGET_CREDENTIAL_SECONDS, +} from './home-widget-credential'; + +jest.mock('./resource-delegation', () => ({ getResourceDelegationAuthority: jest.fn() })); +jest.mock('@kilocode/web-shared/lib/config.server', () => ({ + NEXTAUTH_SECRET: 'widget-contract-test-secret', +})); +jest.mock('@kilocode/web-shared/lib/drizzle', () => ({ + db: { + query: { + kilocode_users: { findFirst: jest.fn() }, + device_sessions: { findFirst: jest.fn() }, + device_refresh_tokens: { findFirst: jest.fn() }, + organizations: { findFirst: jest.fn() }, + organization_memberships: { findFirst: jest.fn() }, + }, + }, +})); +import { db } from '@kilocode/web-shared/lib/drizzle'; + +const user = { + id: 'oauth/widget-user', + api_token_pepper: 'current-pepper', + blocked_at: null, + blocked_reason: null, +} as User; +const organizationId = '11111111-1111-4111-8111-111111111111'; +const deviceSessionId = '22222222-2222-4222-8222-222222222222'; +const authority = jest.mocked(getResourceDelegationAuthority); +const currentUser = jest.mocked(db.query.kilocode_users.findFirst); +const session = jest.mocked(db.query.device_sessions.findFirst); +const refresh = jest.mocked(db.query.device_refresh_tokens.findFirst); +const organization = jest.mocked(db.query.organizations.findFirst); +const membership = jest.mocked(db.query.organization_memberships.findFirst); + +function headers(token: string): Headers { + return new Headers({ Authorization: `Bearer ${token}` }); +} + +beforeEach(() => { + jest.clearAllMocks(); + currentUser.mockResolvedValue(user); + session.mockResolvedValue({ id: deviceSessionId } as DeviceSession); + refresh.mockResolvedValue({ token_hash: 'unconsumed' } as DeviceRefreshToken); + organization.mockResolvedValue({ id: organizationId } as Organization); + membership.mockResolvedValue({ organization_id: organizationId } as OrganizationMembership); + authority.mockResolvedValue({ + user, + credentialKind: 'device-access', + deviceSessionId, + isModern: false, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }); +}); + +describe('dedicated Home widget credential', () => { + it('binds verified device/account/org authority, lasts thirty days, and cannot authorize ordinary APIs', async () => { + const requestHeaders = headers('source-access-token'); + const issued = await issueHomeWidgetCredential(user, organizationId, requestHeaders); + expect(authority).toHaveBeenCalledWith(user, { headers: requestHeaders, organizationId }); + const claims = jwt.verify(issued.token, 'widget-contract-test-secret', { + audience: HOME_WIDGET_AUDIENCE, + }); + expect(claims).toMatchObject({ + widgetUserId: user.id, + deviceSessionId, + organizationId, + tokenPurpose: 'home-widget', + }); + if (typeof claims === 'string' || claims.exp === undefined || claims.iat === undefined) + throw new Error('claims missing'); + expect(claims.exp - claims.iat).toBe(HOME_WIDGET_CREDENTIAL_SECONDS); + expect(issued.expiresAt).toBe(claims.exp * 1000); + expect(await authenticateHomeWidget(headers(issued.token))).toEqual({ + userId: user.id, + organizationId, + }); + expect(validateAuthorizationHeader(headers(issued.token))).toHaveProperty('error'); + }); + + it('refuses browser or human API principals without a native device session', async () => { + authority.mockResolvedValue({ + user, + credentialKind: 'human-api', + isModern: false, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }); + await expect(issueHomeWidgetCredential(user, null, new Headers())).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it.each([ + 'user missing', + 'blocked', + 'pepper rotated', + 'session revoked', + 'session expired', + 'org removed', + 'membership removed', + ])('rejects %s on every read', async failure => { + const issued = await issueHomeWidgetCredential(user, organizationId); + if (failure === 'user missing') currentUser.mockResolvedValue(undefined); + if (failure === 'blocked') + currentUser.mockResolvedValue({ ...user, blocked_reason: 'blocked' }); + if (failure === 'pepper rotated') + currentUser.mockResolvedValue({ ...user, api_token_pepper: 'rotated' }); + if (failure === 'session revoked') session.mockResolvedValue(undefined); + if (failure === 'session expired') refresh.mockResolvedValue(undefined); + if (failure === 'org removed') organization.mockResolvedValue(undefined); + if (failure === 'membership removed') membership.mockResolvedValue(undefined); + await expect(authenticateHomeWidget(headers(issued.token))).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it.each(['wrong audience', 'wrong environment', 'expired', 'future issuance', 'wrong signature'])( + 'rejects %s before database access', + async failure => { + const issued = await issueHomeWidgetCredential(user, null); + const claims = jwt.verify(issued.token, 'widget-contract-test-secret'); + if (typeof claims === 'string') throw new Error('claims missing'); + if (failure === 'wrong audience') claims.aud = 'kilo-api'; + if (failure === 'wrong environment') claims.env = 'other-environment'; + if (failure === 'expired') claims.exp = Math.floor(Date.now() / 1000) - 1; + if (failure === 'future issuance') claims.iat = Math.floor(Date.now() / 1000) + 60; + const token = jwt.sign( + claims, + failure === 'wrong signature' ? 'wrong-secret' : 'widget-contract-test-secret' + ); + currentUser.mockClear(); + await expect(authenticateHomeWidget(headers(token))).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(currentUser).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/web/src/lib/auth/home-widget-credential.ts b/apps/web/src/lib/auth/home-widget-credential.ts new file mode 100644 index 0000000000..344cfa29dd --- /dev/null +++ b/apps/web/src/lib/auth/home-widget-credential.ts @@ -0,0 +1,161 @@ +import 'server-only'; + +import type { User } from '@kilocode/db/schema'; +import { + device_refresh_tokens, + device_sessions, + kilocode_users, + organization_memberships, + organizations, +} from '@kilocode/db/schema'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { NEXTAUTH_SECRET } from '@kilocode/web-shared/lib/config.server'; +import { and, eq, gt, isNull } from 'drizzle-orm'; +import jwt from 'jsonwebtoken'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { getResourceDelegationAuthority } from './resource-delegation'; + +export const HOME_WIDGET_AUDIENCE = 'kilo-home-widget'; +export const HOME_WIDGET_CREDENTIAL_SECONDS = 30 * 24 * 60 * 60; + +const claimsSchema = z + .object({ + widgetUserId: z.string().min(1), + apiTokenPepper: z.string().nullable(), + organizationId: z.uuid().nullable(), + deviceSessionId: z.uuid().optional(), + env: z.string(), + tokenPurpose: z.literal('home-widget'), + aud: z.literal(HOME_WIDGET_AUDIENCE), + iat: z.number().int().nonnegative(), + exp: z.number().int().positive(), + }) + .strict(); +type WidgetClaims = z.infer; +export type HomeWidgetPrincipal = { userId: string; organizationId: string | null }; + +function unauthorized(): never { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Widget credential is no longer authorized', + }); +} + +async function assertCurrentPrincipal( + claims: Pick< + WidgetClaims, + 'widgetUserId' | 'apiTokenPepper' | 'deviceSessionId' | 'organizationId' + > +): Promise { + const user = await db.query.kilocode_users.findFirst({ + where: eq(kilocode_users.id, claims.widgetUserId), + }); + if ( + !user || + user.blocked_at || + user.blocked_reason || + user.api_token_pepper !== claims.apiTokenPepper + ) { + unauthorized(); + } + if (claims.deviceSessionId !== undefined) { + const session = await db.query.device_sessions.findFirst({ + where: and( + eq(device_sessions.id, claims.deviceSessionId), + eq(device_sessions.kilo_user_id, user.id), + isNull(device_sessions.revoked_at) + ), + }); + if (!session) unauthorized(); + // Sessions have no expires_at. Their live, unconsumed refresh credential + // defines the device-auth lifetime; widgets never consume or rotate it. + const refresh = await db.query.device_refresh_tokens.findFirst({ + where: and( + eq(device_refresh_tokens.device_session_id, session.id), + isNull(device_refresh_tokens.consumed_at), + gt(device_refresh_tokens.expires_at, new Date().toISOString()) + ), + }); + if (!refresh) unauthorized(); + } + if (claims.organizationId !== null) { + const organization = await db.query.organizations.findFirst({ + where: and(eq(organizations.id, claims.organizationId), isNull(organizations.deleted_at)), + }); + const membership = await db.query.organization_memberships.findFirst({ + where: and( + eq(organization_memberships.kilo_user_id, user.id), + eq(organization_memberships.organization_id, claims.organizationId) + ), + }); + if (!organization || !membership) unauthorized(); + } +} + +/** Only native device sessions receive this long-lived read credential; each read rechecks that session. */ +export async function issueHomeWidgetCredential( + user: User, + organizationId: string | null, + requestHeaders?: Headers +): Promise<{ token: string; expiresAt: number }> { + const authority = await getResourceDelegationAuthority(user, { + headers: requestHeaders, + organizationId: organizationId ?? undefined, + }); + if (authority.user.id !== user.id || authority.deviceSessionId === undefined) { + unauthorized(); + } + const now = Math.floor(Date.now() / 1000); + if (authority.expiresAt !== undefined && authority.expiresAt <= now) unauthorized(); + const claims = { + widgetUserId: authority.user.id, + apiTokenPepper: authority.user.api_token_pepper, + organizationId, + ...(authority.deviceSessionId === undefined + ? {} + : { deviceSessionId: authority.deviceSessionId }), + env: process.env.NODE_ENV, + tokenPurpose: 'home-widget' as const, + aud: HOME_WIDGET_AUDIENCE, + iat: now, + exp: now + HOME_WIDGET_CREDENTIAL_SECONDS, + }; + const validated = claimsSchema.parse(claims); + await assertCurrentPrincipal(validated); + return { + token: jwt.sign(validated, NEXTAUTH_SECRET, { algorithm: 'HS256' }), + expiresAt: validated.exp * 1000, + }; +} + +/** Only dedicated native widget routes call this verifier; ordinary API auth rejects this audience. */ +export async function authenticateHomeWidget( + requestHeaders: Headers +): Promise { + const bearer = requestHeaders.get('authorization')?.match(/^Bearer (\S+)$/i)?.[1]; + if (!bearer) unauthorized(); + let claims: WidgetClaims; + try { + claims = claimsSchema.parse( + jwt.verify(bearer, NEXTAUTH_SECRET, { + algorithms: ['HS256'], + audience: HOME_WIDGET_AUDIENCE, + }) + ); + } catch { + unauthorized(); + } + const now = Math.floor(Date.now() / 1000); + if ( + claims.env !== process.env.NODE_ENV || + claims.iat > now || + claims.exp <= now || + claims.exp <= claims.iat || + claims.exp - claims.iat > HOME_WIDGET_CREDENTIAL_SECONDS + ) { + unauthorized(); + } + await assertCurrentPrincipal(claims); + return { userId: claims.widgetUserId, organizationId: claims.organizationId }; +} diff --git a/apps/web/src/lib/auth/resource-delegation.test.ts b/apps/web/src/lib/auth/resource-delegation.test.ts index abccdd04cc..9da003ce0d 100644 --- a/apps/web/src/lib/auth/resource-delegation.test.ts +++ b/apps/web/src/lib/auth/resource-delegation.test.ts @@ -294,6 +294,30 @@ describe('resource delegation authority', () => { }); }); + test('preserves verified legacy device sid for separately scoped widget issuance', async () => { + const current = await user(); + const [session] = await db + .insert(device_sessions) + .values({ kilo_user_id: current.id, user_agent: 'widget-delegation-test' }) + .returning({ id: device_sessions.id }); + const token = jwt.sign( + { + version: 3, + kiloUserId: current.id, + apiTokenPepper: current.api_token_pepper, + env: process.env.NODE_ENV, + deviceSessionId: session.id, + }, + secret, + { algorithm: 'HS256', expiresIn: 60 * 60 } + ); + const authority = await getResourceDelegationAuthority(current, { headers: bearer(token) }); + expect(authority).toMatchObject({ + credentialKind: 'device-access', + deviceSessionId: session.id, + }); + }); + test('requires an active owned device session for a modern device credential', async () => { const current = await user(); const [session] = await db diff --git a/apps/web/src/lib/auth/resource-delegation.ts b/apps/web/src/lib/auth/resource-delegation.ts index 01579ec0da..2182bfa55d 100644 --- a/apps/web/src/lib/auth/resource-delegation.ts +++ b/apps/web/src/lib/auth/resource-delegation.ts @@ -334,6 +334,7 @@ export async function getResourceDelegationAuthority( credentialKind: isLegacyDevice ? 'device-access' : 'human-api', expiresAt: claims.exp, isModern: false, + deviceSessionId: claims.deviceSessionId, runtimeAdmission: { source: 'user', authorizationUserId: user.id, diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts index fd46aed967..6096a19129 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts @@ -104,8 +104,12 @@ beforeEach(() => { // expose it) so the test exercises the actual fetch call, not a stub. const realCloudAgentClientModule = jest.requireActual('./cloud-agent-client'); -const { closeCloudAgentOrgStreams, CloudAgentNextClient, createAppBuilderCloudAgentNextClient } = - realCloudAgentClientModule; +const { + closeCloudAgentOrgStreams, + CloudAgentNextClient, + createAppBuilderCloudAgentNextClient, + readCloudAgentWidgetApprovalKey, +} = realCloudAgentClientModule; describe('CloudAgentNextClient review message results', () => { const input = { @@ -1246,3 +1250,52 @@ describe('closeCloudAgentOrgStreams', () => { ); }); }); + +describe('readCloudAgentWidgetApprovalKey', () => { + const originalFetch = global.fetch; + const input = { + userId: 'usr_1', + organizationId: null, + kiloSessionId: 'ses_0123456789abcdefghijklmnop', + cloudAgentSessionId: 'workspace_12345678-1234-4234-9234-123456789abc', + }; + + afterEach(() => { + global.fetch = originalFetch; + }); + + function respond(response: Response) { + const fetchMock = jest + .fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>() + .mockResolvedValue(response); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + it('POSTs the exact owned scope with the internal key and returns only the binding', async () => { + const key = 'b'.repeat(64); + const fetchMock = respond(new Response(JSON.stringify({ approvalKey: key }))); + + await expect(readCloudAgentWidgetApprovalKey(input)).resolves.toBe(key); + expect(fetchMock).toHaveBeenCalledWith( + 'http://cloud-agent-next/internal/widgets/approval-key', + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-api-key': 'test-secret' }, + body: JSON.stringify(input), + }) + ); + }); + + it('rejects a response that is not a lowercase SHA-256 binding', async () => { + respond(new Response(JSON.stringify({ approvalKey: 'per_raw_permission' }))); + await expect(readCloudAgentWidgetApprovalKey(input)).rejects.toThrow(); + }); + + it('throws on a refused read without echoing the body', async () => { + respond(new Response('Session access denied ses_private', { status: 403 })); + await expect(readCloudAgentWidgetApprovalKey(input)).rejects.toThrow( + /^Cloud Agent widget approval key failed: 403$/ + ); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 3f4621d7d1..5d92d531a2 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -31,6 +31,7 @@ import { SandboxStatusSnapshotSchema, type SandboxStatusSnapshot, } from '../../../../../services/cloud-agent-next/src/shared/sandbox-status'; +import { fetchWithinBudget } from '@/lib/bounded-service-fetch'; export type { SendMessagePayload } from './types.js'; /** @@ -1287,3 +1288,47 @@ export async function closeCloudAgentOrgStreams( ); } } + +// Optional enrichment that runs after the active-session read inside the native +// widget route's ten-second deadline: a slow identity read drops only the key. +const WIDGET_APPROVAL_KEY_BUDGET_MS = 1_500; + +const widgetApprovalKeyResponseSchema = z.object({ + approvalKey: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable(), +}); + +/** + * The Home widget's binding of the oldest pending permission on one owned + * control-plane session: SHA-256 of `JSON.stringify([kiloSessionId, + * permissionId])`, computed in the Worker so the raw permission never reaches + * this process. The Worker re-checks user, organization scope and the + * Kilo/cloud session pairing; the key grants no authority. Bounded, and throws + * on any non-OK answer so the caller can drop the key while keeping counts. + */ +export async function readCloudAgentWidgetApprovalKey(input: { + userId: string; + organizationId: string | null; + kiloSessionId: string; + cloudAgentSessionId: string; +}): Promise { + const response = await fetchWithinBudget( + `${CLOUD_AGENT_NEXT_API_URL}/internal/widgets/approval-key`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': INTERNAL_API_SECRET, + }, + body: JSON.stringify(input), + cache: 'no-store', + signal: AbortSignal.timeout(WIDGET_APPROVAL_KEY_BUDGET_MS), + } + ); + if (!response.ok) { + throw new Error(`Cloud Agent widget approval key failed: ${response.status}`); + } + return widgetApprovalKeyResponseSchema.parse(await response.json()).approvalKey; +} diff --git a/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts b/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts index b5b986c1a6..bc5891ff39 100644 --- a/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts +++ b/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts @@ -1,19 +1,38 @@ import type { ActiveSession } from '@/lib/active-sessions-list'; import { listActiveSessions } from '@/lib/active-sessions-list'; +import { readCloudAgentWidgetApprovalKey } from '@/lib/cloud-agent-next/cloud-agent-client'; +import { db } from '@kilocode/web-shared/lib/drizzle'; jest.mock('@/lib/active-sessions-list', () => ({ listActiveSessions: jest.fn(), })); +jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ + readCloudAgentWidgetApprovalKey: jest.fn(), +})); +jest.mock('@kilocode/web-shared/lib/drizzle', () => ({ db: { select: jest.fn() } })); -import { buildGlanceableSnapshotForUser } from './glanceable-agents-snapshot-server'; +import { buildHomeWidgetResponseForUser } from './glanceable-agents-snapshot-server'; const mockedListActiveSessions = listActiveSessions as jest.MockedFunction< typeof listActiveSessions >; +const mockedReadApprovalKey = jest.mocked(readCloudAgentWidgetApprovalKey); +const limit = jest.fn( + async (_count: number): Promise<{ cloudAgentSessionId: string | null }[]> => [] +); +const where = jest.fn(() => ({ limit })); + +const KEY = 'a'.repeat(64); +const CLOUD_SESSION = 'workspace_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; -describe('buildGlanceableSnapshotForUser', () => { +describe('buildHomeWidgetResponseForUser', () => { beforeEach(() => { mockedListActiveSessions.mockReset(); + mockedReadApprovalKey.mockReset(); + limit.mockReset(); + limit.mockResolvedValue([]); + jest.mocked(db.select).mockReset(); + jest.mocked(db.select).mockReturnValue({ from: () => ({ where }) } as never); }); it('copies no forbidden session field into the snapshot', async () => { @@ -37,7 +56,7 @@ describe('buildGlanceableSnapshotForUser', () => { ]; mockedListActiveSessions.mockResolvedValue({ sessions }); - const snapshot = await buildGlanceableSnapshotForUser({ + const { snapshot } = await buildHomeWidgetResponseForUser({ userId: 'oauth/user-1', organizationId: 'org-9', }); @@ -72,7 +91,7 @@ describe('buildGlanceableSnapshotForUser', () => { ], }); - const snapshot = await buildGlanceableSnapshotForUser({ + const { snapshot } = await buildHomeWidgetResponseForUser({ userId: 'oauth/user-1', organizationId: null, }); @@ -80,4 +99,145 @@ describe('buildGlanceableSnapshotForUser', () => { expect(snapshot.running).toBe(1); expect(snapshot.needsInputSince).toBeNull(); }); + + it('builds private Home details and generic counts from one complete authorized list', async () => { + mockedListActiveSessions.mockResolvedValue({ + sessions: [ + { + id: 'raw-session', + status: 'permission', + title: 'Home only', + connectionId: 'raw-connection', + }, + ], + }); + const response = await buildHomeWidgetResponseForUser({ + userId: 'oauth/user-1', + organizationId: null, + }); + expect(mockedListActiveSessions).toHaveBeenCalledTimes(1); + expect(mockedListActiveSessions).toHaveBeenCalledWith({ + userId: 'oauth/user-1', + organizationId: null, + includeCloudAgentSessions: true, + requireCompleteSnapshot: true, + }); + expect(response.snapshot.needsInput).toBe(1); + expect(response.home.primaryKind).toBe('needsInput'); + expect(response.home.primaryTitle).toBe('Home only'); + expect(response.refreshAt).toBe(Date.parse(response.snapshot.updatedAt) + 30 * 60 * 1000); + expect(JSON.stringify(response.snapshot)).not.toContain('Home only'); + expect(JSON.stringify(response)).not.toContain('raw-session'); + expect(JSON.stringify(response)).not.toContain('raw-connection'); + }); + + it('does not synthesize a successful Home response from an upstream failure', async () => { + mockedListActiveSessions.mockRejectedValue(new Error('source unavailable')); + await expect( + buildHomeWidgetResponseForUser({ userId: 'oauth/user-1', organizationId: null }) + ).rejects.toThrow('source unavailable'); + }); + + describe('approval identity', () => { + const permissionRows: ActiveSession[] = [ + { + id: 'ses_newer_permission', + status: 'permission', + title: 'Newer approval', + connectionId: 'cloud-agent', + statusUpdatedAt: '2026-10-09T09:00:00.000Z', + }, + { id: 'ses_running', status: 'busy', title: 'Running', connectionId: 'cloud-agent' }, + { + id: 'ses_oldest_permission', + status: 'permission', + title: 'Oldest approval', + connectionId: 'cloud-agent', + statusUpdatedAt: '2026-10-09T08:00:00.000Z', + }, + ]; + + it('binds only the oldest approvable cloud permission and ranks it first', async () => { + mockedListActiveSessions.mockResolvedValue({ sessions: permissionRows }); + limit.mockResolvedValue([{ cloudAgentSessionId: CLOUD_SESSION }]); + mockedReadApprovalKey.mockResolvedValue(KEY); + + const response = await buildHomeWidgetResponseForUser({ + userId: 'oauth/user-1', + organizationId: 'org-9', + }); + + expect(mockedReadApprovalKey).toHaveBeenCalledTimes(1); + expect(mockedReadApprovalKey).toHaveBeenCalledWith({ + userId: 'oauth/user-1', + organizationId: 'org-9', + kiloSessionId: 'ses_oldest_permission', + cloudAgentSessionId: CLOUD_SESSION, + }); + expect(response.details.approvalKey).toBe(KEY); + expect(response.home).toMatchObject({ + approvalKey: KEY, + canApprove: true, + primaryTitle: 'Oldest approval', + }); + expect(JSON.stringify(response.snapshot)).not.toContain(KEY); + const json = JSON.stringify(response); + for (const raw of ['ses_oldest_permission', 'ses_newer_permission', CLOUD_SESSION]) { + expect(json).not.toContain(raw); + } + }); + + it('keeps valid counts and omits approval when the identity read fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + mockedListActiveSessions.mockResolvedValue({ sessions: permissionRows }); + limit.mockResolvedValue([{ cloudAgentSessionId: CLOUD_SESSION }]); + mockedReadApprovalKey.mockRejectedValue(new Error(`upstream ${CLOUD_SESSION} perm_secret`)); + + const response = await buildHomeWidgetResponseForUser({ + userId: 'oauth/user-1', + organizationId: null, + }); + + expect(response.snapshot.needsInput).toBe(2); + expect(response.snapshot.running).toBe(1); + expect(response.details.approvalKey).toBeNull(); + expect(response.home).toMatchObject({ approvalKey: null, canApprove: false }); + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain(CLOUD_SESSION); + expect(logged).not.toContain('perm_secret'); + expect(logged).not.toContain('ses_oldest_permission'); + expect(logged).not.toContain('oauth/user-1'); + warn.mockRestore(); + }); + + it('asks for no identity when the approvable row is a remote CLI session', async () => { + mockedListActiveSessions.mockResolvedValue({ sessions: permissionRows }); + limit.mockResolvedValue([{ cloudAgentSessionId: null }]); + + const response = await buildHomeWidgetResponseForUser({ + userId: 'oauth/user-1', + organizationId: null, + }); + + expect(mockedReadApprovalKey).not.toHaveBeenCalled(); + expect(response.details.approvalKey).toBeNull(); + }); + + it('reads nothing when no permission row waits', async () => { + mockedListActiveSessions.mockResolvedValue({ + sessions: [ + { id: 'ses_question', status: 'question', title: 'Answer', connectionId: 'cloud-agent' }, + ], + }); + + const response = await buildHomeWidgetResponseForUser({ + userId: 'oauth/user-1', + organizationId: null, + }); + + expect(db.select).not.toHaveBeenCalled(); + expect(mockedReadApprovalKey).not.toHaveBeenCalled(); + expect(response.home.canApprove).toBe(false); + }); + }); }); diff --git a/apps/web/src/lib/glanceable-agents-snapshot-server.ts b/apps/web/src/lib/glanceable-agents-snapshot-server.ts index 8eff426466..a68effc0cf 100644 --- a/apps/web/src/lib/glanceable-agents-snapshot-server.ts +++ b/apps/web/src/lib/glanceable-agents-snapshot-server.ts @@ -1,40 +1,107 @@ import 'server-only'; import { - buildGlanceableSnapshot, - type GlanceableAgentsSnapshot, -} from '@kilocode/app-shared/glanceable-agents-snapshot'; + buildHomeWidgetData, + buildHomeWidgetPresentation, + buildHomeWidgetPresentationTimeline, + homeWidgetRefreshAt, + type HomeWidgetResponse, + type HomeWidgetSessionRow, +} from '@kilocode/app-shared/home-widget'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { and, eq, isNull } from 'drizzle-orm'; -import { listActiveSessions } from '@/lib/active-sessions-list'; +import { type ActiveSession, listActiveSessions } from '@/lib/active-sessions-list'; +import { readCloudAgentWidgetApprovalKey } from '@/lib/cloud-agent-next/cloud-agent-client'; + +type HomeWidgetScope = { userId: string; organizationId: string | null }; + +/** + * The permission row the widget offers Approve for: the `permission` row with + * the earliest usable `statusUpdatedAt`, timestamp-less rows last, list order + * breaking ties. Mirrors mobile `pickFrontApprovableSession`, so the key binds + * the row the app re-validates against. + */ +function pickFrontPermissionRow(sessions: readonly ActiveSession[]): ActiveSession | null { + let front: ActiveSession | null = null; + let frontAt: number | null = null; + for (const row of sessions) { + if (row.status !== 'permission') continue; + const parsed = row.statusUpdatedAt === undefined ? Number.NaN : Date.parse(row.statusUpdatedAt); + const at = Number.isNaN(parsed) ? null : parsed; + if (front === null || (at !== null && (frontAt === null || at < frontAt))) { + front = row; + frontAt = at; + } + } + return front; +} /** - * Server-side snapshot for background glanceable delivery (Live Activity, - * widgets, Android ongoing). Builds the same versioned, privacy-minimal shape - * the mobile publisher derives from its own tray cache — the snapshot is the - * extracted active-sessions list, never a second session source. - * - * The shared `buildGlanceableSnapshot` reads each session's `status` and - * optional `scheduledAt`, so title, git, id, and every other raw field are - * structurally excluded from the output. `accountEpoch` is intentionally - * omitted: the mobile client applies its own local epoch when it adopts a - * remote snapshot. + * SHA-256 binding of the one approvable permission, or null. The cloud session + * lookup is scoped to the same user and organization as the counts, and the + * Worker re-checks that scope before reading the Session DO's stored pending + * set (no sandbox wake). Any failure drops only the key: counts and titles stay + * valid and nothing is fabricated. Logs carry no session, permission or user ids. */ -export async function buildGlanceableSnapshotForUser({ +async function readApprovalKey( + scope: HomeWidgetScope, + front: ActiveSession +): Promise { + try { + const [row] = await db + .select({ cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.session_id, front.id), + eq(cli_sessions_v2.kilo_user_id, scope.userId), + scope.organizationId === null + ? isNull(cli_sessions_v2.organization_id) + : eq(cli_sessions_v2.organization_id, scope.organizationId) + ) + ) + .limit(1); + // A remote CLI session has no cloud pending set; the app owns its approval. + if (!row?.cloudAgentSessionId) return null; + return await readCloudAgentWidgetApprovalKey({ + ...scope, + kiloSessionId: front.id, + cloudAgentSessionId: row.cloudAgentSessionId, + }); + } catch (error) { + console.warn( + '[home-widget] approval identity unavailable:', + error instanceof Error ? error.name : 'unknown' + ); + return null; + } +} + +/** Authorized callers only. Home details and counts use one complete read. */ +export async function buildHomeWidgetResponseForUser({ userId, organizationId, -}: { - userId: string; - organizationId: string | null; -}): Promise { +}: HomeWidgetScope): Promise { const { sessions } = await listActiveSessions({ userId, organizationId, includeCloudAgentSessions: true, + requireCompleteSnapshot: true, }); - - return buildGlanceableSnapshot({ - sessions, - userId, - organizationId, - now: Date.now(), - }); + const front = pickFrontPermissionRow(sessions); + const approvalKey = + front === null ? null : await readApprovalKey({ userId, organizationId }, front); + const rows: HomeWidgetSessionRow[] = + approvalKey === null + ? sessions + : sessions.map(row => (row === front ? { ...row, approvalKey } : row)); + const now = Date.now(); + const data = buildHomeWidgetData({ sessions: rows, userId, organizationId, now }); + return { + ...data, + home: buildHomeWidgetPresentation(data, now), + refreshAt: homeWidgetRefreshAt(data, now), + presentationTimeline: buildHomeWidgetPresentationTimeline(data, now), + }; } diff --git a/apps/web/src/lib/home-widget-http.ts b/apps/web/src/lib/home-widget-http.ts new file mode 100644 index 0000000000..7dff615ce6 --- /dev/null +++ b/apps/web/src/lib/home-widget-http.ts @@ -0,0 +1,39 @@ +import 'server-only'; +import { NextResponse } from 'next/server'; +import { TRPCError } from '@trpc/server'; +import { RequestDeadlineError, withDeadline } from '@kilocode/event-service'; + +export function homeWidgetJson(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { + status, + headers: { 'Cache-Control': 'no-store, private', Pragma: 'no-cache' }, + }); +} + +/** Bound auth, source reads, and registration even without foreground-app headers. */ +export async function homeWidgetRequest(work: () => Promise): Promise { + try { + return await withDeadline(10_000, work); + } catch (error) { + if (error instanceof TRPCError) { + const status = + error.code === 'UNAUTHORIZED' + ? 401 + : error.code === 'FORBIDDEN' + ? 403 + : error.code === 'BAD_REQUEST' + ? 400 + : error.code === 'CONFLICT' + ? 409 + : 503; + return homeWidgetJson( + { error: status === 401 || status === 403 ? 'Unauthorized' : 'Widget request failed' }, + status + ); + } + return homeWidgetJson( + { error: 'Widget request failed' }, + error instanceof RequestDeadlineError ? 504 : 503 + ); + } +} diff --git a/apps/web/src/lib/home-widget-routes.test.ts b/apps/web/src/lib/home-widget-routes.test.ts new file mode 100644 index 0000000000..74fa6595fd --- /dev/null +++ b/apps/web/src/lib/home-widget-routes.test.ts @@ -0,0 +1,125 @@ +import { NextRequest } from 'next/server'; +import { TRPCError } from '@trpc/server'; +import { + buildHomeWidgetData, + buildHomeWidgetPresentation, + homeWidgetRefreshAt, +} from '@kilocode/app-shared/home-widget'; +import { authenticateHomeWidget } from '@/lib/auth/home-widget-credential'; +import { buildHomeWidgetResponseForUser } from '@/lib/glanceable-agents-snapshot-server'; +import { GET } from '@/app/api/mobile/widgets/route'; +import { POST } from '@/app/api/mobile/widgets/push-token/route'; +import { db } from '@kilocode/web-shared/lib/drizzle'; + +jest.mock('@/lib/auth/home-widget-credential', () => ({ authenticateHomeWidget: jest.fn() })); +jest.mock('@/lib/glanceable-agents-snapshot-server', () => ({ + buildHomeWidgetResponseForUser: jest.fn(), +})); +jest.mock('@kilocode/web-shared/lib/drizzle', () => ({ + db: { insert: jest.fn(), delete: jest.fn() }, +})); + +type MockDb = { insert: jest.Mock; delete: jest.Mock }; +const mockDb = db as unknown as MockDb; + +const token = 'ab'.repeat(32); +const principal = { + userId: 'oauth/widget-user', + organizationId: '11111111-1111-4111-8111-111111111111', +}; +const auth = jest.mocked(authenticateHomeWidget); +const source = jest.mocked(buildHomeWidgetResponseForUser); +const values = jest.fn(); +const conflict = jest.fn(); +const returning = jest.fn(); +const where = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + auth.mockResolvedValue(principal); + const data = buildHomeWidgetData({ ...principal, now: Date.now(), sessions: [] }); + source.mockResolvedValue({ + ...data, + home: buildHomeWidgetPresentation(data, Date.now()), + refreshAt: homeWidgetRefreshAt(data, Date.now()), + }); + returning.mockResolvedValue([{ id: 'widget-row' }]); + conflict.mockReturnValue({ returning }); + values.mockReturnValue({ onConflictDoUpdate: conflict }); + mockDb.insert.mockReturnValue({ values }); + where.mockResolvedValue(undefined); + mockDb.delete.mockReturnValue({ where }); +}); + +function post(body: unknown): NextRequest { + return new NextRequest('https://example.test/api/mobile/widgets/push-token', { + method: 'POST', + headers: { Authorization: 'Bearer dedicated-widget-token' }, + body: JSON.stringify(body), + }); +} + +describe('dedicated native widget routes', () => { + it('reads only the credential scope and returns uncached HomeWidgetResponse JSON', async () => { + const request = new NextRequest( + 'https://example.test/api/mobile/widgets?organizationId=foreign', + { + headers: { Authorization: 'Bearer dedicated-widget-token' }, + } + ); + const response = await GET(request); + expect(auth).toHaveBeenCalledWith(request.headers); + expect(source).toHaveBeenCalledWith(principal); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(await response.json()).toMatchObject({ + snapshot: { status: 'empty' }, + home: { status: 'empty' }, + }); + }); + + it('fails closed before a source read or registration when authentication is revoked', async () => { + auth.mockRejectedValue(new TRPCError({ code: 'UNAUTHORIZED' })); + expect((await GET(new NextRequest('https://example.test/api/mobile/widgets'))).status).toBe( + 401 + ); + expect((await POST(post({ token, enabled: true }))).status).toBe(401); + expect(source).not.toHaveBeenCalled(); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('binds the installation token to the current credential without changing Live Activity rows', async () => { + const response = await POST(post({ token, enabled: true })); + expect(response.status).toBe(200); + expect(values).toHaveBeenCalledWith({ + token, + user_id: principal.userId, + organization_id: principal.organizationId, + kind: 'ios_widget', + platform: 'ios', + }); + expect(conflict.mock.calls[0]?.[0]).toMatchObject({ set: { user_id: principal.userId } }); + expect((await response.json()).success).toBe(true); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + returning.mockResolvedValue([]); + expect((await POST(post({ token, enabled: true }))).status).toBe(409); + }); + + it('unregisters idempotently without touching Live Activity registration', async () => { + expect((await POST(post({ token, enabled: false }))).status).toBe(200); + expect(mockDb.delete).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledTimes(1); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('refuses client-controlled org/kind changes and malformed token payloads', async () => { + for (const body of [ + { token, enabled: true, organizationId: 'foreign' }, + { token, enabled: true, kind: 'ios_activity' }, + { token: 'not-an-apns-token', enabled: true }, + ]) { + expect((await POST(post(body))).status).toBe(400); + } + expect(db.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/active-sessions-router.list.test.ts b/apps/web/src/routers/active-sessions-router.list.test.ts index 974dda8b0e..005bb9f8eb 100644 --- a/apps/web/src/routers/active-sessions-router.list.test.ts +++ b/apps/web/src/routers/active-sessions-router.list.test.ts @@ -19,16 +19,21 @@ jest.mock('@kilocode/web-shared/lib/config.server', () => { let regularUser: User; let otherUser: User; -function mockWorkerSessions(sessions: Array>): jest.SpyInstance { +function mockWorkerSessions( + sessions: Array>, + approvalKey: string | null = null +): jest.SpyInstance { // Fresh Response per call — a single Response body can only be read once. - return jest.spyOn(global, 'fetch').mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify({ sessions }), { + return jest.spyOn(global, 'fetch').mockImplementation(input => { + const url = input instanceof Request ? input.url : String(input); + const body = url.endsWith('/internal/widgets/approval-key') ? { approvalKey } : { sessions }; + return Promise.resolve( + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }) - ) - ); + ); + }); } function mockMalformedWorkerResponse(): jest.SpyInstance { @@ -60,6 +65,86 @@ describe('active-sessions-router.list', () => { fetchSpy?.mockRestore(); }); + it('widgetSnapshot requires membership even for a platform administrator', async () => { + const admin = await insertTestUser({ + google_user_email: 'widget-admin-membership@example.com', + google_user_name: 'Widget Admin', + is_admin: true, + }); + const organization = await createTestOrganization('Admin Foreign Widget Org', otherUser.id, 0); + fetchSpy = mockWorkerSessions([]); + const caller = await createCallerForUser(admin.id); + await expect( + caller.activeSessions.widgetSnapshot({ + organizationId: organization.id, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('widgetSnapshot rejects a foreign organization before reading private session data', async () => { + const organization = await createTestOrganization('Foreign Widget Org', otherUser.id, 0); + fetchSpy = mockWorkerSessions([]); + const caller = await createCallerForUser(regularUser.id); + await expect( + caller.activeSessions.widgetSnapshot({ + organizationId: organization.id, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('widgetSnapshot refuses malformed upstream data instead of confirming empty work', async () => { + fetchSpy = mockMalformedWorkerResponse(); + const caller = await createCallerForUser(regularUser.id); + await expect( + caller.activeSessions.widgetSnapshot({ + organizationId: null, + }) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + }); + + it('widgetSnapshot returns server presentation and Home-only titles from the authorized rows', async () => { + await db.insert(cli_sessions_v2).values({ + session_id: 'ses_widget_home_authorized', + kilo_user_id: regularUser.id, + created_on_platform: 'cloud-agent', + cloud_agent_session_id: 'agent_widget_home_authorized', + }); + fetchSpy = mockWorkerSessions( + [ + { + id: 'ses_widget_home_authorized', + status: 'permission', + title: 'Home-only title', + connectionId: 'conn-widget', + }, + ], + 'a'.repeat(64) + ); + try { + const caller = await createCallerForUser(regularUser.id); + const response = await caller.activeSessions.widgetSnapshot({ organizationId: null }); + expect(response.snapshot).toMatchObject({ needsInput: 1, needsApproval: 1 }); + expect(response.details.waitingAgents).toEqual([ + { title: 'Home-only title', kind: 'permission' }, + ]); + expect(response.home).toMatchObject({ + primaryKind: 'needsInput', + primaryCount: 1, + primaryTitle: 'Home-only title', + canApprove: true, + }); + expect(response.refreshAt).toBe(Date.parse(response.snapshot.updatedAt) + 30 * 60 * 1000); + expect(JSON.stringify(response.snapshot)).not.toContain('Home-only title'); + expect(JSON.stringify(response)).not.toContain('ses_widget_home_authorized'); + } finally { + await db + .delete(cli_sessions_v2) + .where(eq(cli_sessions_v2.session_id, 'ses_widget_home_authorized')); + } + }); + it('merges enrichment fields from cli_sessions_v2 with explicit camelCase keys', async () => { const sessionId = 'ses_active_enrich_match_1234'; const createdAt = '2026-07-01 10:00:00+00'; diff --git a/apps/web/src/routers/active-sessions-router.ts b/apps/web/src/routers/active-sessions-router.ts index 9dada51a68..2667390230 100644 --- a/apps/web/src/routers/active-sessions-router.ts +++ b/apps/web/src/routers/active-sessions-router.ts @@ -14,6 +14,9 @@ import { CLOUD_AGENT_CONNECTION_ID, type ActiveSession, } from '@/lib/active-sessions-list'; +import { homeWidgetResponseSchema } from '@kilocode/app-shared/home-widget'; +import { buildHomeWidgetResponseForUser } from '@/lib/glanceable-agents-snapshot-server'; +import { issueHomeWidgetCredential } from '@/lib/auth/home-widget-credential'; // Re-exported for existing consumers and tests. export { @@ -168,6 +171,31 @@ export const activeSessionsRouter = createTRPCRouter({ }); }), + /** Foreground JS consumers use normal user auth; native background reads use the dedicated widget route. */ + widgetSnapshot: baseProcedure + .input(z.object({ organizationId: z.uuid().nullable() })) + .output(homeWidgetResponseSchema) + .query(async ({ ctx, input }) => { + if (input.organizationId !== null) { + // A personal widget never uses the platform-admin elevation bypass. + await ensureOrganizationAccess( + { ...ctx, user: { ...ctx.user, is_admin: false } }, + input.organizationId + ); + } + return buildHomeWidgetResponseForUser({ + userId: ctx.user.id, + organizationId: input.organizationId, + }); + }), + + widgetCredential: baseProcedure + .input(z.object({ organizationId: z.uuid().nullable() })) + .output(z.object({ token: z.string().min(1), expiresAt: z.number().int().positive() })) + .query(({ ctx, input }) => + issueHomeWidgetCredential(ctx.user, input.organizationId, ctx.headersList) + ), + /** * Live snapshot of every `kilo remote` instance currently connected for the * authenticated user. Unlike `list` (which swallows upstream errors into diff --git a/docs/token-issuance-policy.md b/docs/token-issuance-policy.md index 827b5ca857..ba6c5c1b87 100644 --- a/docs/token-issuance-policy.md +++ b/docs/token-issuance-policy.md @@ -186,3 +186,53 @@ Gastown/Wasteland control issuers are deferred with their delegation adapters: c This PR does not retire legacy native exchange, shorten user credentials, change global pepper/session semantics, or remove ordinary legacy resource access. Workflow issuance reads the existing primary user pepper, including explicit `null`, without initializing or rotating it. Modern workflow tokens retain their audience, purpose, exchange restrictions, and bounded lifetime. This requires no migration and must not reset already initialized peppers; genuine pepper rotation continues to revoke previously issued credentials. + +## Dedicated Home widget credential + +`activeSessions.widgetCredential({ organizationId })` issues a 30-day, +HS256-signed `kilo-home-widget` audience credential and returns +`{ token, expiresAt }`, where `expiresAt` is epoch milliseconds. Issuance uses +`getResourceDelegationAuthority` and requires a verified native device session. +Browser sessions and human API credentials cannot mint it. Every read rechecks +that device session, its live refresh credential, the account pepper, and exact +organization membership. + +This credential is not a general mobile access or refresh token. It cannot +authenticate ordinary tRPC or mutate agent/session state. Only +`GET /api/mobile/widgets` reads Home widget data, and +`POST /api/mobile/widgets/push-token` accepts `{ token, enabled }` to register or +remove an `ios_widget` APNs reload-hint token. A device token moves to the account +that currently holds that device's widget credential; Live Activity rows are never +changed by this route. Both dedicated routes authenticate +the widget audience and derive the exact user/organization scope from signed +claims, rather than client body/query values. Responses use `Cache-Control: +no-store, private`; auth, reads, and registration have a ten-second request bound. + +Home widget data may carry `approvalKey`: SHA-256 (lowercase hex) of +`JSON.stringify([kiloSessionId, permissionId])` for the one oldest approvable +cloud permission. The web backend obtains it from the Cloud Agent Worker's +`POST /internal/widgets/approval-key`, gated by `x-internal-api-key` and never +reachable with the widget credential. The Worker re-checks user, exact +organization scope with live membership, and the Kilo/cloud session pairing, +then reads only the control-plane Session DO's stored pending set (no sandbox +wake) and returns the binding, never the raw permission. The key grants no +authority: the app approves only after re-reading the live pending permission +through its ordinary authenticated routes and matching the same key. + +Every dedicated request verifies signature, algorithm, audience, environment, +purpose, issuance/expiry/lifetime, the current user and pepper, account blocking, +and actual organization membership (including organization deletion). A +device-bound credential additionally requires its owned unrevoked device session +and a live unconsumed refresh credential. Device sessions have no independent +expiry column, so the refresh credential's expiry defines their usable lifetime. +Widget code never reads, consumes, or rotates the app refresh token itself. +Pepper rotation, account blocking/deletion, organization access loss, session +revocation, or refresh expiry immediately invalidates subsequent reads. + +Native storage must protect the credential and clear it with account/scope +changes. Unregister the widget push token before clearing a still-valid +credential where possible. Reload pushes contain no private Home data and are +not authority to restore a cleared account. The native renderer retains its +last-confirmed Home data on transient failures, ages using shared/server-derived +presentation timeline boundaries, and clears private data on explicit auth or +privacy failure. diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3d916b9b4c..82b8830f79 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -10066,7 +10066,7 @@ export type NewUserPushToken = typeof user_push_tokens.$inferInsert; // ─── Activity Tokens (Live Activity / push-to-start / Android ongoing) ── // -// Tokens for the glanceable surfaces (iOS Live Activity + push-to-start, +// Tokens for glanceable surfaces (iOS Live Activity, push-to-start and WidgetKit, // Android ongoing notification). These are NOT Expo push tokens and never // share a table with `user_push_tokens`. `organization_id` is null for the // personal surface and is a server-only lookup key — it never enters a @@ -10084,7 +10084,9 @@ export const user_activity_tokens = pgTable( .notNull() .references(() => kilocode_users.id, { onDelete: 'cascade' }), token: text().notNull(), - kind: text().$type<'ios_push_to_start' | 'ios_activity' | 'android_ongoing'>().notNull(), + kind: text() + .$type<'ios_push_to_start' | 'ios_activity' | 'ios_widget' | 'android_ongoing'>() + .notNull(), platform: text().$type<'ios' | 'android'>().notNull(), // Null means the personal surface. Server-only lookup key; never sent in a // glanceable payload. diff --git a/packages/notifications/package.json b/packages/notifications/package.json index b4a09fe44b..faa5978f66 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -14,6 +14,7 @@ "test": "vitest run" }, "dependencies": { + "@kilocode/app-shared": "workspace:*", "i18next": "^26.3.6", "zod": "catalog:" }, diff --git a/packages/notifications/src/push-data.test.ts b/packages/notifications/src/push-data.test.ts index d131865c2f..73ec76ff96 100644 --- a/packages/notifications/src/push-data.test.ts +++ b/packages/notifications/src/push-data.test.ts @@ -105,6 +105,31 @@ describe('pushDataSchema active_agents_glanceable', () => { needsInputSince: null, } as const; + it('retains bounded Home details beside the generic snapshot', () => { + const homeWidgetDetails = { + primaryTitle: 'Private Home title', + waitingAgents: [{ title: 'Awaiting approval', kind: 'permission' }], + scheduledAgents: [], + }; + const parsed = pushDataSchema.parse({ ...payload, homeWidgetDetails }); + expect(parsed).toMatchObject({ homeWidgetDetails }); + expect( + pushDataSchema.safeParse({ + ...payload, + homeWidgetDetails: { ...homeWidgetDetails, primaryTitle: 'x'.repeat(161) }, + }).success + ).toBe(false); + expect( + pushDataSchema.safeParse({ + ...payload, + homeWidgetDetails: { + ...homeWidgetDetails, + waitingAgents: Array(4).fill(homeWidgetDetails.waitingAgents[0]), + }, + }).success + ).toBe(false); + }); + it('parses a payload from a server without the newest-result keys as null', () => { const parsed = pushDataSchema.parse(payload); if (parsed.type !== 'active_agents_glanceable') { diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts index 8b2f42a496..b16a1734b5 100644 --- a/packages/notifications/src/push-data.ts +++ b/packages/notifications/src/push-data.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { homeWidgetDetailsSchema } from '@kilocode/app-shared/home-widget'; import { instanceLifecycleEventSchema, scheduledActionEventSchema } from './notification-events'; @@ -92,12 +93,9 @@ export const pushDataSchema = z.discriminatedUnion('type', [ remediationId: nonEmptyStringSchema.optional(), prUrl: nonEmptyStringSchema.optional(), }), - // Aggregate glanceable snapshot for the Active Agents Live Activity / widget - // / Android ongoing. Carries generic status, counts, safe timestamps, and an - // opaque scope key only — no titles, ids, or accountEpoch (the client sets - // its local epoch). `status` mirrors the shared glanceable status enum. - // Old clients omit this type; remove the send gate when every client is past - // this release. + // Aggregate snapshot remains privacy-minimal. Optional Home-only details + // travel beside it for background Home widget updates and are never copied + // into Live Activity/ongoing content-state. Older producers omit them. z.object({ type: z.literal('active_agents_glanceable'), schemaVersion: z.literal(1), @@ -140,6 +138,7 @@ export const pushDataSchema = z.discriminatedUnion('type', [ .nullable() .default(null), newestResultAt: z.string().nullable().default(null), + homeWidgetDetails: homeWidgetDetailsSchema.optional(), }), ]); diff --git a/services/cloud-agent-next/src/server.test.ts b/services/cloud-agent-next/src/server.test.ts index b5bf6f88ea..11ff7a9078 100644 --- a/services/cloud-agent-next/src/server.test.ts +++ b/services/cloud-agent-next/src/server.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { parse } from 'jsonc-parser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import jwt from 'jsonwebtoken'; +import { TRPCError } from '@trpc/server'; import { VERCEL_SANDBOX_UNAVAILABLE_MESSAGE } from './agent-sandbox/vercel/vercel-agent-sandbox.js'; import type { Env } from './types.js'; import { mintWrapperDispatchTicket, type WrapperDispatchTicketClaims } from './auth.js'; @@ -2736,6 +2737,131 @@ describe('server /internal/streams/close', () => { }); }); +describe('server /internal/widgets/approval-key', () => { + const cloudAgentSessionId = 'workspace_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const kiloSessionId = 'ses_0123456789abcdefghijklmnop'; + // sha256(JSON.stringify([kiloSessionId, permissionId])), computed with node:crypto. + const KEY_PER_WIDGET_1 = '5af2c75e8f72c8f988b4dddda11194e70bc8bab08c4b6abb9f9c6a5f8f34e099'; + const KEY_PER_WIDGET_2 = '841791022542364fd7f7a8c347f13bd075c52134170cb9dc5d6c81e8c7c1a485'; + + function approvalKeyRequest(body: unknown, internalKey: string | null = 'test-internal-secret') { + return new Request('http://worker.test/internal/widgets/approval-key', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(internalKey === null ? {} : { 'x-internal-api-key': internalKey }), + }, + body: JSON.stringify(body), + }); + } + + function envWithPending(permissions: unknown[]) { + const env = createEnv(); + const getPendingInteractions = vi.fn().mockResolvedValue({ questions: [], permissions }); + env.SANDBOX_SESSION.idFromName.mockReturnValue('control-do-id'); + env.SANDBOX_SESSION.get.mockReturnValue({ getPendingInteractions }); + return { env, getPendingInteractions }; + } + + const body = { userId: 'usr_1', organizationId: 'org_1', kiloSessionId, cloudAgentSessionId }; + + beforeEach(() => { + requireCurrentSessionAccessMock.mockReset(); + requireCurrentSessionAccessMock.mockResolvedValue({ kiloSessionId, organizationId: 'org_1' }); + }); + + it.each([null, 'wrong-key'])('rejects internal key %s before any session read', async key => { + const { env, getPendingInteractions } = envWithPending([{ id: 'per_widget_1' }]); + const response = await fetchWorker(approvalKeyRequest(body, key), env); + + expect(response.status).toBe(401); + expect(requireCurrentSessionAccessMock).not.toHaveBeenCalled(); + expect(getPendingInteractions).not.toHaveBeenCalled(); + }); + + it.each([ + { ...body, organizationId: undefined }, + { ...body, kiloSessionId: 'not-a-kilo-session' }, + { ...body, extra: 'mutation-authority' }, + ])('rejects a malformed body', async invalid => { + const { env, getPendingInteractions } = envWithPending([{ id: 'per_widget_1' }]); + const response = await fetchWorker(approvalKeyRequest(invalid), env); + + expect(response.status).toBe(400); + expect(getPendingInteractions).not.toHaveBeenCalled(); + }); + + it('binds the oldest pending permission of an owned control-plane session', async () => { + const { env, getPendingInteractions } = envWithPending([ + { id: '' }, + { id: 'per_widget_1', permission: 'edit', patterns: ['private/path'] }, + { id: 'per_widget_2' }, + ]); + const response = await fetchWorker(approvalKeyRequest(body), env); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + const text = await response.text(); + expect(JSON.parse(text)).toEqual({ approvalKey: KEY_PER_WIDGET_1 }); + expect(text).not.toContain('per_widget'); + expect(text).not.toContain('private/path'); + expect(requireCurrentSessionAccessMock).toHaveBeenCalledWith({ + env, + kiloUserId: 'usr_1', + cloudAgentSessionId, + expectedOrganizationId: 'org_1', + expectedKiloSessionId: kiloSessionId, + }); + expect(env.SANDBOX_SESSION.idFromName).toHaveBeenCalledWith(`usr_1:${cloudAgentSessionId}`); + expect(getPendingInteractions).toHaveBeenCalledTimes(1); + }); + + it('produces a different key for a different permission id', async () => { + const { env } = envWithPending([{ id: 'per_widget_2' }]); + const response = await fetchWorker(approvalKeyRequest(body), env); + + await expect(response.json()).resolves.toEqual({ approvalKey: KEY_PER_WIDGET_2 }); + expect(KEY_PER_WIDGET_2).not.toBe(KEY_PER_WIDGET_1); + }); + + it('returns a null key when nothing is pending', async () => { + const { env } = envWithPending([]); + const response = await fetchWorker(approvalKeyRequest(body), env); + + await expect(response.json()).resolves.toEqual({ approvalKey: null }); + }); + + it('refuses a session outside the exact user, organization, and session pairing', async () => { + requireCurrentSessionAccessMock.mockRejectedValue( + new TRPCError({ code: 'FORBIDDEN', message: 'Session access denied' }) + ); + const { env, getPendingInteractions } = envWithPending([{ id: 'per_widget_1' }]); + const response = await fetchWorker(approvalKeyRequest({ ...body, organizationId: null }), env); + + expect(response.status).toBe(403); + expect(requireCurrentSessionAccessMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedOrganizationId: null }) + ); + expect(getPendingInteractions).not.toHaveBeenCalled(); + }); + + it('never reads a legacy session, whose pending set lives only in its sandbox', async () => { + const { env, getPendingInteractions } = envWithPending([{ id: 'per_widget_1' }]); + const response = await fetchWorker( + approvalKeyRequest({ + ...body, + cloudAgentSessionId: 'agent_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + }), + env + ); + + await expect(response.json()).resolves.toEqual({ approvalKey: null }); + expect(requireCurrentSessionAccessMock).not.toHaveBeenCalled(); + expect(getPendingInteractions).not.toHaveBeenCalled(); + expect(env.CLOUD_AGENT_SESSION.get).not.toHaveBeenCalled(); + }); +}); + describe('server /sandbox-terminal', () => { const sessionId = 'workspace_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; diff --git a/services/cloud-agent-next/src/server.ts b/services/cloud-agent-next/src/server.ts index 4ae5f8352d..6d7fb1d1e3 100644 --- a/services/cloud-agent-next/src/server.ts +++ b/services/cloud-agent-next/src/server.ts @@ -70,6 +70,8 @@ import { RUNTIME_PROXY_ATTESTATION_HEADER, type RuntimeProxyAttestationAudience, } from '@kilocode/worker-utils/runtime-proxy-attestation'; +import { TRPCError } from '@trpc/server'; +import { readWidgetApprovalKey, widgetApprovalKeyRequestSchema } from './widget-approval-key.js'; const app = new Hono(); @@ -1085,6 +1087,31 @@ app.post('/internal/streams/close', async (c: Context) => { return c.body(null, 204); }); +// Home widget identity read for the trusted web backend only: the internal key +// gates the route, the body names the exact owned session, and the response +// carries a one-way binding, never the raw permission or any mutation authority. +app.post('/internal/widgets/approval-key', async (c: Context) => { + const unauthorized = requireInternalApi(c); + if (unauthorized) return unauthorized; + + const parsed = widgetApprovalKeyRequestSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.text('Invalid request', 400); + } + + let approvalKey: string | null; + try { + approvalKey = await readWidgetApprovalKey(c.env, parsed.data); + } catch (error) { + // Ownership refusals (403) and an unavailable access read (503) come from + // `requireCurrentSessionAccess`; anything else is a DO failure (500). + if (error instanceof TRPCError) return projectSessionAccessHttpError(error); + throw error; + } + c.header('Cache-Control', 'no-store'); + return c.json({ approvalKey }); +}); + app.use('/trpc/*', authMiddleware); app.use('/trpc/*', balanceMiddleware); diff --git a/services/cloud-agent-next/src/widget-approval-key.ts b/services/cloud-agent-next/src/widget-approval-key.ts new file mode 100644 index 0000000000..609a8b5f10 --- /dev/null +++ b/services/cloud-agent-next/src/widget-approval-key.ts @@ -0,0 +1,82 @@ +import * as z from 'zod'; +import { sessionIdSchema as kiloSessionIdSchema } from '@kilocode/session-ingest-contracts'; +import { sessionIdSchema } from './router/schemas.js'; +import { requireCurrentSessionAccess } from './session-access.js'; +import { isControlSession } from './session-plane.js'; +import { getSandboxSessionStub } from './sandbox-session/session-stub.js'; +import { withDORetry } from './utils/do-retry.js'; +import type { Env } from './types.js'; + +/** + * The Home widget's internal identity read: the trusted web backend names the + * row it is about to show (user, organization scope, Kilo session, cloud + * session) and receives only a SHA-256 binding of the oldest pending + * permission. Raw permission ids, prompts and patterns never leave the Worker. + * The key grants nothing: every approval re-reads the live request. + */ +export const widgetApprovalKeyRequestSchema = z + .object({ + userId: z.string().min(1), + organizationId: z.string().min(1).nullable(), + kiloSessionId: kiloSessionIdSchema, + cloudAgentSessionId: sessionIdSchema, + }) + .strict(); + +export type WidgetApprovalKeyRequest = z.infer; + +const pendingPermissionsSchema = z.object({ permissions: z.array(z.unknown()) }); + +/** SHA-256 hex of `JSON.stringify([kiloSessionId, permissionId])`, the shared binding. */ +export async function widgetApprovalKey( + kiloSessionId: string, + permissionId: string +): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(JSON.stringify([kiloSessionId, permissionId])) + ); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +/** The first entry with a usable id: the DO keeps permissions in arrival order. */ +function oldestPermissionId(permissions: readonly unknown[]): string | null { + for (const permission of permissions) { + if (typeof permission === 'object' && permission !== null && 'id' in permission) { + const id = permission.id; + if (typeof id === 'string' && id.length > 0) return id; + } + } + return null; +} + +/** + * Resolve the approval key for one owned session. Ownership (user, exact + * organization scope with live membership, and the Kilo/cloud session pairing) + * is enforced by `requireCurrentSessionAccess`, which throws FORBIDDEN on any + * mismatch. Only the control-plane Session DO is read: its pending set lives in + * DO memory/storage, so no sandbox is woken. A legacy `agent_*` session keeps + * its pending set only in its live wrapper, so it yields no key here. + */ +export async function readWidgetApprovalKey( + env: Pick, + request: WidgetApprovalKeyRequest +): Promise { + if (!isControlSession(request.cloudAgentSessionId)) return null; + await requireCurrentSessionAccess({ + env, + kiloUserId: request.userId, + cloudAgentSessionId: request.cloudAgentSessionId, + expectedOrganizationId: request.organizationId, + expectedKiloSessionId: request.kiloSessionId, + }); + const pending = pendingPermissionsSchema.parse( + await withDORetry( + () => getSandboxSessionStub(env, request.userId, request.cloudAgentSessionId), + session => session.getPendingInteractions(), + 'getPendingInteractions' + ) + ); + const permissionId = oldestPermissionId(pending.permissions); + return permissionId === null ? null : widgetApprovalKey(request.kiloSessionId, permissionId); +} diff --git a/services/notifications/src/lib/apns-widget.test.ts b/services/notifications/src/lib/apns-widget.test.ts new file mode 100644 index 0000000000..de96bb2b69 --- /dev/null +++ b/services/notifications/src/lib/apns-widget.test.ts @@ -0,0 +1,72 @@ +import { buildGlanceableSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { describe, expect, it, vi } from 'vitest'; +import { buildWidgetApnsRequest, sendWidgetApns } from './apns-widget'; +import { toGlanceableContentState } from './glanceable-delivery'; + +vi.mock('./apns-live-activity', () => ({ signApnsJwt: vi.fn(async () => 'test-provider-jwt') })); +const credentials = { + teamId: 'test-team', + keyId: 'test-key', + topic: 'com.kilocode.kiloapp', + privateKeyPem: '', +}; + +describe('WidgetKit reload hints', () => { + it('uses the widgets topic and carries no authoritative data', () => { + const request = buildWidgetApnsRequest({ + token: 'test-widget', + credentials, + authorizationJwt: 'test-provider-jwt', + }); + expect(request.headers['apns-push-type']).toBe('widgets'); + expect(request.headers['apns-topic']).toBe('com.kilocode.kiloapp.push-type.widgets'); + expect(JSON.parse(request.body)).toEqual({ aps: { 'content-changed': true } }); + }); + it('does not send after the durable scope fence changes', async () => { + const fetchFn = vi.fn(); + await sendWidgetApns({ + credentials, + tokens: ['test-widget'], + isCurrent: async () => false, + onGone: vi.fn(), + fetchFn, + }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + it('retires only a confirmed gone token and preserves transient targets', async () => { + const onGone = vi.fn(async () => undefined); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 410 })) + .mockResolvedValueOnce(new Response(null, { status: 503 })); + await sendWidgetApns({ + credentials, + tokens: ['gone-widget', 'retained-widget'], + onGone, + fetchFn, + }); + expect(onGone).toHaveBeenCalledExactlyOnceWith('gone-widget'); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + it('does not let Home titles enter Live Activity content state', () => { + const snapshot = buildGlanceableSnapshot({ + userId: 'test-user', + organizationId: null, + sessions: [{ status: 'permission' }], + now: 1_750_000_000_000, + }); + const content = toGlanceableContentState({ + ...snapshot, + type: 'active_agents_glanceable', + homeWidgetDetails: { + approvalKey: 'a'.repeat(64), + primaryTitle: 'Private Home title', + waitingAgents: [{ title: 'Private waiting title', kind: 'permission' }], + scheduledAgents: [], + }, + }); + expect(content.props).not.toContain('Private'); + expect(JSON.parse(content.props)).not.toHaveProperty('homeWidgetDetails'); + expect(content.props).not.toContain('a'.repeat(64)); + }); +}); diff --git a/services/notifications/src/lib/apns-widget.ts b/services/notifications/src/lib/apns-widget.ts new file mode 100644 index 0000000000..fd32729684 --- /dev/null +++ b/services/notifications/src/lib/apns-widget.ts @@ -0,0 +1,55 @@ +import { signApnsJwt, type ApnsCredentials } from './apns-live-activity'; + +/** WidgetKit pushes contain no product data: they only request an authenticated reload. */ +export function buildWidgetApnsRequest(params: { + token: string; + credentials: ApnsCredentials; + authorizationJwt: string; +}) { + return { + url: `https://api.push.apple.com/3/device/${params.token}`, + headers: { + authorization: `bearer ${params.authorizationJwt}`, + 'apns-topic': `${params.credentials.topic}.push-type.widgets`, + 'apns-push-type': 'widgets', + 'apns-priority': '5', + 'apns-expiration': '0', + 'content-type': 'application/json', + }, + body: JSON.stringify({ aps: { 'content-changed': true } }), + }; +} + +export async function sendWidgetApns(params: { + credentials: ApnsCredentials; + tokens: readonly string[]; + isCurrent?: () => Promise; + onGone: (token: string) => Promise; + fetchFn?: typeof fetch; +}): Promise { + if (params.tokens.length === 0) return; + const authorizationJwt = await signApnsJwt(params.credentials, Math.floor(Date.now() / 1000)); + await Promise.all( + params.tokens.map(async token => { + if (params.isCurrent && !(await params.isCurrent())) return; + const request = buildWidgetApnsRequest({ + token, + credentials: params.credentials, + authorizationJwt, + }); + try { + const response = await (params.fetchFn ?? fetch)(request.url, { + method: 'POST', + headers: request.headers, + body: request.body, + signal: AbortSignal.timeout(10_000), + }); + if (response.status === 410) await params.onGone(token); + if (!response.ok) console.warn('Widget APNs hint rejected', { status: response.status }); + } catch { + // A lost hint must not prevent delivery to other surfaces; timelines remain the fallback. + console.warn('Widget APNs hint failed'); + } + }) + ); +} diff --git a/services/notifications/src/lib/glanceable-delivery-deps.ts b/services/notifications/src/lib/glanceable-delivery-deps.ts index 6903a8dca2..80fd3b7a16 100644 --- a/services/notifications/src/lib/glanceable-delivery-deps.ts +++ b/services/notifications/src/lib/glanceable-delivery-deps.ts @@ -5,6 +5,7 @@ import { pushDataSchema } from '@kilocode/notifications'; import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'; import { sendLiveActivityApns, type ApnsCredentials } from './apns-live-activity'; +import { sendWidgetApns } from './apns-widget'; import { sendPushNotifications } from './expo-push'; import type { GlanceableDeliveryDeps, IosActivityToken } from './glanceable-delivery'; @@ -151,6 +152,46 @@ export function glanceableDeliveryDeps(env: Env): GlanceableDeliveryDeps { }); } }, + sendIosWidgetHints: async (userId, organizationId, isCurrent) => { + const orgPredicate = + organizationId === null + ? isNull(user_activity_tokens.organization_id) + : eq(user_activity_tokens.organization_id, organizationId); + const rows = await getDbForCall() + .select({ + id: user_activity_tokens.id, + token: user_activity_tokens.token, + updated_at: user_activity_tokens.updated_at, + }) + .from(user_activity_tokens) + .where( + and( + eq(user_activity_tokens.user_id, userId), + orgPredicate, + eq(user_activity_tokens.kind, 'ios_widget') + ) + ); + const credentials = await readApnsCredentials(env); + if (!credentials || (isCurrent && !(await isCurrent()))) return; + await sendWidgetApns({ + credentials, + tokens: rows.map(row => row.token), + isCurrent, + onGone: async token => { + const row = rows.find(candidate => candidate.token === token); + if (!row) return; + await getDbForCall() + .delete(user_activity_tokens) + .where( + and( + eq(user_activity_tokens.id, row.id), + eq(user_activity_tokens.updated_at, row.updated_at), + eq(user_activity_tokens.kind, 'ios_widget') + ) + ); + }, + }); + }, listIosExpoTokens: async userId => { const rows = await getDbForCall() .select({ token: user_push_tokens.token, locale: user_push_tokens.locale }) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index bb3ecb62d7..9715efcd94 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -196,6 +196,19 @@ describe('NotificationsService.refreshGlanceableSessions', () => { } return { rows: [] }; } + if (sql.includes('from "user_activity_tokens"') && params.includes('ios_widget')) { + // The widget hint read selects id, token, updated_at for ios_widget rows only. + return { + rows: [...activityRows] + .filter( + ([, row]) => + row.kind === 'ios_widget' && + matches('user_id', row.userId ?? 'usr_1') && + matches('organization_id', row.organizationId ?? null) + ) + .map(([token, row]) => [row.id, token, row.updated_at]), + }; + } if (sql.includes('from "user_activity_tokens"')) { if (params.includes('android_ongoing')) return { rows: [['subscription']] }; await options.beforeIosTokens?.(); @@ -2109,6 +2122,14 @@ describe('buildGlanceableExpoMessages', () => { }); describe('deliverGlanceableSnapshot', () => { + it('sends independent WidgetKit hints even with no Live Activity targets', async () => { + const sendIosWidgetHints = vi.fn(async () => undefined); + const { deps, calls } = fakeDeps({ sendIosWidgetHints }); + await deliverGlanceableSnapshot({ userId: 'u1', organizationId: 'org-1' }, deps); + expect(sendIosWidgetHints).toHaveBeenCalledExactlyOnceWith('u1', 'org-1', undefined); + expect(calls.iosSends).toHaveLength(0); + }); + it('skips all delivery when the snapshot cannot be built', async () => { const { deps, calls } = fakeDeps({ buildSnapshot: vi.fn(async () => null) }); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index b18ab53356..5ab51958af 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -165,6 +165,12 @@ export type GlanceableDeliveryDeps = { onEndRejected?: (token: string) => Promise, onStarted?: (token: string) => Promise ) => Promise; + /** Independent WidgetKit targets, never part of ActivityKit singleton/end grouping. */ + sendIosWidgetHints?: ( + userId: string, + organizationId: string | null, + isCurrent?: () => Promise + ) => Promise; /** Reserved before reading; do not assign a new timestamp after a delayed send. */ apnsTimestampSeconds?: number; /** Durable generation fence, also checked by adapters after awaits and before outbound sends. */ @@ -225,6 +231,8 @@ export async function deliverGlanceableSnapshot( ); } + await deps.sendIosWidgetHints?.(params.userId, params.organizationId, deps.isCurrent); + // iOS Expo tokens always need the data-only wake: it drives the widget // timeline through the background task while the app is not foregrounded. if (deps.isCurrent && !(await deps.isCurrent())) return; From 492c18e1aba3295570ee7e44041d6bfdc7ef22e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:27:51 +0200 Subject: [PATCH 03/31] feat(mobile): keep Home widgets current and redesign every widget size --- apps/mobile/app.config.ts | 2 + apps/mobile/index.js | 26 +- .../home-widget-refresh/android/build.gradle | 14 + .../android/src/main/AndroidManifest.xml | 3 + .../HomeWidgetRefreshModule.kt | 54 + .../HomeWidgetRefreshWorker.kt | 91 + .../homewidgetrefresh/HomeWidgetStore.kt | 145 ++ .../expo-module.config.json | 5 + .../ios/HomeWidgetRefresh.podspec | 16 + .../ios/HomeWidgetRefreshModule.swift | 90 + .../ios/HomeWidgetRefreshStore.swift | 279 +++ .../HomeWidgetTimelineProvider.swift | 40 + .../plugins/backup/kilo_backup_rules.xml | 3 +- .../backup/kilo_data_extraction_rules.xml | 5 +- apps/mobile/plugins/withHomeWidgetRefresh.js | 131 ++ apps/mobile/scripts/glanceable-fixtures.json | 108 +- .../profiles/kilo-command-form-sheet.tsx | 2 +- .../components/profiles/kilo-command-row.tsx | 2 +- .../active-agents-widget-parts.tsx | 258 +++ .../active-agents-widget.test-helpers.ts | 160 ++ .../active-agents-widget.test.ts | 858 ++------- .../active-agents-widget.tsx | 1584 ++--------------- .../glanceable-android/android-sink.test.ts | 13 + .../src/glanceable-android/android-sink.ts | 10 +- .../glanceable-android/approve-task.test.ts | 164 +- .../src/glanceable-android/approve-task.ts | 17 +- .../glanceable-android/count-format.test.ts | 76 + .../src/glanceable-android/count-format.ts | 25 +- .../src/glanceable-android/home-copy.ts | 140 ++ .../register.test-helpers.ts | 2 +- .../src/glanceable-android/register.test.ts | 533 +++--- .../mobile/src/glanceable-android/register.ts | 161 +- .../glanceable-android/widget-props.test.ts | 946 +++------- .../src/glanceable-android/widget-props.ts | 362 +--- .../glanceable-ios/active-agents-widget.tsx | 1074 ++++++----- .../src/glanceable-ios/ios-sink.test.ts | 110 +- apps/mobile/src/glanceable-ios/ios-sink.ts | 6 +- .../src/glanceable-ios/layout-copy.test.ts | 31 +- apps/mobile/src/glanceable-ios/layout-copy.ts | 11 +- .../src/glanceable-ios/view-props.test.ts | 144 +- apps/mobile/src/glanceable-ios/view-props.ts | 149 +- .../src/glanceable-ios/widget-actions.test.ts | 585 +++--- .../src/glanceable-ios/widget-actions.ts | 53 +- .../glanceable-ios/widget-families.test.ts | 38 - apps/mobile/src/i18n/locales/af.json | 15 +- apps/mobile/src/i18n/locales/am.json | 15 +- apps/mobile/src/i18n/locales/ar.json | 15 +- apps/mobile/src/i18n/locales/az.json | 15 +- apps/mobile/src/i18n/locales/be.json | 15 +- apps/mobile/src/i18n/locales/bg.json | 15 +- apps/mobile/src/i18n/locales/bn.json | 15 +- apps/mobile/src/i18n/locales/bs.json | 15 +- apps/mobile/src/i18n/locales/ca.json | 15 +- apps/mobile/src/i18n/locales/ckb.json | 15 +- apps/mobile/src/i18n/locales/cs.json | 15 +- apps/mobile/src/i18n/locales/cy.json | 15 +- apps/mobile/src/i18n/locales/da.json | 15 +- apps/mobile/src/i18n/locales/de.json | 15 +- apps/mobile/src/i18n/locales/el.json | 15 +- apps/mobile/src/i18n/locales/en.json | 13 +- apps/mobile/src/i18n/locales/es.json | 15 +- apps/mobile/src/i18n/locales/et.json | 15 +- apps/mobile/src/i18n/locales/eu.json | 15 +- apps/mobile/src/i18n/locales/fa.json | 15 +- apps/mobile/src/i18n/locales/fi.json | 15 +- apps/mobile/src/i18n/locales/fil.json | 15 +- apps/mobile/src/i18n/locales/fr.json | 15 +- apps/mobile/src/i18n/locales/ga.json | 15 +- apps/mobile/src/i18n/locales/gl.json | 15 +- apps/mobile/src/i18n/locales/gu.json | 15 +- apps/mobile/src/i18n/locales/ha.json | 15 +- apps/mobile/src/i18n/locales/he.json | 15 +- apps/mobile/src/i18n/locales/hi.json | 15 +- apps/mobile/src/i18n/locales/hr.json | 15 +- apps/mobile/src/i18n/locales/ht.json | 15 +- apps/mobile/src/i18n/locales/hu.json | 15 +- apps/mobile/src/i18n/locales/hy.json | 15 +- apps/mobile/src/i18n/locales/id.json | 15 +- apps/mobile/src/i18n/locales/ig.json | 15 +- apps/mobile/src/i18n/locales/is.json | 15 +- apps/mobile/src/i18n/locales/it.json | 15 +- apps/mobile/src/i18n/locales/ja.json | 15 +- apps/mobile/src/i18n/locales/ka.json | 15 +- apps/mobile/src/i18n/locales/kk.json | 15 +- apps/mobile/src/i18n/locales/km.json | 15 +- apps/mobile/src/i18n/locales/kn.json | 15 +- apps/mobile/src/i18n/locales/ko.json | 15 +- apps/mobile/src/i18n/locales/lo.json | 15 +- apps/mobile/src/i18n/locales/lt.json | 15 +- apps/mobile/src/i18n/locales/lv.json | 15 +- apps/mobile/src/i18n/locales/mg.json | 15 +- apps/mobile/src/i18n/locales/mi.json | 15 +- apps/mobile/src/i18n/locales/mk.json | 15 +- apps/mobile/src/i18n/locales/ml.json | 15 +- apps/mobile/src/i18n/locales/mn.json | 15 +- apps/mobile/src/i18n/locales/mr.json | 15 +- apps/mobile/src/i18n/locales/ms.json | 15 +- apps/mobile/src/i18n/locales/mt.json | 15 +- apps/mobile/src/i18n/locales/my.json | 15 +- apps/mobile/src/i18n/locales/nb.json | 15 +- apps/mobile/src/i18n/locales/ne.json | 15 +- apps/mobile/src/i18n/locales/nl.json | 15 +- apps/mobile/src/i18n/locales/om.json | 15 +- apps/mobile/src/i18n/locales/or.json | 15 +- apps/mobile/src/i18n/locales/pa.json | 15 +- apps/mobile/src/i18n/locales/pl.json | 15 +- apps/mobile/src/i18n/locales/ps.json | 15 +- apps/mobile/src/i18n/locales/pt-BR.json | 15 +- apps/mobile/src/i18n/locales/pt.json | 15 +- apps/mobile/src/i18n/locales/ro.json | 15 +- apps/mobile/src/i18n/locales/ru.json | 15 +- apps/mobile/src/i18n/locales/si.json | 15 +- apps/mobile/src/i18n/locales/sk.json | 15 +- apps/mobile/src/i18n/locales/sl.json | 15 +- apps/mobile/src/i18n/locales/so.json | 15 +- apps/mobile/src/i18n/locales/sq.json | 15 +- apps/mobile/src/i18n/locales/sr.json | 15 +- apps/mobile/src/i18n/locales/sv.json | 15 +- apps/mobile/src/i18n/locales/sw.json | 15 +- apps/mobile/src/i18n/locales/ta.json | 15 +- apps/mobile/src/i18n/locales/te.json | 15 +- apps/mobile/src/i18n/locales/th.json | 15 +- apps/mobile/src/i18n/locales/tr.json | 15 +- apps/mobile/src/i18n/locales/uk.json | 15 +- apps/mobile/src/i18n/locales/ur.json | 15 +- apps/mobile/src/i18n/locales/uz.json | 15 +- apps/mobile/src/i18n/locales/vi.json | 15 +- apps/mobile/src/i18n/locales/yo.json | 15 +- apps/mobile/src/i18n/locales/zh-Hans.json | 15 +- apps/mobile/src/i18n/locales/zh-Hant.json | 15 +- apps/mobile/src/i18n/locales/zu.json | 15 +- apps/mobile/src/lib/glanceable/cleanup.ts | 2 + .../lib/glanceable/fixture-catalog.test.ts | 22 - .../src/lib/glanceable/fixture-catalog.ts | 124 +- .../src/lib/glanceable/fixture-harness.ts | 76 +- .../src/lib/glanceable/home-widget-copy.ts | 31 + .../src/lib/glanceable/home-widget-data.ts | 227 +++ .../src/lib/glanceable/home-widget-refresh.ts | 272 +++ apps/mobile/src/lib/glanceable/mount.tsx | 64 +- apps/mobile/src/lib/glanceable/persist.ts | 8 + .../lib/glanceable/platform-parity.test.ts | 53 +- apps/mobile/src/lib/glanceable/publisher.ts | 20 +- .../src/lib/glanceable/sink-registry.ts | 3 + .../src/lib/glanceable/widget-actions.test.ts | 125 +- .../src/lib/glanceable/widget-actions.ts | 160 +- apps/mobile/src/lib/notifications.ts | 9 +- pnpm-lock.yaml | 3 + tools/oxlint/no-literal-copy/allowlist.json | 2 +- 148 files changed, 5782 insertions(+), 5248 deletions(-) create mode 100644 apps/mobile/modules/home-widget-refresh/android/build.gradle create mode 100644 apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt create mode 100644 apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt create mode 100644 apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt create mode 100644 apps/mobile/modules/home-widget-refresh/expo-module.config.json create mode 100644 apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec create mode 100644 apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift create mode 100644 apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift create mode 100644 apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift create mode 100644 apps/mobile/plugins/withHomeWidgetRefresh.js create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts create mode 100644 apps/mobile/src/glanceable-android/count-format.test.ts create mode 100644 apps/mobile/src/glanceable-android/home-copy.ts delete mode 100644 apps/mobile/src/lib/glanceable/fixture-catalog.test.ts create mode 100644 apps/mobile/src/lib/glanceable/home-widget-copy.ts create mode 100644 apps/mobile/src/lib/glanceable/home-widget-data.ts create mode 100644 apps/mobile/src/lib/glanceable/home-widget-refresh.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 30a968ce20..6f85db6b83 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -484,6 +484,8 @@ const config: ExpoConfig = { // Aggregate "Active Agents" glanceable surfaces: one Live Activity plus Home // Screen and Lock Screen widgets, rendered by src/glanceable-ios. The widget // target reuses the existing app group; no second group is created. + // Native Home refresh runs after expo-widgets' source generation (mods run in reverse). + './plugins/withHomeWidgetRefresh', [ 'expo-widgets', { diff --git a/apps/mobile/index.js b/apps/mobile/index.js index 9649fc1e9f..e89697e79f 100644 --- a/apps/mobile/index.js +++ b/apps/mobile/index.js @@ -1,18 +1,8 @@ // The app entry. // -// Android redraws a placed widget, and answers the ongoing notification's -// Approve action, from a headless JS task: the bundle loads with no Activity, -// no route and no notification handler runs first, so both tasks have to be -// registered here. The widget module loads only when its task fires — requiring -// it at entry would start the widget sink before `expo-router/entry` sets the -// app up. +// Android widget redraws, native refresh, and notification approval run without +// an Activity. Register their lazy task factories before the router entry. // -// The Approve action on the Live Update notification boots the same kind of -// headless run, so its task is registered here too, under the key its Kotlin -// worker starts (`KiloActiveAgentsApprove`). `registerApproveTask` is the second -// registration that action can take, through `ActiveAgentsApproveTaskService`: -// it requires the task module itself, which is why the literal above is only a -// factory. // // `require`, not `import`: ESM hoisting would run `expo-router/entry` first. const { AppRegistry, LogBox, Platform } = require('react-native'); @@ -32,12 +22,12 @@ if (Platform.OS === 'android') { await handleWidgetTask(task); }); - // `KiloActiveAgentsApprove` is `APPROVE_HEADLESS_TASK_KEY` - // (src/glanceable-android/approve-task.ts) and the Kotlin worker's - // `TASK_NAME`; only the string crosses the native boundary, so the three are - // asserted equal in approve-task.test.ts. The factory keeps this registration - // bodyless, like the widget handler above; the module itself arrives with the - // eager `registerApproveTask` require. + AppRegistry.registerHeadlessTask( + 'HomeWidgetRefresh', + () => require('./src/glanceable-android/register').handleHomeWidgetRefresh + ); + + // The Kotlin approval worker starts this key without a foreground Activity. AppRegistry.registerHeadlessTask( 'KiloActiveAgentsApprove', () => require('./src/glanceable-android/approve-task').handleApproveTask diff --git a/apps/mobile/modules/home-widget-refresh/android/build.gradle b/apps/mobile/modules/home-widget-refresh/android/build.gradle new file mode 100644 index 0000000000..36bcc56af6 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/build.gradle @@ -0,0 +1,14 @@ +apply plugin: 'com.android.library' +group = 'com.kilocode.homewidgetrefresh' +version = '1.0.0' +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() +android { namespace 'com.kilocode.homewidgetrefresh' } +dependencies { + implementation 'androidx.work:work-runtime:2.8.1' + implementation 'com.facebook.react:react-android' +} diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml b/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f79cac1ac1 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt new file mode 100644 index 0000000000..ab77747ced --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt @@ -0,0 +1,54 @@ +package com.kilocode.homewidgetrefresh + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.json.JSONObject +import org.json.JSONArray + +class HomeWidgetRefreshModule : Module() { + private val operationLock = Any() + private var operationEpoch = 0L + private var destroyed = false + private val context get() = requireNotNull(appContext.reactContext) + override fun definition() = ModuleDefinition { + Name("HomeWidgetRefresh") + Function("getOperationEpoch") { synchronized(operationLock) { operationEpoch.toDouble() } } + AsyncFunction("configure") { config: Map -> + val json = JSONObject(config) + val epoch = json.getLong("operationEpoch") + synchronized(operationLock) { + if (!destroyed && epoch > operationEpoch) { + operationEpoch = epoch + HomeWidgetStore.configure(context, json) + } + } + } + AsyncFunction("clear") { epoch: Long -> + synchronized(operationLock) { + if (!destroyed && epoch > operationEpoch) { + operationEpoch = epoch + HomeWidgetStore.clear(context) + } + } + } + AsyncFunction("setFixtureMode") { enabled: Boolean -> HomeWidgetStore.fixture(context, enabled) } + AsyncFunction("getData") { HomeWidgetStore.data(context)?.let { jsonMap(it) } } + AsyncFunction("getWidgetPushToken") { null as String? } + AsyncFunction("widgetsChanged") { HomeWidgetStore.schedule(context, replace = true) } + AsyncFunction("isCurrent") { scope: String, epoch: Int, generation: String -> HomeWidgetStore.current(context, scope, epoch, generation) } + OnDestroy { + synchronized(operationLock) { + destroyed = true + operationEpoch += 1 + } + } + } +} + +internal fun jsonValue(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> jsonMap(value) + is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) } + else -> value +} +internal fun jsonMap(value: JSONObject): Map = value.keys().asSequence().associateWith { jsonValue(value.get(it)) } diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt new file mode 100644 index 0000000000..4e65e857ec --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt @@ -0,0 +1,91 @@ +package com.kilocode.homewidgetrefresh + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.facebook.react.ReactApplication +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** WorkManager performs networking before booting JS; JS is only the widget renderer. */ +class HomeWidgetRefreshWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + @Volatile private var stopped = false + private val finished = CountDownLatch(1) + private var listener: ReactInstanceEventListener? = null + private var taskContext: HeadlessJsTaskContext? = null + private var taskListener: HeadlessJsTaskEventListener? = null + private var taskId = -1 + + override fun doWork(): Result { + try { + val payload = try { HomeWidgetStore.fetch(applicationContext) } catch (_: Exception) { null } + if (payload != null && !isStopped) render(payload) + return Result.success() + } finally { + cleanup() + if (!isStopped) HomeWidgetStore.schedule(applicationContext) + } + } + + private fun render(payload: JSONObject) { + val host = (applicationContext as? ReactApplication)?.reactHost ?: return + UiThreadUtil.runOnUiThread { + if (stopped) return@runOnUiThread + val ready = host.currentReactContext + if (ready != null) startTask(ready, payload) else { + val pending = object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + listener?.let { host.removeReactInstanceEventListener(it) } + listener = null + startTask(context, payload) + } + } + listener = pending + host.addReactInstanceEventListener(pending) + val initialized = host.currentReactContext + if (initialized != null) { + host.removeReactInstanceEventListener(pending) + listener = null + startTask(initialized, payload) + } else host.start() + } + } + finished.await(60, TimeUnit.SECONDS) + } + + private fun startTask(context: ReactContext, payload: JSONObject) { + if (stopped || !HomeWidgetStore.current(applicationContext, payload.getString("scopeKey"), payload.getInt("accountEpoch"), payload.getString("generation"))) { + finished.countDown() + return + } + val tasks = HeadlessJsTaskContext.getInstance(context) + val events = object : HeadlessJsTaskEventListener { + override fun onHeadlessJsTaskStart(id: Int) = Unit + override fun onHeadlessJsTaskFinish(id: Int) { if (id == taskId) finished.countDown() } + } + taskContext = tasks + taskListener = events + tasks.addTaskEventListener(events) + taskId = tasks.startTask(HeadlessJsTaskConfig("HomeWidgetRefresh", Arguments.makeNativeMap(jsonMap(payload)), 45_000, true)) + } + + private fun cleanup() { + stopped = true + UiThreadUtil.runOnUiThread { + val host = (applicationContext as? ReactApplication)?.reactHost + listener?.let { host?.removeReactInstanceEventListener(it) } + listener = null + taskListener?.let { taskContext?.removeTaskEventListener(it) } + taskListener = null + } + } + override fun onStopped() { super.onStopped(); cleanup(); finished.countDown() } +} diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt new file mode 100644 index 0000000000..df7001f86c --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt @@ -0,0 +1,145 @@ +package com.kilocode.homewidgetrefresh + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.work.* +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyStore +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal object HomeWidgetStore { + private const val ALIAS = "kilo-home-widget-context" + private const val WORK = "kilo-home-widget-refresh" + private fun prefs(context: Context) = context.getSharedPreferences("home-widget-refresh", Context.MODE_PRIVATE) + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + }.generateKey() + } + @Synchronized fun config(context: Context): JSONObject? { + val encrypted = prefs(context).getString("protectedContext", null) ?: return null + return try { + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) + JSONObject(String(cipher.doFinal(bytes, 12, bytes.size - 12), Charsets.UTF_8)) + } catch (_: Exception) { null } + } + @Synchronized fun configure(context: Context, config: JSONObject) { + val previous = HomeWidgetStore.config(context) + if (previous != null && (previous.optString("scopeKey") != config.optString("scopeKey") || previous.optInt("accountEpoch") != config.optInt("accountEpoch"))) clear(context) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + val encrypted = cipher.iv + cipher.doFinal(config.toString().toByteArray(Charsets.UTF_8)) + check(prefs(context).edit().putString("protectedContext", Base64.encodeToString(encrypted, Base64.NO_WRAP)) + .putString("generation", UUID.randomUUID().toString()).putString("data", config.getJSONObject("data").toString()) + .remove("terminalFence").putLong("refreshAt", config.getLong("refreshAt")) + .putLong("refreshDelay", (config.getLong("refreshAt") - System.currentTimeMillis()).coerceAtLeast(900_000)).commit()) + schedule(context, replace = true) + } + @Synchronized fun clear(context: Context, cancel: Boolean = true) { + prefs(context).edit().remove("protectedContext").remove("data").remove("terminalFence") + .remove("refreshAt").remove("refreshDelay").putString("generation", UUID.randomUUID().toString()).commit() + if (cancel) WorkManager.getInstance(context).cancelUniqueWork(WORK) + } + @Synchronized fun fixture(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("fixture", enabled).putString("generation", UUID.randomUUID().toString()).commit() + if (enabled) WorkManager.getInstance(context).cancelUniqueWork(WORK) else schedule(context, replace = true) + } + fun hasWidgets(context: Context): Boolean = AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context.packageName, "${context.packageName}.widget.ActiveAgentsWidget")).isNotEmpty() + @Synchronized fun generation(context: Context) = prefs(context).getString("generation", "")!! + @Synchronized fun current(context: Context, scope: String, epoch: Int, generation: String): Boolean { + if (!hasWidgets(context) || prefs(context).getBoolean("fixture", false) || generation(context) != generation) return false + val config = config(context) ?: prefs(context).getString("terminalFence", null)?.let { JSONObject(it) } ?: return false + return config.optString("scopeKey") == scope && config.optInt("accountEpoch") == epoch + } + @Synchronized fun data(context: Context): JSONObject? = prefs(context).getString("data", null)?.let { JSONObject(it) } + @Synchronized fun schedule(context: Context, replace: Boolean = false) { + if (config(context) == null || !hasWidgets(context) || prefs(context).getBoolean("fixture", false)) { + WorkManager.getInstance(context).cancelUniqueWork(WORK) + return + } + val now = System.currentTimeMillis() + val wake = prefs(context).getLong("refreshAt", now + 1_800_000) + val requested = if (wake > now) wake - now else prefs(context).getLong("refreshDelay", 1_800_000) + val delay = requested.coerceAtLeast(900_000) + val request = OneTimeWorkRequest.Builder(HomeWidgetRefreshWorker::class.java) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInitialDelay(delay, TimeUnit.MILLISECONDS).build() + WorkManager.getInstance(context).enqueueUniqueWork(WORK, + if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + fun fetch(context: Context): JSONObject? { + val config: JSONObject + val generation: String + synchronized(this) { + if (!hasWidgets(context) || prefs(context).getBoolean("fixture", false)) return null + config = config(context) ?: return null + generation = generation(context) + } + val endpoint = URL(config.getString("endpoint")) + require(endpoint.protocol == "https" || endpoint.host == "localhost" || endpoint.host == "127.0.0.1" || endpoint.host == "10.0.2.2") + val connection = endpoint.openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 15_000 + connection.instanceFollowRedirects = false + connection.setRequestProperty("Authorization", "Bearer ${config.getString("token")}") + connection.setRequestProperty("Accept", "application/json") + try { + val status = connection.responseCode + // Only an authentication refusal is terminal; 403 and every other failure keep retained content. + if (status == 401) { + synchronized(this) { + if (!current(context, config.getString("scopeKey"), config.getInt("accountEpoch"), generation)) return null + clear(context, cancel = false) + val fence = JSONObject().put("scopeKey", config.getString("scopeKey")) + .put("accountEpoch", config.getInt("accountEpoch")).put("generation", generation(context)) + prefs(context).edit().putString("terminalFence", fence.toString()).commit() + return fence.put("terminal", "privacy") + } + } + if (status != 200) return null + val bytes = connection.inputStream.use { stream -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(4096) + while (output.size() <= 262_144) { + val count = stream.read(buffer, 0, minOf(buffer.size, 262_145 - output.size())) + if (count < 0) break + output.write(buffer, 0, count) + } + output.toByteArray() + } + if (bytes.size > 262_144) return null + val response = JSONObject(String(bytes, Charsets.UTF_8)) + val snapshot = response.getJSONObject("snapshot") + if (snapshot.getString("scopeKey") != config.getString("scopeKey")) return null + response.getJSONObject("home") + val data = JSONObject().put("snapshot", snapshot).put("details", response.getJSONObject("details")) + synchronized(this) { + if (!current(context, config.getString("scopeKey"), config.getInt("accountEpoch"), generation)) return null + val oldAt = HomeWidgetStore.data(context)?.getJSONObject("snapshot")?.optString("updatedAt") + if (oldAt != null && snapshot.getString("updatedAt") < oldAt) return null + prefs(context).edit().putString("data", data.toString()).putLong("refreshAt", response.getLong("refreshAt")) + .putLong("refreshDelay", (response.getLong("refreshAt") - System.currentTimeMillis()).coerceAtLeast(900_000)).commit() + } + return JSONObject().put("response", response).put("scopeKey", config.getString("scopeKey")) + .put("accountEpoch", config.getInt("accountEpoch")).put("generation", generation) + .put("copy", config.getJSONObject("copy")).put("locale", config.getString("locale")) + } finally { connection.disconnect() } + } +} diff --git a/apps/mobile/modules/home-widget-refresh/expo-module.config.json b/apps/mobile/modules/home-widget-refresh/expo-module.config.json new file mode 100644 index 0000000000..83584302ab --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/expo-module.config.json @@ -0,0 +1,5 @@ +{ + "platforms": ["apple", "android"], + "apple": { "modules": ["HomeWidgetRefreshModule"] }, + "android": { "modules": ["com.kilocode.homewidgetrefresh.HomeWidgetRefreshModule"] } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec new file mode 100644 index 0000000000..c312ca7031 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'HomeWidgetRefresh' + s.version = '1.0.0' + s.summary = 'Authenticated background Home widget refresh' + s.description = 'Shared protected authentication and WidgetKit refresh transport.' + s.license = { :type => 'Proprietary' } + s.author = 'Kilo' + s.homepage = 'https://github.com/Kilo-Org/cloud' + s.source = { :git => 'https://github.com/Kilo-Org/cloud.git' } + s.platforms = { :ios => '16.4' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = 'HomeWidgetRefreshModule.swift', 'HomeWidgetRefreshStore.swift' + s.frameworks = 'Security', 'WidgetKit' +end diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift new file mode 100644 index 0000000000..1a61b9f38b --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift @@ -0,0 +1,90 @@ +import ExpoModulesCore +import WidgetKit +import Foundation + +public class HomeWidgetRefreshModule: Module { + private let operationLock = NSLock() + private var operationEpoch = 0 + private var destroyed = false + private var pendingClearEpoch: Int? + + private func performOperation(_ epoch: Int, _ body: () throws -> Void) rethrows -> Bool { + operationLock.lock() + defer { operationLock.unlock() } + guard !destroyed, epoch > operationEpoch else { return false } + operationEpoch = epoch + // A newer configure supersedes any in-flight clear; teardown must not erase the new scope. + pendingClearEpoch = nil + try body() + return true + } + + private func currentOperationEpoch() -> Int { + operationLock.lock() + defer { operationLock.unlock() } + return operationEpoch + } + + /// Admits a clear and captures the still-stored credential for unregister before it is removed. + private func admitClear(_ epoch: Int) -> (config: [String: Any]?, token: String?)? { + operationLock.lock() + defer { operationLock.unlock() } + guard !destroyed, epoch > operationEpoch else { return nil } + operationEpoch = epoch + pendingClearEpoch = epoch + return HomeWidgetRefreshStore.locked { + (HomeWidgetRefreshStore.context(), HomeWidgetRefreshStore.defaults.string(forKey: "homeWidgetPushToken")) + } + } + + private func finishClear(_ epoch: Int) { + operationLock.lock() + defer { operationLock.unlock() } + guard pendingClearEpoch == epoch else { return } + pendingClearEpoch = nil + // A newer configure supersedes this clear; it already replaced (and fenced) the old scope. + guard !destroyed, operationEpoch == epoch else { return } + HomeWidgetRefreshStore.clear() + } + public func definition() -> ModuleDefinition { + Name("HomeWidgetRefresh") + Function("getOperationEpoch") { self.currentOperationEpoch() } + AsyncFunction("configure") { (config: [String: Any]) in + guard let epoch = config["operationEpoch"] as? Int else { + throw NSError(domain: HomeWidgetRefreshStore.service, code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing widget operation epoch"]) + } + let applied = try self.performOperation(epoch) { try HomeWidgetRefreshStore.configure(config) } + if applied { await HomeWidgetRefreshStore.registerStoredPushToken() } + } + AsyncFunction("clear") { (epoch: Int) in + guard let captured = self.admitClear(epoch) else { return } + if let config = captured.config, let token = captured.token { + await HomeWidgetRefreshStore.unregisterPushToken(token, config: config) + } + self.finishClear(epoch) + } + AsyncFunction("setFixtureMode") { (enabled: Bool) in HomeWidgetRefreshStore.fixture(enabled) } + AsyncFunction("getData") { () -> [String: Any]? in + HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.readJSON("homeWidgetData") } + } + AsyncFunction("getWidgetPushToken") { () -> String? in + HomeWidgetRefreshStore.defaults.string(forKey: "homeWidgetPushToken") + } + AsyncFunction("isCurrent") { (scope: String, epoch: Int, generation: String) -> Bool in + HomeWidgetRefreshStore.locked { + HomeWidgetRefreshStore.current(["scopeKey": scope, "accountEpoch": epoch], generation) + } + } + AsyncFunction("widgetsChanged") { WidgetCenter.shared.reloadTimelines(ofKind: HomeWidgetRefreshStore.widgetName) } + OnDestroy { + self.operationLock.lock() + self.destroyed = true + self.operationEpoch += 1 + let clearPending = self.pendingClearEpoch != nil + self.pendingClearEpoch = nil + self.operationLock.unlock() + // An admitted sign-out clear must still complete when its unregister is interrupted by teardown. + if clearPending { HomeWidgetRefreshStore.clear() } + } + } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift new file mode 100644 index 0000000000..f8be86466b --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -0,0 +1,279 @@ +import Foundation +import Security +import WidgetKit +import SwiftUI +import Darwin + +// Compiled into both the app pod and widget target. JWT/context never enter UserDefaults. +enum HomeWidgetRefreshStore { + static let widgetName = "ActiveAgentsWidget" + static let service = "com.kilocode.home-widget-refresh" + static let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String ?? "group.com.kilocode.kiloapp" + static let defaults = UserDefaults(suiteName: group)! + static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "HomeWidgetKeychainAccessGroup") as? String + static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 20 + return URLSession(configuration: config, delegate: HomeWidgetNetworkDelegate(), delegateQueue: nil) + }() + static var layoutDirection: LayoutDirection { + let locale = defaults.string(forKey: "homeWidgetLocale") ?? Locale.current.identifier + let language = locale.replacingOccurrences(of: "_", with: "-").split(separator: "-").first.map(String.init) ?? "en" + return Locale.characterDirection(forLanguage: language) == .rightToLeft ? .rightToLeft : .leftToRight + } + static var keyQuery: [String: Any] { + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "context"] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + return query + } + + static func saveJSON(_ value: Any?, key: String) { + guard let value, let data = try? JSONSerialization.data(withJSONObject: value) else { return } + defaults.set(data, forKey: key) + } + static func readJSON(_ key: String) -> [String: Any]? { + guard let bytes = defaults.data(forKey: key) else { return nil } + return (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] + } + static func propertyList(_ value: Any) -> Any { + if let dictionary = value as? [String: Any] { + return dictionary.filter { !($0.value is NSNull) }.mapValues { propertyList($0) } + } + if let array = value as? [Any] { return array.filter { !($0 is NSNull) }.map { propertyList($0) } } + return value + } + + static func locked(_ body: () throws -> T) rethrows -> T { + let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)! + .appendingPathComponent("home-widget-refresh.lock") + let fd = open(url.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + precondition(fd >= 0, "Cannot lock widget state") + flock(fd, LOCK_EX) + defer { flock(fd, LOCK_UN); close(fd) } + // UserDefaults caches across processes; refresh under the cross-process lock. + defaults.synchronize() + return try body() + } + + static func context() -> [String: Any]? { + var query = keyQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + static func configure(_ config: [String: Any]) throws { + try locked { + let previous = context() + if previous?["scopeKey"] as? String != config["scopeKey"] as? String || + previous?["accountEpoch"] as? Int != config["accountEpoch"] as? Int { clearLocked() } + let data = try JSONSerialization.data(withJSONObject: config) + var query = keyQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemDelete(keyQuery as CFDictionary) + guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { + throw NSError(domain: service, code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot protect widget authentication"]) + } + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + defaults.removeObject(forKey: "homeWidgetTerminalFence") + saveJSON(config["data"], key: "homeWidgetData") + saveJSON(config["home"], key: "homeWidgetPresentation") + defaults.set(config["refreshAt"], forKey: "homeWidgetRefreshAt") + defaults.set(config["locale"], forKey: "homeWidgetLocale") + defaults.set(max(900, (config["refreshAt"] as? Double ?? 0) / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") + defaults.synchronize() + } + WidgetCenter.shared.reloadTimelines(ofKind: widgetName) + } + + static func clearLocked() { + SecItemDelete(keyQuery as CFDictionary) + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + for key in ["homeWidgetData", "homeWidgetPresentation", "homeWidgetRefreshAt", "homeWidgetRefreshDelay", "homeWidgetTerminalFence", "__expo_widgets_\(widgetName)_timeline"] { + defaults.removeObject(forKey: key) + } + defaults.synchronize() + } + static func clear() { locked { clearLocked() }; WidgetCenter.shared.reloadTimelines(ofKind: widgetName) } + static func fixture(_ enabled: Bool) { + locked { + defaults.set(enabled, forKey: "homeWidgetFixture") + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + defaults.synchronize() + } + } + static func current(_ config: [String: Any], _ generation: String) -> Bool { + guard !defaults.bool(forKey: "homeWidgetFixture"), + defaults.string(forKey: "homeWidgetGeneration") == generation, + let latest = context() ?? defaults.dictionary(forKey: "homeWidgetTerminalFence") else { return false } + return latest["scopeKey"] as? String == config["scopeKey"] as? String && + latest["accountEpoch"] as? Int == config["accountEpoch"] as? Int + } + static func request(_ config: [String: Any], input: [String: Any]? = nil) -> URLRequest? { + guard let endpoint = config["endpoint"] as? String, let token = config["token"] as? String, + var components = URLComponents(string: endpoint), + components.scheme == "https" || components.host == "localhost" || components.host == "127.0.0.1" else { return nil } + if input != nil { + components.path = "/api/mobile/widgets/push-token" + components.query = nil + } + guard let url = components.url else { return nil } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = input == nil ? "GET" : "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let input { request.httpBody = try? JSONSerialization.data(withJSONObject: input) } + return request + } + + static func refresh() async { + let captured: ([String: Any], String)? = locked { + guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), + let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } + return (config, generation) + } + guard let (config, generation) = captured, + let request = request(config) else { return } + do { + let (bytes, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return } + // Only an authentication refusal is terminal; 403 and every other failure keep retained content. + if http.statusCode == 401 { + locked { + if current(config, generation) { + clearLocked() + defaults.set(["scopeKey": config["scopeKey"]!, "accountEpoch": config["accountEpoch"]!], forKey: "homeWidgetTerminalFence") + let copy = config["copy"] as? [String: String] ?? [:] + defaults.set([["timestamp": Int(Date().timeIntervalSince1970 * 1000), + "props": ["statusLine": copy["privacy"] ?? "", "countLines": [], "primaryCount": 0, + "actions": ["approve": false, "newAgent": false]]]], forKey: "__expo_widgets_\(widgetName)_timeline") + defaults.synchronize() + } + } + return + } + guard http.statusCode == 200, bytes.count <= 262_144, + let payload = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], + let snapshot = payload["snapshot"] as? [String: Any], + snapshot["scopeKey"] as? String == config["scopeKey"] as? String, + let details = payload["details"] as? [String: Any], + let home = payload["home"] as? [String: Any], + let refreshAt = payload["refreshAt"] as? Double else { return } + locked { + guard current(config, generation) else { return } + let oldSnapshot = readJSON("homeWidgetData")?["snapshot"] as? [String: Any] + if let oldAt = oldSnapshot?["updatedAt"] as? String, let newAt = snapshot["updatedAt"] as? String, newAt < oldAt { return } + saveJSON(["snapshot": snapshot, "details": details], key: "homeWidgetData") + saveJSON(home, key: "homeWidgetPresentation") + defaults.set(refreshAt, forKey: "homeWidgetRefreshAt") + defaults.set(max(900, refreshAt / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") + let copy = config["copy"] as? [String: String] ?? [:] + var phases = payload["presentationTimeline"] as? [[String: Any]] ?? [["at": Date().timeIntervalSince1970 * 1000, "home": home]] + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expires = (snapshot["expiresAt"] as? String).flatMap { formatter.date(from: $0) }.map { $0.timeIntervalSince1970 * 1000 } + if let expires, expires > Date().timeIntervalSince1970 * 1000 { + let retained = phases.last?["home"] as? [String: Any] ?? home + phases.append(["at": expires, "home": retained]) + phases.sort { ($0["at"] as? Double ?? 0) < ($1["at"] as? Double ?? 0) } + } + let entries = phases.compactMap { phase -> [String: Any]? in + guard let at = phase["at"] as? Double, let presentation = phase["home"] as? [String: Any] else { return nil } + var props = (defaults.array(forKey: "__expo_widgets_\(widgetName)_timeline")?.first as? [String: Any])?["props"] as? [String: Any] ?? [:] + props["home"] = presentation + var counts = presentation["secondaryCounts"] as? [[String: Any]] ?? [] + if let kind = presentation["primaryKind"] as? String, let count = presentation["primaryCount"] as? Int, count > 0 { + counts.insert(["kind": kind, "count": count], at: 0) + } + props["countLines"] = counts.map { count -> [String: Any] in + var line = count + line["label"] = copy[count["kind"] as? String ?? ""] ?? "" + return line + } + props["primaryKind"] = presentation["primaryKind"] + props["primaryCount"] = presentation["primaryCount"] + props["primaryLabel"] = copy[presentation["primaryKind"] as? String ?? ""] + let status = presentation["status"] as? String ?? "waiting" + props["statusLine"] = status == "content" ? nil : copy[status] + if let expires, at >= expires { + props["countLines"] = [] + props["primaryKind"] = nil + props["primaryCount"] = 0 + props["primaryLabel"] = nil + props["statusLine"] = copy["expired"] + } + props["actions"] = ["approve": presentation["canApprove"] as? Bool ?? false, "newAgent": presentation["canCreate"] as? Bool ?? false] + props["needsInputSince"] = snapshot["needsInputSince"] + props["scheduledAt"] = presentation["scheduledAt"] + props["newestTitle"] = nil + props["actionLine"] = nil + props["accessibilityLabel"] = counts.map { "\($0["count"] ?? 0) \(copy[$0["kind"] as? String ?? ""] ?? "")" }.joined(separator: ", ") + props.removeValue(forKey: "pendingAction") + // JSON nulls aren't valid property-list values; expo-widgets omits them too. + return ["timestamp": Int(at), "props": propertyList(props)] + } + if !entries.isEmpty { defaults.set(entries, forKey: "__expo_widgets_\(widgetName)_timeline") } + defaults.synchronize() + } + } catch { + // Keep the confirmed cache and its checkedAt on every transport/parse failure. + } + } + + static func registerStoredPushToken() async { + guard let token = locked({ defaults.string(forKey: "homeWidgetPushToken") }) else { return } + let widgets: [WidgetInfo]? = await withCheckedContinuation { continuation in + WidgetCenter.shared.getCurrentConfigurations { result in + continuation.resume(returning: try? result.get()) + } + } + guard let widgets else { return } + await uploadPushToken(token, enabled: widgets.contains(where: { $0.kind == widgetName })) + } + + static func pushToken(_ token: Data, widgets: [WidgetInfo]) async { + let hex = token.map { String(format: "%02x", $0) }.joined() + locked { defaults.set(hex, forKey: "homeWidgetPushToken"); defaults.synchronize() } + await uploadPushToken(hex, enabled: widgets.contains(where: { $0.kind == widgetName })) + } + + static func uploadPushToken(_ token: String, enabled: Bool) async { + let captured: ([String: Any], String)? = locked { + guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), + let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } + return (config, generation) + } + guard let (config, generation) = captured, + locked({ current(config, generation) }), + let request = request(config, input: ["token": token, "enabled": enabled]) else { return } + _ = try? await session.data(for: request) + if !locked({ current(config, generation) }) { + // A completed older upload cannot leave the current installation bound to its former scope. + await registerStoredPushToken() + } + } + + /// Best-effort unregister before explicit clear. Bounded so sign-out isolation is never held hostage by the network. + static func unregisterPushToken(_ token: String, config: [String: Any]) async { + guard let request = request(config, input: ["token": token, "enabled": false]) else { return } + await withTaskGroup(of: Void.self) { group in + group.addTask { _ = try? await session.data(for: request) } + group.addTask { try? await Task.sleep(nanoseconds: 3_000_000_000) } + await group.next() + group.cancelAll() + } + } +} + +private final class HomeWidgetNetworkDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + // Credentials are scoped to the configured origin; redirects aren't an authentication recovery path. + completionHandler(nil) + } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift new file mode 100644 index 0000000000..bac03ffb28 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift @@ -0,0 +1,40 @@ +import Foundation +import WidgetKit +internal import ExpoWidgets + +struct HomeWidgetTimelineProvider: TimelineProvider { + let name: String + private var base: WidgetsTimelineProvider { WidgetsTimelineProvider(name: name) } + func placeholder(in context: Context) -> WidgetsTimelineEntry { base.placeholder(in: context) } + func getSnapshot(in context: Context, completion: @escaping @Sendable (WidgetsTimelineEntry) -> Void) { + base.getSnapshot(in: context, completion: completion) + } + func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { + Task { + await HomeWidgetRefreshStore.refresh() + base.getTimeline(in: context) { timeline in + let next: Double? = HomeWidgetRefreshStore.locked { + guard !HomeWidgetRefreshStore.defaults.bool(forKey: "homeWidgetFixture"), + HomeWidgetRefreshStore.context() != nil else { return nil } + return HomeWidgetRefreshStore.defaults.double(forKey: "homeWidgetRefreshAt") + } + guard let next else { + completion(Timeline(entries: timeline.entries, policy: .never)) + return + } + // WidgetKit budgets requests; the requested wake is not an exact alarm. + let now = Date().timeIntervalSince1970 + let retryDelay = HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.defaults.double(forKey: "homeWidgetRefreshDelay") } + let wake = Date(timeIntervalSince1970: next / 1000 > now ? max(now + 900, next / 1000) : now + max(900, retryDelay)) + completion(Timeline(entries: timeline.entries, policy: .after(wake))) + } + } + } +} + +@available(iOS 26.0, *) +struct HomeWidgetPushHandler: WidgetPushHandler { + func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) { + Task { await HomeWidgetRefreshStore.pushToken(pushInfo.token, widgets: widgets) } + } +} diff --git a/apps/mobile/plugins/backup/kilo_backup_rules.xml b/apps/mobile/plugins/backup/kilo_backup_rules.xml index 2b9cf22c90..54876847f3 100644 --- a/apps/mobile/plugins/backup/kilo_backup_rules.xml +++ b/apps/mobile/plugins/backup/kilo_backup_rules.xml @@ -5,13 +5,14 @@ Union of the exclusions that expo-secure-store and the AppsFlyer SDK would otherwise each try to declare on : - shared preferences are included wholesale, - - the SecureStore shared-preference file is excluded, + - the SecureStore and native Home widget shared-preference files are excluded, - the AppsFlyer capture files (appsflyer-data, appsflyer-purchase-data, afpurchases.db) are excluded. --> + \ No newline at end of file diff --git a/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml b/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml index 63f62ced48..32a6d5ed0b 100644 --- a/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml +++ b/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml @@ -4,18 +4,21 @@ Same union of exclusions as kilo_backup_rules.xml, declared once for each of cloud-backup and device-transfer: shared preferences are included, - SecureStore and the AppsFlyer capture files are excluded. + SecureStore, the native Home widget cache, and the AppsFlyer capture files + are excluded. --> + + diff --git a/apps/mobile/plugins/withHomeWidgetRefresh.js b/apps/mobile/plugins/withHomeWidgetRefresh.js new file mode 100644 index 0000000000..d7c7b4e5b2 --- /dev/null +++ b/apps/mobile/plugins/withHomeWidgetRefresh.js @@ -0,0 +1,131 @@ +const fs = require('fs'); +const path = require('path'); +const plist = require('@expo/plist').default; +const { + withDangerousMod, + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} = require('expo/config-plugins'); + +const TARGET = 'ExpoWidgetsTarget'; +const GROUP = 'group.com.kilocode.kiloapp'; +const KEYCHAIN = '$(AppIdentifierPrefix)com.kilocode.kiloapp.home-widget-refresh'; +const FILES = ['HomeWidgetRefreshStore.swift', 'HomeWidgetTimelineProvider.swift']; + +function integrateProvider(source) { + if (source.includes('HomeWidgetTimelineProvider(name: name)')) return source; + const provider = 'WidgetsTimelineProvider(name: name)'; + const body = 'var body: some WidgetConfiguration {'; + if ( + !source.includes(provider) || + !source.includes(body) || + !source.includes('WidgetsEntryView(entry: entry)') + ) { + throw new Error('Home widget generator changed: cannot attach authenticated provider'); + } + source = source + .replace(provider, 'HomeWidgetTimelineProvider(name: name)') + .replace(body, 'var configuration: some WidgetConfiguration {') + .replace( + 'WidgetsEntryView(entry: entry)', + 'WidgetsEntryView(entry: entry).environment(\\.layoutDirection, HomeWidgetRefreshStore.layoutDirection)' + ); + const end = source.lastIndexOf('}'); + return ( + source.slice(0, end) + + ` + // Statement returns, not an if-expression: SE-0360 allows a different opaque type under #available. + var body: some WidgetConfiguration { + if #available(iOS 26.0, *) { + return configuration.pushHandler(HomeWidgetPushHandler.self) + } + return configuration + } +` + + source.slice(end) + ); +} + +// Register BEFORE expo-widgets: dangerous/Xcode mods run in reverse order. +module.exports = function withHomeWidgetRefresh(config) { + const extensions = config.extra?.eas?.build?.experimental?.ios?.appExtensions ?? []; + const previous = extensions.find(entry => entry.targetName === TARGET); + config.extra ??= {}; + config.extra.eas ??= {}; + config.extra.eas.build ??= {}; + config.extra.eas.build.experimental ??= {}; + config.extra.eas.build.experimental.ios ??= {}; + config.extra.eas.build.experimental.ios.appExtensions = [ + ...extensions.filter(entry => entry.targetName !== TARGET), + { + ...previous, + targetName: TARGET, + bundleIdentifier: `${config.ios.bundleIdentifier}.${TARGET}`, + entitlements: { + ...previous?.entitlements, + 'com.apple.security.application-groups': [GROUP], + 'keychain-access-groups': [KEYCHAIN], + 'aps-environment': 'production', + }, + }, + ]; + config = withEntitlementsPlist(config, cfg => { + const groups = cfg.modResults['keychain-access-groups'] ?? []; + // SecureStore's default must remain first; the extension may access only the widget group. + const defaultGroup = `$(AppIdentifierPrefix)${cfg.ios.bundleIdentifier}`; + cfg.modResults['keychain-access-groups'] = [...new Set([defaultGroup, ...groups, KEYCHAIN])]; + return cfg; + }); + config = withInfoPlist(config, cfg => { + cfg.modResults.HomeWidgetKeychainAccessGroup = KEYCHAIN; + return cfg; + }); + config = withDangerousMod(config, [ + 'ios', + cfg => { + const root = path.join(cfg.modRequest.platformProjectRoot, TARGET); + const module = path.join(cfg.modRequest.projectRoot, 'modules/home-widget-refresh/ios'); + if (!fs.existsSync(path.join(root, 'ActiveAgentsWidget.swift'))) + throw new Error('expo-widgets must generate its target before Home refresh'); + fs.copyFileSync(path.join(module, FILES[0]), path.join(root, FILES[0])); + fs.copyFileSync(path.join(module, 'WidgetExtension', FILES[1]), path.join(root, FILES[1])); + const widgetPath = path.join(root, 'ActiveAgentsWidget.swift'); + fs.writeFileSync(widgetPath, integrateProvider(fs.readFileSync(widgetPath, 'utf8'))); + const infoPath = path.join(root, 'Info.plist'); + const info = plist.parse(fs.readFileSync(infoPath, 'utf8')); + info.HomeWidgetKeychainAccessGroup = KEYCHAIN; + fs.writeFileSync(infoPath, plist.build(info)); + const entitlementPath = path.join(root, `${TARGET}.entitlements`); + const entitlements = plist.parse(fs.readFileSync(entitlementPath, 'utf8')); + entitlements['keychain-access-groups'] = [KEYCHAIN]; + entitlements['aps-environment'] = 'production'; + fs.writeFileSync(entitlementPath, plist.build(entitlements)); + return cfg; + }, + ]); + return withXcodeProject(config, cfg => { + const project = cfg.modResults; + const targets = project.pbxNativeTargetSection(); + const uuid = Object.keys(targets).find( + key => !key.endsWith('_comment') && targets[key].name === TARGET + ); + if (!uuid) throw new Error('Home widget target must exist before attaching refresh sources'); + // Xcode rejects a second Sources phase as duplicate tasks; join the target's existing phase. + const groups = project.hash.project.objects.PBXGroup; + const groupKey = Object.keys(groups).find( + key => + !key.endsWith('_comment') && + groups[key].name === TARGET && + String(groups[key].path).replaceAll('"', '') === TARGET + ); + if (!groupKey) throw new Error('Home widget group must exist before attaching refresh sources'); + for (const file of FILES) { + if (!project.hasFile(file)) { + project.addSourceFile(file, { target: uuid }, groupKey); + } + } + return cfg; + }); +}; +module.exports.integrateProvider = integrateProvider; diff --git a/apps/mobile/scripts/glanceable-fixtures.json b/apps/mobile/scripts/glanceable-fixtures.json index d7b2c09b68..f804029fc8 100644 --- a/apps/mobile/scripts/glanceable-fixtures.json +++ b/apps/mobile/scripts/glanceable-fixtures.json @@ -2,15 +2,30 @@ "url": "kiloapp:///dev/glanceable-fixture/{name}", "release": "kiloapp:///dev/glanceable-fixture/release", "fixtures": [ - { "name": "signed-out", "description": "Signed out: the sign-in copy, no counts, no actions." }, - { "name": "waiting", "description": "First load: no snapshot yet, the waiting copy." }, - { "name": "empty", "description": "Signed in, no agents: the empty copy with New agent." }, + { + "name": "signed-out", + "description": "Signed out: the sign-in copy, no counts, no actions." + }, + { + "name": "waiting", + "description": "First load: no snapshot yet, the waiting copy." + }, + { + "name": "empty", + "description": "Signed in, no agents: the empty copy with New agent." + }, { "name": "privacy", "description": "Locked (org switch/privacy): the open-Kilo copy, no counts." }, - { "name": "expired", "description": "Expired: counts dropped, the expired copy." }, - { "name": "stale", "description": "Delayed: the mixed counts under the updates-delayed copy." }, + { + "name": "expired", + "description": "Expired activity: the Home widget retains last-known mixed work." + }, + { + "name": "stale", + "description": "Delayed: mixed work retains its last successful confirmation time." + }, { "name": "needs-approval", "description": "Only needs input, 2 of 3 approvable (waiting 28 min): Approve shows." @@ -19,7 +34,10 @@ "name": "needs-input-question", "description": "Only needs input, questions only (waiting 12 min): no Approve." }, - { "name": "running-only", "description": "Only running: three agents working." }, + { + "name": "running-only", + "description": "Only running: three agents working." + }, { "name": "scheduled-only", "description": "Only scheduled: two agents, the soonest wake on a round clock time." @@ -36,7 +54,10 @@ "name": "large-counts", "description": "Width stress: 128 needs input, 1,234 running, 56 scheduled, 999 idle." }, - { "name": "long-title", "description": "Mixed counts with a very long newest-session title." }, + { + "name": "long-title", + "description": "Mixed counts with a very long newest-session title." + }, { "name": "approving", "description": "Needs approval while the in-place Approve runs: the Approving line." @@ -53,6 +74,77 @@ "name": "newest-scheduled", "description": "Mixed counts; newest change is scheduled (large footer)." }, - { "name": "newest-idle", "description": "Mixed counts; newest change is idle (large footer)." } + { + "name": "newest-idle", + "description": "Mixed counts; newest change is idle (large footer)." + }, + { + "name": "scheduled-no-time", + "description": "Scheduled agents without a usable wake time." + }, + { + "name": "scheduled-overdue", + "description": "The earliest scheduled wake passed without confirmation; never invent working." + }, + { + "name": "scheduled-tomorrow", + "description": "Three future runs show non-today local dates and times." + }, + { + "name": "retry-only", + "description": "Needs input from provider retry states, with no permission to approve." + }, + { + "name": "untitled", + "description": "Waiting and scheduled rows without titles use a localized generic label." + }, + { + "name": "stale-idle", + "description": "Old idle work remains useful without claiming current confirmation." + }, + { + "name": "stale-empty", + "description": "A last-known empty state retains creation and an honest timestamp." + }, + { + "name": "mix-input-running", + "description": "Needs input and working." + }, + { + "name": "mix-input-scheduled", + "description": "Needs input and scheduled." + }, + { + "name": "mix-input-idle", + "description": "Needs input and idle." + }, + { + "name": "mix-running-scheduled", + "description": "Working and scheduled." + }, + { + "name": "mix-running-idle", + "description": "Working and idle." + }, + { + "name": "mix-scheduled-idle", + "description": "Scheduled and idle; the wake time takes priority over idle details." + }, + { + "name": "mix-input-running-scheduled", + "description": "Needs input, working, and scheduled." + }, + { + "name": "mix-input-running-idle", + "description": "Needs input, working, and idle." + }, + { + "name": "mix-input-scheduled-idle", + "description": "Needs input, scheduled, and idle." + }, + { + "name": "mix-running-scheduled-idle", + "description": "Working, scheduled, and idle." + } ] } diff --git a/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx b/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx index 4a955a4a36..ba84c62bde 100644 --- a/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx +++ b/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx @@ -154,7 +154,7 @@ export function KiloCommandFormSheet({ }} /> {agent.length > 0 || model.length > 0 ? ( - {[agent.length > 0 ? `${t('profiles.slashCommands.agent')}: ${agent}` : '', model] + {[agent.length > 0 ? `${t('common.agent')}: ${agent}` : '', model] .filter(Boolean) .join(' ')} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx new file mode 100644 index 0000000000..1580686ec9 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx @@ -0,0 +1,258 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget, type HexColor, TextWidget } from 'react-native-android-widget'; + +import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; + +import { type AndroidWidgetProps } from './widget-props'; + +// Short rows reserve two 48dp targets. +export const ACTION_TARGET = 48; +export const ENTRY_ROW_HEIGHT = 48; + +type Palette = { + background: HexColor; + foreground: HexColor; + mutedForeground: HexColor; + primary: HexColor; + primaryForeground: HexColor; + warn: HexColor; + good: HexColor; +}; +export type Paint = { palette: Palette; rtl: boolean }; +export type Copy = NonNullable; +type CountLine = Copy['secondaryCounts'][number]; + +/** Strip and compact heroes put the label beside the number; cards stack it. */ +export type HeroVariant = 'strip' | 'compact' | 'regular' | 'large'; +const HERO_METRICS = { + strip: { inline: true, statusSize: 12, statusLines: 1, numberSize: 22, labelSize: 13 }, + compact: { inline: true, statusSize: 12, statusLines: 2, numberSize: 25, labelSize: 13 }, + regular: { inline: false, statusSize: 18, statusLines: 2, numberSize: 34, labelSize: 14 }, + large: { inline: false, statusSize: 18, statusLines: 3, numberSize: 44, labelSize: 14 }, +} as const; + +const DOT_COLORS = { + needsInput: 'warn', + running: 'good', + idle: 'mutedForeground', + scheduled: 'mutedForeground', +} as const satisfies Record; + +export function readingOrder(children: React.ReactNode[], rtl: boolean) { + return rtl ? [...children].toReversed() : children; +} + +export function text( + value: string, + paint: Paint, + options: { + size: number; + bold?: boolean; + muted?: boolean; + lines?: number; + } +) { + return ( + + ); +} + +export function action( + props: AndroidWidgetProps, + kind: 'create' | 'approve' | 'approve-labeled', + paint: Paint +) { + const create = kind === 'create'; + const labeled = kind === 'approve-labeled'; + const width = labeled ? 104 : ACTION_TARGET; + const approvalKey = props.home?.approvalKey ?? null; + const enabled = create + ? (props.home?.canCreate ?? props.actions.newAgent) + : props.home?.canApprove === true && + approvalKey !== null && + props.actionFeedback !== 'approving'; + if (!enabled) { + // Both controls own a fixed slot, even while approving or privacy-blanked. + return ; + } + const label = create ? props.actions.newAgentLabel : props.actions.approveLabel; + // oxlint-disable-next-line no-literal-copy/no-literal-copy -- compact action glyphs, with translated spoken labels + const glyph = { create: '+', approve: '✓', 'approve-labeled': label }[kind]; + return ( + + + + + + ); +} + +export function header(props: AndroidWidgetProps, paint: Paint, wide: boolean) { + return ( + + {readingOrder( + [ + + {text('Kilo', paint, { size: 18, bold: true })} + , + action(props, wide ? 'approve-labeled' : 'approve', paint), + action(props, 'create', paint), + ], + paint.rtl + )} + + ); +} + +export function hero(copy: Copy, paint: Paint, variant: HeroVariant) { + const metrics = HERO_METRICS[variant]; + if (copy.primaryLabel === null) { + return ( + + {text(copy.status ?? '', paint, { + size: metrics.statusSize, + bold: true, + lines: metrics.statusLines, + })} + + ); + } + const number = text(copy.primaryCount, paint, { size: metrics.numberSize, bold: true }); + const label = text(copy.primaryLabel, paint, { size: metrics.labelSize }); + const leadingEdge = paint.rtl ? 'flex-end' : 'flex-start'; + return ( + + {metrics.inline + ? readingOrder( + [ + {number}, + + {label} + , + ], + paint.rtl + ) + : [ + {number}, + {label}, + ]} + + ); +} + +export function countRow(line: CountLine, paint: Paint) { + return ( + + {readingOrder( + [ + , + {text(line.count, paint, { size: 15, bold: true })}, + + {text(line.label, paint, { size: 13, muted: true })} + , + ], + paint.rtl + )} + + ); +} + +/** `fallback` fills an empty detail slot with the first support count. */ +export function detail(copy: Copy, paint: Paint, slot: { height: number; fallback: boolean }) { + const first = copy.secondaryCounts[0]; + const value = + copy.detail ?? (slot.fallback && first !== undefined ? `${first.count} ${first.label}` : ''); + return ( + + {text(value, paint, { size: slot.height < 20 ? 11 : 12, muted: true })} + + ); +} + +export function footer(copy: Copy, paint: Paint, height = 20) { + return ( + + {text(copy.checked ?? '', paint, { size: 11, muted: true })} + + ); +} + +export function entries(copy: Copy, paint: Paint, slots: number) { + const rows = + copy.waitingAgents.length > 0 + ? copy.waitingAgents.map(agent => ({ title: agent.title, time: agent.reason })) + : copy.scheduledAgents; + return ( + + {rows.slice(0, slots).map((row, index) => ( + + {text(row.title, paint, { size: 14 })} + {row.time === null ? null : text(row.time, paint, { size: 12, muted: true })} + + ))} + + ); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts new file mode 100644 index 0000000000..a1c5c0bc58 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts @@ -0,0 +1,160 @@ +import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; +import { expect } from 'vitest'; + +import { renderActiveAgentsWidget } from './active-agents-widget'; +import { type AndroidWidgetProps, buildAndroidWidgetProps } from './widget-props'; + +/** Shared fixtures and tree walkers for the widget layout suite. Mocks stay in the test file. */ + +export const NOW = 1_750_000_000_000; +export const WAKE = new Date(NOW + 7_200_000).toISOString(); +const COPY: Record = { + 'glanceable.needsInput': 'Needs input', + 'common.working': 'Working', + 'common.scheduled': 'Scheduled', + 'common.idle': 'Idle', + 'common.agent': 'Agent', + 'glanceable.empty': 'No work in progress', + 'glanceable.waiting': 'Waiting for agents', + 'glanceable.signedOut': 'Sign in to see agents', + 'glanceable.privacy': 'Open Kilo to see agents', + 'glanceable.checked': 'Checked', + 'glanceable.lastKnown': 'Last known', + 'glanceable.awaitingUpdate': 'Awaiting update', + 'glanceable.nextRun': 'Next run', + 'glanceable.newAgent': 'New agent', + 'common.approve': 'Approve', + 'glanceable.approving': 'Approving…', + 'glanceable.couldNotApprove': 'Could not approve', + 'agentChat.permissionCard.title': 'Permission required', + 'glanceable.answerNeeded': 'Answer needed', + 'glanceable.waitingToRetry': 'Waiting to retry', + 'glanceable.openAgents': 'Open agents', +}; +export const translate = (key: string) => COPY[key] ?? key; + +// Launcher-reported dp frames, including the shallow landscape variants. +export const CELLS = [ + [172, 104], + [266, 104], + [360, 104], + [172, 224], + [266, 224], + [360, 224], + [172, 344], + [266, 344], + [360, 344], + [360, 464], + [307, 62], + [467, 62], + [627, 62], + [307, 135], + [467, 135], + [627, 135], + [307, 208], + [467, 208], + [627, 208], + [627, 281], +] as const; + +export type Element = { + props: { + children?: unknown; + text?: string; + maxLines?: number; + clickAction?: string; + clickActionData?: { uri?: string }; + accessibilityLabel?: string; + style?: { + height?: string | number; + width?: string | number; + flex?: number; + flexDirection?: string; + flexGap?: number; + padding?: number; + fontSize?: number; + backgroundColor?: string; + textAlign?: string; + }; + }; +}; +export function nodes(root: unknown): Element[] { + if (root === null || typeof root !== 'object') { + return []; + } + if (Array.isArray(root)) { + return root.flatMap(child => nodes(child)); + } + const element = root as Element; + return [element, ...nodes(element.props.children)]; +} +export function propsFor( + sessions: HomeWidgetSessionRow[], + status?: Parameters[0]['status'], + copy = translate +) { + const data = buildHomeWidgetData({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + ...(status ? { status } : {}), + }); + return buildAndroidWidgetProps(data.snapshot, {}, copy, String, String, () => '8:00 PM', data); +} +/** `size` is a launcher-reported `[width, height]` dp frame. */ +export function render(props: AndroidWidgetProps, size: readonly [number, number], rtl = false) { + const [width, height] = size; + return renderActiveAgentsWidget( + props, + { + widgetName: 'ActiveAgentsWidget', + widgetId: 1, + width, + height, + screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, + }, + rtl + ) as { light: Element; dark: Element }; +} +export function texts(root: Element) { + return nodes(root).flatMap(node => (node.props.text ? [node.props.text] : [])); +} + +/** Check emitted native slot budgets, including fallback-script font padding. Not pixel measurement. */ +export function minimumHeight(root: unknown): number { + if (root === null || typeof root !== 'object') { + return 0; + } + if (Array.isArray(root)) { + return root.reduce((sum: number, child) => sum + minimumHeight(child), 0); + } + const element = root as Element; + const { style = {}, text: value, maxLines = 1 } = element.props; + if (value !== undefined) { + if (value === '') { + return 0; + } + const tall = /[\u0600-\u08FF\u0900-\u0DFF]/u.test(value); + return Math.ceil((style.fontSize ?? 14) * (tall ? 1.62 : 1.32)) * maxLines; + } + const children = ( + Array.isArray(element.props.children) + ? element.props.children.flat(Infinity) + : [element.props.children] + ).filter(child => child != null); + const heights = children.map(child => minimumHeight(child)); + const content = + style.flexDirection === 'row' + ? Math.max(0, ...heights) + : heights.reduce((sum, height) => sum + height, 0) + + Math.max(0, heights.length - 1) * (style.flexGap ?? 0); + if (typeof style.height === 'number') { + expect( + content, + JSON.stringify({ style, text: nodes(element).map(node => node.props.text) }) + ).toBeLessThanOrEqual(style.height - 2 * (style.padding ?? 0)); + return style.height; + } + return content + 2 * (style.padding ?? 0); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index 559d21b2b8..d48ad1f483 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -1,707 +1,231 @@ -/* eslint-disable max-lines -- one suite covering every composition and the state matrix through a shared mock-element tree harness */ -import { - buildGlanceableSnapshot, - type GlanceableAgentsSnapshot, -} from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; -import { renderActiveAgentsWidget } from './active-agents-widget'; import { - type AndroidWidgetProps, - buildAndroidWidgetProps, - buildCurrentWidgetProps, -} from './widget-props'; + CELLS, + minimumHeight, + nodes, + NOW, + propsFor, + render, + texts, + translate, + WAKE, +} from './active-agents-widget.test-helpers'; +import { buildCurrentWidgetProps } from './widget-props'; -// Stub the widget primitives so the layout functions return inspectable trees -// without loading react-native. The real components are exercised by prebuild. vi.mock('react-native-android-widget', () => ({ - FlexWidget: (props: Record) => ({ kind: 'FlexWidget', props }), - TextWidget: (props: Record) => ({ kind: 'TextWidget', props }), - ImageWidget: (props: Record) => ({ kind: 'ImageWidget', props }), - requestWidgetUpdate: () => undefined, + FlexWidget: () => null, + TextWidget: () => null, })); -const NOW = 1_750_000_000_000; - -/** The newest result's timestamp, forwarded to the age formatter below. */ -const NEWEST_AT = new Date(NOW - 180_000).toISOString(); -/** Two hours ahead of the suite's clock: the soonest scheduled wake. */ -const WAKE = new Date(NOW + 7_200_000).toISOString(); - -type MockElement = { - type?: { name?: string } | string; - props: { - text?: string; - clickAction?: string; - clickActionData?: { uri?: string }; - accessibilityLabel?: string; - allowFontScaling?: boolean; - maxLines?: number; - style?: { - backgroundColor?: string; - borderColor?: string; - justifyContent?: string; - alignItems?: string; - height?: number | string; - }; - children?: unknown; - }; -}; - -const COPY: Record = { - 'glanceable.needsInput': 'Needs input', - 'common.idle': 'Idle', - 'common.working': 'Working', - 'common.scheduled': 'Scheduled', - 'glanceable.waiting': 'Waiting for agents', - 'glanceable.empty': 'No work in progress', - 'glanceable.expired': 'Status expired', - 'glanceable.signedOut': 'Sign in to see agents', - 'glanceable.privacy': 'Open Kilo to see agents', - 'glanceable.stale': 'Updates delayed', - 'glanceable.openAgents': 'Open agents', - 'glanceable.newestResult': 'Newest result', - 'glanceable.noneWaiting': 'No agents waiting', - 'glanceable.newAgent': 'New agent', - 'glanceable.approving': 'Approving…', - 'glanceable.couldNotApprove': 'Could not approve', - 'glanceable.newestSession': 'Newest: {{title}}', - 'common.approve': 'Approve', -}; - +beforeEach(() => { + _resetHomeWidgetDataForTests(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); afterEach(() => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); vi.useRealTimers(); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); }); -function translate(key: string): string { - return COPY[key] ?? key; -} - -/** The formatters the app injects, stubbed to short, realistic strings. */ -const formatAgo = (): string => '3 min ago'; -const formatClock = (): string => '8:00 PM'; - -/** - * The cell sizes (w x h dp) the Pixel 6 launcher reports for every span from - * 2x1 to 4x4, portrait and landscape: the sizes the widget is drawn at. - */ -const LAUNCHER_CELLS = [ - { span: '2x1', width: 172, height: 104 }, - { span: '3x1', width: 266, height: 104 }, - { span: '4x1', width: 360, height: 104 }, - { span: '2x2', width: 172, height: 224 }, - { span: '3x2', width: 266, height: 224 }, - { span: '4x2', width: 360, height: 224 }, - { span: '2x3', width: 172, height: 344 }, - { span: '3x3', width: 266, height: 344 }, - { span: '4x3', width: 360, height: 344 }, - { span: '4x4', width: 360, height: 464 }, - { span: '2x1 landscape', width: 307, height: 62 }, - { span: '3x1 landscape', width: 467, height: 62 }, - { span: '4x1 landscape', width: 627, height: 62 }, - { span: '2x2 landscape', width: 307, height: 135 }, - { span: '3x2 landscape', width: 467, height: 135 }, - { span: '4x2 landscape', width: 627, height: 135 }, - { span: '2x3 landscape', width: 307, height: 208 }, - { span: '3x3 landscape', width: 467, height: 208 }, - { span: '4x3 landscape', width: 627, height: 208 }, - { span: '4x4 landscape', width: 627, height: 281 }, +const MIXED: HomeWidgetSessionRow[] = [ + { status: 'permission', title: 'Review the release', approvalKey: 'a'.repeat(64) }, + { status: 'busy', title: 'Build the app' }, + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle', title: 'Connected agent' }, ]; - -function snapshotFor( - sessions: { status: string; statusUpdatedAt?: string; scheduledAt?: string }[], - revision = 0, - status?: GlanceableAgentsSnapshot['status'] -): GlanceableAgentsSnapshot { - return buildGlanceableSnapshot({ - sessions, - userId: 'u1', - organizationId: null, - now: NOW, - previousRevision: revision, - ...(status === undefined ? {} : { status }), - }); -} - -function propsFor(snapshot: GlanceableAgentsSnapshot): AndroidWidgetProps { - return buildAndroidWidgetProps(snapshot, {}, translate, String, formatAgo, formatClock); -} - -/** Every state at once: two approvable waits, work, a scheduled wake, idle, and a newest result. */ -function mixedProps(): AndroidWidgetProps { - return propsFor( - snapshotFor([ - { status: 'permission' }, - { status: 'permission' }, - { status: 'busy', statusUpdatedAt: NEWEST_AT }, - { status: 'scheduled', scheduledAt: WAKE }, - { status: 'idle' }, - ]) - ); -} - -function children(element: MockElement): unknown[] { - const kids = element.props.children; - if (kids == null) { - return []; - } - return Array.isArray(kids) ? kids.flat(Infinity) : [kids]; -} - -function walk(node: unknown, visit: (element: MockElement) => void): void { - if (node == null || typeof node !== 'object') { - return; - } - if (Array.isArray(node)) { - for (const item of node) { - walk(item, visit); - } - return; - } - const element = node as MockElement; - visit(element); - for (const child of children(element)) { - walk(child, visit); - } -} - -function collectText(node: unknown): string[] { - const text: string[] = []; - walk(node, element => { - if (typeof element.props.text === 'string') { - text.push(element.props.text); - } - }); - return text; -} - -function findAll(node: unknown, match: (element: MockElement) => boolean): MockElement[] { - const found: MockElement[] = []; - walk(node, element => { - if (match(element)) { - found.push(element); - } - }); - return found; -} - -const NEW_AGENT_URI = 'kiloapp:///cloud/sessions/new'; -const isApprove = (element: MockElement): boolean => element.props.clickAction === 'approve'; -const isNewAgent = (element: MockElement): boolean => - element.props.clickActionData?.uri === NEW_AGENT_URI; -const isMark = (element: MockElement): boolean => - typeof element.props === 'object' && 'imageWidth' in element.props; - -type Cell = { width: number; height: number; rtl?: boolean }; - -function render(props: AndroidWidgetProps, cell: Cell) { - const { width, height, rtl = false } = cell; - return renderActiveAgentsWidget( - props, - { - widgetName: 'ActiveAgentsWidget', - widgetId: 1, - width, - height, - screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, - }, - rtl - ) as unknown as { light: MockElement; dark: MockElement }; -} - -/** The visible numbers, in order. */ -function counts(node: unknown): string[] { - return collectText(node).filter(text => /^\d+$/.test(text)); -} - -describe('renderActiveAgentsWidget', () => { - it('returns distinct light and dark layouts in the app palette', () => { - const rep = render(mixedProps(), { width: 360, height: 344 }); - - expect(rep.light).not.toBe(rep.dark); - expect(rep.light.props.style?.backgroundColor).toBe(lightColors.background); - expect(rep.dark.props.style?.backgroundColor).toBe(darkColors.background); - // One plan draws both themes, so they never differ in what they show. - expect(collectText(rep.dark)).toEqual(collectText(rep.light)); - }); - - it('opens Kilo from the whole widget, in every composition', () => { - for (const cell of LAUNCHER_CELLS) { - for (const props of [mixedProps(), propsFor(snapshotFor([], 0, 'empty'))]) { - const { light } = render(props, cell); - expect(light.props.clickAction, cell.span).toBe('OPEN_URI'); - expect(light.props.clickActionData, cell.span).toEqual({ - uri: 'kiloapp:///cloud/sessions', - }); - expect(light.props.accessibilityLabel, cell.span).toBe(props.accessibilityLabel); - } - } - }); - - // Rule: the action is always reachable. Every launcher cell can hold one line - // of content plus the 48 dp action, so every one of them offers it. - it('offers Approve at every launcher size, and never a count less', () => { - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - const approve = findAll(light, isApprove); - expect(approve, cell.span).toHaveLength(1); - expect(approve[0]?.props.accessibilityLabel).toBe('Approve'); - expect(approve[0]?.props.style?.height, cell.span).toBe(48); - expect(counts(light), cell.span).toEqual(['2', '1', '1', '1']); - } - }); - - it('offers New agent at every launcher size for the empty state', () => { - const props = propsFor(snapshotFor([], 0, 'empty')); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - const chip = findAll(light, isNewAgent); - expect(chip, cell.span).toHaveLength(1); - // Starting an agent needs the composer: a plain deep link, no headless task. - expect(chip[0]?.props.clickAction).toBe('OPEN_URI'); - expect(collectText(light), cell.span).toEqual(['No agents waiting', 'New agent']); - } - }); - - it('offers New agent when every agent is idle', () => { - const props = propsFor(snapshotFor([{ status: 'idle' }, { status: 'idle' }])); - for (const cell of LAUNCHER_CELLS) { - expect(findAll(render(props, cell).light, isNewAgent), cell.span).toHaveLength(1); - } - }); - - // A retry or a question needs the app: no Approve whose press would only open it. - it('draws no Approve for a wait the action cannot answer', () => { - const props = propsFor(snapshotFor([{ status: 'retry' }])); - for (const cell of LAUNCHER_CELLS) { - expect(findAll(render(props, cell).light, isApprove), cell.span).toEqual([]); - } - }); - - it('offers no action for a state with nothing to act on', () => { - for (const status of ['waiting', 'signed_out', 'privacy', 'expired'] as const) { - const { light } = render(propsFor(snapshotFor([], 0, status)), { width: 360, height: 344 }); - expect( - findAll(light, element => element !== light && element.props.clickAction !== undefined), - status - ).toEqual([]); - } - }); - - it('drops the action rather than clipping it in a cell too short for its target', () => { - const { light } = render(propsFor(snapshotFor([], 0, 'empty')), { width: 172, height: 40 }); - - expect(findAll(light, isNewAgent)).toEqual([]); - expect(collectText(light)).toEqual(['No agents waiting']); - }); - - // Rule: overflow order in narrow cells — the wake first, then the secondary - // labels, then the primary label, then the mark. Never a count, and never the - // action while the cell can hold it beside the bare counts. - it('drops the wake, then the labels, then the mark as a one-row cell narrows', () => { - const props = mixedProps(); - const widths = [627, 520, 467, 400, 360, 307, 266]; - const seen = widths.map(width => { - const { light } = render(props, { width, height: 62 }); - const text = collectText(light); - return { - width, - time: text.includes('8:00 PM'), - secondary: text.includes('Working'), - primary: text.includes('Needs input'), - mark: findAll(light, isMark).length > 0, - approve: findAll(light, isApprove).length, - counts: counts(light), - }; - }); - - for (const step of seen) { - expect(step.counts, `${step.width}`).toEqual(['2', '1', '1', '1']); - expect(step.approve, `${step.width}`).toBe(1); - } - // Once a piece is gone it stays gone at every narrower width. - for (const key of ['time', 'secondary', 'primary', 'mark'] as const) { - const firstGone = seen.findIndex(step => !step[key]); - if (firstGone !== -1) { - expect( - seen.slice(firstGone).every(step => !step[key]), - key - ).toBe(true); - } - } - // And a piece never outlives one ranked below it. - for (const step of seen) { - if (step.time) { - expect(step.secondary, `${step.width}`).toBe(true); - } - if (step.secondary) { - expect(step.primary, `${step.width}`).toBe(true); +const STATES = [ + ['waiting', [], 'waiting'], + ['empty', [], 'empty'], + ['signed out', MIXED, 'signed_out'], + ['privacy', MIXED, 'privacy'], + ['mixed', MIXED, undefined], + ['running', [{ status: 'busy' }], undefined], + ['idle', [{ status: 'idle' }], undefined], + ['scheduled', [{ status: 'scheduled', scheduledAt: WAKE }], undefined], + ['missing wake', [{ status: 'scheduled' }], undefined], + [ + 'overdue wake', + [{ status: 'scheduled', scheduledAt: new Date(NOW - 60_000).toISOString() }], + undefined, + ], +] as const; + +describe('Home widget native compositions', () => { + it.each(CELLS)('preserves the state, navigation and action at %dx%d', (width, height) => { + for (const [name, sessions, status] of STATES) { + const props = propsFor([...sessions], status); + const representation = render(props, [width, height]); + for (const theme of [representation.light, representation.dark]) { + const visible = texts(theme); + expect(visible, name).toContain('Kilo'); + expect(theme.props.clickActionData, name).toEqual({ uri: 'kiloapp:///cloud/sessions' }); + expect(theme.props.accessibilityLabel, name).toBe(props.homeCopy?.accessibilityLabel); + if (props.home?.status === 'content') { + expect(visible, name).toContain(props.homeCopy?.primaryCount); + expect(visible, name).toContain(props.homeCopy?.primaryLabel); + expect(visible, name).not.toContain('0'); + expect(visible, name).toContain('Checked 8:00 PM'); + } else { + expect(visible, name).toContain(props.homeCopy?.status); + } + const controls = nodes(theme).filter( + node => + node.props.clickAction === 'approve' || + node.props.clickActionData?.uri === 'kiloapp:///cloud/sessions/new' + ); + expect(controls.length, name).toBe( + Number(props.home?.canCreate) + Number(props.home?.canApprove) + ); + for (const control of controls) { + expect(control.props.style?.width).toBeGreaterThanOrEqual(48); + expect(control.props.style?.height).toBeGreaterThanOrEqual(48); + expect(control.props.accessibilityLabel).toBeTruthy(); + } + expect(minimumHeight(theme), name).toBeLessThanOrEqual(height); } - if (step.primary) { - expect(step.mark, `${step.width}`).toBe(true); + expect(texts(representation.light)).toEqual(texts(representation.dark)); + expect(representation.light.props.style?.backgroundColor).toBe(lightColors.background); + expect(representation.dark.props.style?.backgroundColor).toBe(darkColors.background); + } + }); + + it.each(CELLS)( + 'budgets German and Arabic/RTL without shrinking the count at %dx%d', + (width, height) => { + for (const language of ['de', 'ar']) { + const translated = (key: string) => + language === 'ar' ? `العربية ${translate(key)}` : `Deutsch ${translate(key)}`; + const props = propsFor(MIXED, undefined, translated); + const tree = render(props, [width, height], language === 'ar').light; + expect(minimumHeight(tree)).toBeLessThanOrEqual(height); + const primary = nodes(tree).find( + node => + node.props.text === props.homeCopy?.primaryCount && + (node.props.style?.fontSize ?? 0) >= 22 + ); + expect(primary?.props.style?.fontSize).toBeGreaterThanOrEqual(22); + const label = nodes(tree).find(node => node.props.text === props.homeCopy?.primaryLabel); + expect(label?.props.style?.textAlign).toBe(language === 'ar' ? 'right' : 'left'); } } - expect(seen[0]).toMatchObject({ time: true, secondary: true, primary: true, mark: true }); - }); - - it('mirrors every row for a right-to-left language', () => { - const props = propsFor(snapshotFor([{ status: 'permission' }, { status: 'busy' }])); - const ltr = collectText(render(props, { width: 627, height: 62 }).light); - const rtl = collectText(render(props, { width: 627, height: 62, rtl: true }).light); - - // Each count row reads label-then-number, and the rows run right to left. - expect(rtl.filter(text => text !== 'Approve')).toEqual( - ltr.filter(text => text !== 'Approve').toReversed() - ); - }); - - // Arabic falls back to a font with taller lines than Roboto: four stacked - // rows that fit a one-row cell in English cut the last one in Arabic. - it('budgets taller lines for Arabic copy, so a one-row cell never stacks four rows', () => { - const arabic: Record = { - 'glanceable.needsInput': 'بانتظار تدخلك', - 'common.working': 'جارٍ العمل', - 'common.scheduled': 'مجدول', - 'common.idle': 'خامل', - }; - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'permission' }, { status: 'busy' }]), - {}, - key => arabic[key] ?? translate(key), - String, - formatAgo, - formatClock - ); - const stacks = (cell: Cell, input: AndroidWidgetProps) => - findAll( - render(input, cell).light, - element => - (element.props.style as { flexDirection?: string } | undefined)?.flexDirection === - 'column' && children(element).filter(child => counts(child).length === 1).length === 4 - ).length; + ); - expect(stacks({ width: 266, height: 104 }, mixedProps())).toBe(1); - expect(stacks({ width: 266, height: 104, rtl: true }, props)).toBe(0); - expect(counts(render(props, { width: 266, height: 104, rtl: true }).light)).toHaveLength(4); + it('uses compact, wide and tall space for hierarchy rather than a four-zero ledger', () => { + const props = propsFor(MIXED); + const small = texts(render(props, [172, 104]).light); + const wide = texts(render(props, [360, 224]).light); + const tall = texts(render(props, [360, 464]).light); + expect(small).toContain('Review the release'); + expect(wide).toEqual(expect.arrayContaining(['Working', 'Scheduled', 'Idle'])); + expect(tall).toEqual(expect.arrayContaining(['Review the release', 'Permission required'])); + expect( + nodes(render(props, [360, 464]).light).find(node => node.props.text === '1')?.props.style + ?.fontSize + ).toBeGreaterThan(30); }); - it('hides counts and shows the expired copy for an expired snapshot', () => { - const props = propsFor({ - ...snapshotFor([{ status: 'busy' }], 0), - status: 'expired', - running: 0, - needsInput: 0, - idle: 0, - }); - - expect(collectText(render(props, { width: 266, height: 104 }).light)).toEqual([ - 'Status expired', + it.each(CELLS)('keeps a scheduled wake ahead of idle details at %dx%d', (width, height) => { + const props = propsFor([ + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle' }, ]); + expect(texts(render(props, [width, height]).light)).toContain('Next run 8:00 PM'); + const overdue = propsFor([ + { status: 'scheduled', scheduledAt: new Date(NOW - 1).toISOString() }, + ]); + expect(texts(render(overdue, [width, height]).light)).toContain('Awaiting update'); + expect(texts(render(overdue, [width, height]).light)).not.toContain('Working'); }); - // A widget cell is a fixed frame with no scrolling and no reflow, so text that - // scaled with the system font size pushed the action out of it at Large text. - it('pins every label to its dp size in every composition and theme', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - for (const cell of LAUNCHER_CELLS) { - for (const [theme, surface] of Object.entries(render(mixedProps(), cell))) { - const labels = findAll(surface, element => typeof element.props.text === 'string'); - expect(labels.length).toBeGreaterThan(0); - for (const label of labels) { - expect( - label.props.allowFontScaling, - `${theme} \`${label.props.text}\` at ${cell.span}` - ).toBe(false); - } - } - } - }); -}); - -describe('the tall counts card', () => { - // Rule: a header row holds the mark at the leading edge and the action at the - // trailing edge, and the count rows sit below it. - it('heads the card with the mark and the action', () => { - const { light } = render(mixedProps(), { width: 360, height: 344 }); - const header = findAll( - light, - element => findAll(element, isMark).length === 1 && findAll(element, isApprove).length === 1 - ).at(-1); - - expect(header).toBeDefined(); - expect(counts(header)).toEqual([]); - }); - - // Rule: the footer is pinned only when its caption and its line both fit; - // the caption never draws alone. - it('draws the newest-result footer only whole', () => { - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const text = collectText(render(props, cell).light); - if (text.includes('Newest result')) { - expect(text, cell.span).toContain('Working'); - expect(text.indexOf('Newest result'), cell.span).toBeLessThan(text.lastIndexOf('Working')); - } - } - expect(collectText(render(props, { width: 360, height: 344 }).light)).toContain( - 'Newest result' - ); - expect(collectText(render(props, { width: 360, height: 224 }).light)).not.toContain( - 'Newest result' - ); - }); - - it('drops the footer age whole when the row would not fit with it', () => { - const props = mixedProps(); - - expect(collectText(render(props, { width: 360, height: 344 }).light)).toContain('3 min ago'); - const narrow = collectText( - render( - { ...props, newestResultAgo: '12 minutes ago' }, - { - width: 140, - height: 344, - } - ).light + it('uses the large list for earliest waits or wakes, with honest generic missing titles', () => { + const waiting = propsFor([ + { status: 'permission', title: 'First' }, + { status: 'question', title: 'Second' }, + { status: 'retry' }, + ]); + expect(texts(render(waiting, [360, 464]).light)).toEqual( + expect.arrayContaining([ + 'First', + 'Second', + 'Agent', + 'Permission required', + 'Answer needed', + 'Waiting to retry', + ]) ); - expect(narrow).toContain('Newest result'); - expect(narrow).not.toContain('12 minutes ago'); - }); - - // Rule: tall cards scale the count rows up with the height. - it('sets the count rows larger in a taller card', () => { - const fontOf = (cell: Cell) => - findAll(render(mixedProps(), cell).light, element => element.props.text === 'Needs input')[0] - ?.props.style as { fontSize?: number } | undefined; - - const short = fontOf({ width: 360, height: 224 })?.fontSize ?? 0; - const tall = fontOf({ width: 360, height: 464 })?.fontSize ?? 0; - expect(tall).toBeGreaterThan(short); - // Numbers and labels in one row share one size. - const number = findAll( - render(mixedProps(), { width: 360, height: 464 }).light, - element => element.props.text === '2' - )[0]?.props.style as { fontSize?: number } | undefined; - expect(number?.fontSize).toBe(tall); + const scheduled = propsFor([ + { status: 'scheduled', title: 'Later', scheduledAt: new Date(NOW + 20_000).toISOString() }, + { status: 'scheduled', title: 'Sooner', scheduledAt: new Date(NOW + 10_000).toISOString() }, + ]); + const shown = texts(render(scheduled, [360, 464]).light); + expect(shown.indexOf('Sooner')).toBeLessThan(shown.indexOf('Later')); }); - it('keeps the rows and states the delayed copy under the caption when stale', () => { - const props = propsFor({ - ...snapshotFor([{ status: 'busy', statusUpdatedAt: NEWEST_AT }], 0, 'stale'), - needsInput: 2, - idle: 3, - running: 4, + it('retains last-known data after activity expiry without renewing the checked timestamp', () => { + const data = buildHomeWidgetData({ + sessions: MIXED, + userId: 'u1', + organizationId: null, + now: NOW, }); - const text = collectText(render(props, { width: 360, height: 344 }).light); - - expect(counts(render(props, { width: 360, height: 344 }).light)).toEqual(['2', '4', '0', '3']); - expect(text.slice(-2)).toEqual(['Newest result', 'Updates delayed']); - }); - - it('draws the delayed copy once the data lapses', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 31 * 60_000); + vi.setSystemTime(Date.parse(data.snapshot.expiresAt) + 1); const props = buildCurrentWidgetProps( - { - ...snapshotFor([{ status: 'busy', statusUpdatedAt: NEWEST_AT }], 0), - needsInput: 2, - idle: 3, - running: 4, - }, + data.snapshot, translate, String, - formatAgo, - formatClock - ); - vi.useRealTimers(); - const { light } = render(props, { width: 360, height: 344 }); - - expect(collectText(light).slice(-2)).toEqual(['Newest result', 'Updates delayed']); - expect(light.props.accessibilityLabel).toBe( - 'Updates delayed, 2 Needs input, 4 Working, 3 Idle, Open agents' - ); - }); - - it('draws the scheduled wake as a clock time, dropped whole when the row is too wide', () => { - const props = mixedProps(); - // The deepest element that holds both the scheduled label and its count is its row. - const scheduledRow = (cell: Cell) => - findAll(render(props, cell).light, element => { - const text = collectText(element); - return text.includes('Scheduled') && text.includes('1'); - }).at(-1); - - expect(collectText(scheduledRow({ width: 360, height: 344 }))).toEqual([ - '1', - 'Scheduled', - '8:00 PM', - ]); - expect(collectText(scheduledRow({ width: 172, height: 224 }))).toEqual(['1', 'Scheduled']); - }); - - it('gives the scheduled marker its own color instead of the idle outline', () => { - const rep = render(mixedProps(), { width: 360, height: 344 }); - const styles = (node: unknown) => - findAll(node, element => element.props.style !== undefined).map( - element => element.props.style ?? {} - ); - - expect(styles(rep.light).some(style => style.backgroundColor === lightColors.mutedSoft)).toBe( - true - ); - expect(styles(rep.dark).some(style => style.backgroundColor === darkColors.mutedSoft)).toBe( - true + String, + () => '8:00 PM', + data ); - expect(styles(rep.light).some(style => style.borderColor === lightColors.foreground)).toBe( - true + expect(props.home?.checkedAt).toBe(data.snapshot.updatedAt); + expect(props.home?.stale).toBe(true); + expect(texts(render(props, [172, 104]).light)).toEqual( + expect.arrayContaining(['1', 'Needs input', 'Checked 8:00 PM']) ); - }); - - const fontOf = (node: unknown, text: string): number | undefined => - ( - findAll(node, element => element.props.text === text)[0]?.props.style as - | { fontSize?: number } - | undefined - )?.fontSize; - - // The footer follows the rows: large rows over a footnote read as a mistake. - it('scales the footer with the count rows, never below its base sizes', () => { - const tall = render(mixedProps(), { width: 360, height: 464 }).light; - const rowFont = fontOf(tall, 'Needs input') ?? 0; - const lineFont = fontOf(tall, '3 min ago') ?? 0; - const captionFont = fontOf(tall, 'Newest result') ?? 0; - - expect(rowFont).toBeGreaterThan(20); - expect(lineFont).toBe(Math.round(rowFont * 0.7)); - expect(captionFont).toBe(Math.round(rowFont * 0.6)); - - const short = render(mixedProps(), { width: 360, height: 344 }).light; - expect(fontOf(short, '3 min ago')).toBeGreaterThanOrEqual(13); - expect(fontOf(short, 'Newest result')).toBeGreaterThanOrEqual(11); - }); - - // Zero rows keep the grid still but must not compete with the real counts. - it('draws a zero row muted: glyph, number, and label, the number not bold', () => { - const props = propsFor(snapshotFor([{ status: 'permission' }])); - const { light } = render(props, { width: 360, height: 344 }); - const working = findAll(light, element => { - const text = collectText(element); - return text.includes('Working') && text.includes('0'); - }).at(-1); - const count = findAll(working, element => element.props.text === '0')[0]?.props.style as - | { color?: string; fontWeight?: string } - | undefined; - const needs = findAll(light, element => element.props.text === '1')[0]?.props.style as - | { color?: string; fontWeight?: string } - | undefined; - - expect(count).toMatchObject({ color: lightColors.mutedForeground, fontWeight: 'normal' }); - expect(needs).toMatchObject({ color: lightColors.foreground, fontWeight: 'bold' }); expect( - findAll(working, element => element.props.style?.backgroundColor === lightColors.good) - ).toEqual([]); - }); - - // A chip too wide to sit beside the mark takes its own row at the bottom; - // the mark keeps the header. - it('moves a wide action under the counts and keeps the mark', () => { - const props = { - ...mixedProps(), - actions: { ...mixedProps().actions, approveLabel: 'Genehmigen' }, - }; - const { light } = render(props, { width: 172, height: 344 }); - const top = children(light).filter(child => child !== null && child !== undefined); - - expect(findAll(top[0], isMark)).toHaveLength(1); - expect(findAll(top[0], isApprove)).toEqual([]); - expect(findAll(top.at(-1), isApprove)).toHaveLength(1); - }); - - // One size for every row: a label too long for the cell shrinks the whole - // column, down to the minimum, before any label ends in an ellipsis. - it('shrinks the whole column to fit a long label, down to 12 dp', () => { - const translateDe = (key: string) => - ({ 'glanceable.needsInput': 'Eingabe erforderlich' })[key] ?? translate(key); - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'permission' }, { status: 'busy' }]), - {}, - translateDe, - String, - formatAgo, - formatClock - ); - const { light } = render(props, { width: 172, height: 344 }); - const sizes = ['Eingabe erforderlich', 'Working', 'Scheduled', 'Idle'].map(text => - fontOf(light, text) + nodes(render(props, [172, 104]).light).some(node => + node.props.clickActionData?.uri?.endsWith('/new') + ) + ).toBe(true); + }); + + it('shows action progress in the fixed detail slot and keeps the create control', () => { + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'approving' }); + const inFlight = render(propsFor(MIXED), [172, 104]).light; + expect(texts(inFlight)).toContain('Approving…'); + expect(nodes(inFlight).some(node => node.props.clickAction === 'approve')).toBe(false); + expect(nodes(inFlight).some(node => node.props.clickActionData?.uri?.endsWith('/new'))).toBe( + true ); - - expect(new Set(sizes).size).toBe(1); - expect(sizes[0]).toBeLessThan(15); - expect(sizes[0]).toBeGreaterThanOrEqual(12); - }); -}); - -describe('the in-flight and failed Approve', () => { - it('draws the progress line where the chip was while an Approve runs', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: 'approving' }); - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - expect(findAll(light, isApprove), cell.span).toEqual([]); - expect(collectText(light), cell.span).toContain('Approving…'); - } + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'couldNotApprove' }); + const failed = render(propsFor(MIXED), [172, 104]).light; + expect(texts(failed)).toContain('Could not approve'); + expect(nodes(failed).some(node => node.props.clickAction === 'approve')).toBe(true); }); - it('keeps the chip to retry, with the failure over it, at every launcher size', () => { - setSurfaceExtras({ - newestSessionTitle: 'Fix the flaky test', - actionFeedback: 'couldNotApprove', - }); - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - expect(findAll(light, isApprove), cell.span).toHaveLength(1); - expect(counts(light), cell.span).toEqual(['2', '1', '1', '1']); - } - const tall = collectText(render(props, { width: 360, height: 344 }).light); - expect(tall).toContain('Could not approve'); - // The failure belongs to the chip, so the footer keeps the newest result. - expect(tall).toContain('Newest result'); - }); - - it('names the newest session over the newest result, and only once', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - const text = collectText(render(mixedProps(), { width: 360, height: 344 }).light); - - expect(text.slice(-3)).toEqual(['Newest: Fix the flaky test', 'Working', '3 min ago']); - expect(text).not.toContain('Newest result'); + it.each(['signed_out', 'privacy'] as const)('removes titles and counts on %s', status => { + const props = propsFor(MIXED, status); + const shown = texts(render(props, [360, 464]).light).join(' '); + expect(shown).not.toContain('Review the release'); + expect(shown).not.toContain('Build the app'); + expect(shown).not.toContain('Checked'); + expect(shown).not.toContain('1'); }); }); -describe('a state with no counts', () => { - // Rule: count-less states in tall cards are one centered composition. - it.each([ - ['waiting', 'Waiting for agents'], - ['empty', 'No agents waiting'], - ['expired', 'Status expired'], - ['signed_out', 'Sign in to see agents'], - ['privacy', 'Open Kilo to see agents'], - ] as const)('centers the mark and the %s copy in a tall cell', (status, copy) => { - const { light, dark } = render(propsFor(snapshotFor([], 0, status)), { - width: 360, - height: 344, - }); - - expect(light.props.style?.justifyContent).toBe('center'); - expect(light.props.style?.alignItems).toBe('center'); - expect(findAll(light, isMark)).toHaveLength(1); - expect(collectText(light)[0]).toBe(copy); - expect(collectText(dark)[0]).toBe(copy); - expect(collectText(light)).not.toContain('Newest result'); - }); +it.each(CELLS)('keeps four-digit primary counts legible at %dx%d', (width, height) => { + const props = propsFor(Array.from({ length: 9999 }, () => ({ status: 'busy' }))); + const tree = render(props, [width, height]).light; + const count = nodes(tree).find(node => node.props.text === '9999'); + expect(count).toBeDefined(); + expect(count?.props.style?.fontSize).toBeGreaterThanOrEqual(22); + expect(texts(tree)).toContain('Working'); + expect(minimumHeight(tree)).toBeLessThanOrEqual(height); }); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx index c86756e381..0accf60cf3 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -1,1462 +1,252 @@ -/* eslint-disable max-lines -- every composition and its height and width budget share this one widget layout module */ -/* eslint-disable react-native/no-inline-styles -- react-native-android-widget primitives take style objects; NativeWind className is unavailable in the widget host */ - +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ 'use no memo'; -// Metro turns a static image import into the asset id the widget host resolves, -// the same value `require` would give. Imported rather than required so vitest -// can stand in for the binary. -import LOGO from '../../assets/images/logo-widget.png'; import { FlexWidget, - type FlexWidgetStyle, - type HexColor, - ImageWidget, - TextWidget, type WidgetInfo, type WidgetRepresentation, } from 'react-native-android-widget'; -import { type GlanceableCountKind } from '@/lib/glanceable/presentation'; import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; -import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; +import { + action, + ACTION_TARGET, + type Copy, + countRow, + detail, + entries, + ENTRY_ROW_HEIGHT, + footer, + header, + hero, + type Paint, + readingOrder, + text, +} from './active-agents-widget-parts'; import { type AndroidWidgetProps } from './widget-props'; export const WIDGET_NAME = 'ActiveAgentsWidget'; -/* - * How the layout works. - * - * The library draws the tree with native Android views into one bitmap the - * size the host reports (`info.width` x `info.height`, in dp). Nothing scrolls - * and nothing reflows, so a piece that does not fit is cut at the cell edge. - * The layout therefore budgets every composition against the cell before it - * draws, with an estimate of each text's width and height, and picks the - * richest composition that fits: - * - * - card: a header row (the mark leading, the action trailing), the count rows - * scaled with the height, and the newest-result footer pinned to the bottom - * when its caption and its line both fit; - * - side: the mark, the count rows as a column (set tight in a one-row cell), - * and the action trailing — or no action, the fallback that keeps the rows; - * - line: the mark and the counts run as one row, the action beside them or - * under them. - * - * A state with no counts (signed out, waiting, empty, locked, expired) centers - * the mark, its status line, and the New agent action when it offers one; a - * one-row cell runs them beside the mark instead. - * - * An Approve in flight draws its progress line where the chip was; a failed one - * draws its line over the chip, which stays as the retry. - * - * What each composition drops when the width runs out, in order: the wake - * time, the labels of the secondary rows, the primary label, then the mark. A - * count is never dropped, and the action wins over all of them. - * - * Every label is pinned to its dp size (`allowFontScaling={false}`): the cell is - * a fixed frame, so scaled text would push the action out of it. A user gets a - * larger surface by resizing the widget. - */ - -/** Below this height (dp) a cell is one launcher row: its content runs beside the mark. */ -const TALL_MIN_HEIGHT_DP = 130; -/** At or above this height (dp) the counts card has room for its footer and the larger type. */ -const LARGE_MIN_HEIGHT_DP = 220; - -type Palette = { - background: HexColor; - foreground: HexColor; - muted: HexColor; - primary: HexColor; - primaryForeground: HexColor; - /** Four states, four colors — the same vocabulary the iOS surfaces draw. */ - needsInput: HexColor; - running: HexColor; - scheduled: HexColor; - /** The marker of a state no agent is in: the row stays, the ink steps back. */ - zero: HexColor; -}; - -// The app's own palette, not a widget-local one: a Home Screen card that does -// not match the app it opens reads as a different product. -const LIGHT: Palette = { - background: lightColors.background, - foreground: lightColors.foreground, - muted: lightColors.mutedForeground, - primary: lightColors.primary, - primaryForeground: lightColors.primaryForeground, - needsInput: lightColors.warn, - running: lightColors.good, - // The muted-soft tone the session list's scheduled clock glyph uses, so the - // widget's scheduled marker matches the row the user taps to get here. - scheduled: lightColors.mutedSoft, - zero: lightColors.mutedSoft, -}; - -const DARK: Palette = { - background: darkColors.background, - foreground: darkColors.foreground, - muted: darkColors.mutedForeground, - primary: darkColors.primary, - primaryForeground: darkColors.primaryForeground, - needsInput: darkColors.warn, - running: darkColors.good, - scheduled: darkColors.mutedSoft, - zero: darkColors.mutedSoft, -}; - -// This function is evaluated only through `renderActiveAgentsWidget` and the -// library's `buildWidgetTree`. Everything it references is explicit so the -// React Compiler is disabled ("use no memo") and the widget host can re-evaluate -// the source. Translated copy arrives through `props`. - -// --------------------------------------------------------------------------- -// Metrics -// --------------------------------------------------------------------------- - -/** The padding inside a one-row cell and inside a taller one. */ -const SHORT_PAD_DP = 12; -const TALL_PAD_DP = 16; -/** The gap between the mark and what sits beside it. */ -const MARK_GAP_DP = 12; -/** The gap between the content and a trailing action. */ -const TRAILING_GAP_DP = 14; -/** The minimum gap between the blocks of a tall composition. */ -const BLOCK_GAP_DP = 12; -/** The gap between a body line and the line or action under it. */ -const BODY_GAP_DP = 8; +// Keep the existing native renderer's height buckets. +const SHORT_HEIGHT = 180; -/** - * The action's tap target and its drawn pill. The target holds Android's 48 dp - * minimum; the pill inside it is smaller, so the target may reach into the - * cell padding while the drawn part keeps its distance from the edge. - */ -const ACTION_TARGET_DP = 48; -const ACTION_PILL_DP = 36; -const ACTION_FONT_DP = 13; -const ACTION_PAD_X_DP = 16; +/** Everything one layout needs to draw a placed widget in one palette. */ +type Frame = { props: AndroidWidgetProps; copy: Copy; paint: Paint; info: WidgetInfo }; -/** The type sizes. Count numbers and labels in one row share one size. */ -const SHORT_COUNT_FONT_DP = 13; -const TALL_COUNT_FONT_DP = 15; -const MAX_COUNT_FONT_DP = 28; -/** - * No count row is set below this size (dp). A column that would need less - * gives way to the one-line row, which drops parts instead of shrinking them. - */ -const MIN_COUNT_FONT_DP = 12; -const STATUS_FONT_DP = { short: 13, tall: 14, large: 16, nightstand: 18 } as const; -/** At or above this height (dp) a count-less card sets its mark and copy a size up. */ -const NIGHTSTAND_MIN_HEIGHT_DP = 400; -const SLOT_FONT_DP = 12; -const FOOTER_CAPTION_FONT_DP = 11; -const FOOTER_FONT_DP = 13; - -/** - * One pinned dp text line is about 1.32 times its font size in Roboto with - * font padding. Arabic, the Indic scripts, Thai, and Myanmar fall back to fonts - * with taller ascenders and descenders, and a column budgeted at Roboto's - * height cut their last row at the cell edge. - */ -const ROBOTO_LINE_FACTOR = 1.32; -const TALL_SCRIPT_LINE_FACTOR = 1.62; -const TALL_SCRIPT = - /[\u0600-\u08FF\u0900-\u0DFF\u0E00-\u0E7F\u1000-\u109F\uFB50-\uFDFF\uFE70-\uFEFF]/u; - -/** The line height factor the copy in `props` draws at. */ -function lineFactorOf(props: AndroidWidgetProps): number { - const copy = [ - props.statusLine, - props.newestLine, - props.newestResultLabel, - props.actions.approveLabel, - props.actions.newAgentLabel, - ...props.countLines.map(line => line.label), - ]; - return copy.some(text => text !== null && TALL_SCRIPT.test(text)) - ? TALL_SCRIPT_LINE_FACTOR - : ROBOTO_LINE_FACTOR; -} - -function lineHeight(fontSize: number, factor: number): number { - return Math.ceil(fontSize * factor); -} - -/** - * Roboto advance widths per em, by character class. The host reports no - * measured text width, so the layout estimates it to decide what to drop - * before it draws; the estimate errs wide (the factor below) so a near miss - * drops a piece rather than cutting it. - */ -function charAdvance(char: string): number { - if (char >= '0' && char <= '9') { - return 0.562; - } - if (char === ' ' || char === '\u00A0' || char === '\u202F') { - return 0.25; - } - if ("ijlI!|.,:;'’".includes(char)) { - return 0.26; - } - if ('frt()[]-/'.includes(char)) { - return 0.35; - } - if ('mwMW…%'.includes(char)) { - return 0.88; - } - if (/[\u0600-\u06FF]/u.test(char)) { - return 0.5; - } - // CJK, kana, hangul, and everything after them: a full em. - if (/[\u2E80-\u{10FFFF}]/u.test(char)) { - return 1; - } - const code = char.codePointAt(0) ?? 0; - if (char !== char.toLowerCase()) { - return 0.66; - } - return code < 0x02_50 ? 0.54 : 0.6; -} - -/** - * The estimated width of `text` in dp. The table is close to bold Roboto and a - * little over regular, and the factor adds a margin on top, so an estimate errs - * wide: a near miss drops a piece rather than cutting it. - */ -function textWidth(text: string, fontSize: number): number { - let em = 0; - for (const char of text) { - em += charAdvance(char); - } - return Math.ceil(em * fontSize * 1.04); -} - -/** How many lines `text` takes at `width`, word-wrapped. */ -function lineCount(text: string, fontSize: number, width: number): number { - let lines = 1; - let used = 0; - const space = textWidth(' ', fontSize); - for (const word of text.split(' ')) { - const w = textWidth(word, fontSize); - if (used > 0 && used + space + w > width) { - lines += 1; - used = w; - } else { - used += (used > 0 ? space : 0) + w; - } - if (w > width) { - lines += Math.floor(w / width); - } - } - return lines; -} - -// --------------------------------------------------------------------------- -// Pieces -// --------------------------------------------------------------------------- - -/** The edge a column's content starts from. */ -function startEdge(rtl: boolean): 'flex-start' | 'flex-end' { - return rtl ? 'flex-end' : 'flex-start'; -} - -/** Lay a row's children out in reading order. */ -function inReadingOrder(children: React.ReactNode[], rtl: boolean): React.ReactNode[] { - return rtl ? children.toReversed() : children; -} - -function dotColor(kind: GlanceableCountKind, palette: Palette): HexColor { - if (kind === 'needsInput') { - return palette.needsInput; - } - if (kind === 'running') { - return palette.running; - } - if (kind === 'scheduled') { - return palette.scheduled; - } - return palette.foreground; -} - -/** - * The state marker. Needs-input, working, and scheduled are filled, idle is an - * outline — the shapes differ as well as the colors, so the four states stay - * apart for a user who cannot tell orange from green or from grey. - */ -function stateDot(kind: GlanceableCountKind, color: HexColor, size: number) { +/** A landscape launcher row: brand/checked, hero/detail, then two 48dp actions. */ +function strip({ props, copy, paint, info }: Frame) { + const wide = info.width >= 467; return ( - - ); -} - -function logo(size: number) { - return ( - - ); -} - -/** A flexible gap: at least `min` dp, plus an even share of the free space. */ -function spacer(key: string, min = 0) { - return ; -} - -type CountLine = AndroidWidgetProps['countLines'][number]; - -/** Which parts of a count row draw: the label, and the wake text (scheduled row only). */ -type RowParts = { label: boolean; time: string | null }; - -const dotSize = (fontSize: number): number => - fontSize < 14 ? 9 : Math.min(14, Math.round(fontSize * 0.62)); -function innerGap(fontSize: number): number { - if (fontSize >= 18) { - return 8; - } - return fontSize >= 14 ? 6 : 5; -} -const timeFont = (fontSize: number): number => Math.max(11, Math.round(fontSize * 0.8)); -const columnGap = (fontSize: number): number => Math.round(fontSize * 0.5); - -function rowWidth(line: CountLine, fontSize: number, parts: RowParts) { - const gap = innerGap(fontSize); - let width = dotSize(fontSize) + gap + textWidth(line.count, fontSize); - if (parts.label) { - width += gap + textWidth(line.label, fontSize); - } - if (parts.time !== null && line.kind === 'scheduled') { - width += gap + textWidth(parts.time, timeFont(fontSize)); - } - return width; -} - -type RowStyle = { - palette: Palette; - fontSize: number; - rtl: boolean; - parts: RowParts; - isPrimary: boolean; - /** No agent is in this state: the row keeps its place but draws muted. */ - zero: boolean; - /** In a column the label may shrink (and end in an ellipsis) as a last resort. */ - shrinkLabel: boolean; -}; - -/** - * One count line: marker, count, label, and the scheduled row's wake. Only the - * label color ranks the rows; number and label share one size. - */ -function countRow(line: CountLine, style: RowStyle) { - const { palette, fontSize, rtl, parts, isPrimary, shrinkLabel, zero } = style; - let label: React.ReactNode = null; - if (parts.label) { - label = ( - - ); - if (shrinkLabel) { - label = ( - - {label} - - ); - } - } - const time = - parts.time !== null && line.kind === 'scheduled' ? ( - - ) : null; - return ( - - {inReadingOrder( + + {readingOrder( [ - stateDot( - line.kind, - zero ? palette.zero : dotColor(line.kind, palette), - dotSize(fontSize) - ), - , - label, - time, + + {text('Kilo', paint, { size: 14, bold: true })} + {footer(copy, paint)} + , + + {readingOrder( + [ + + {hero(copy, paint, 'strip')} + {detail(copy, paint, { height: 18, fallback: !wide })} + , + wide ? ( + + {copy.secondaryCounts.slice(0, 2).map(line => countRow(line, paint))} + + ) : null, + ], + paint.rtl + )} + , + action(props, 'approve', paint), + action(props, 'create', paint), ], - rtl + paint.rtl )} ); } -/** A count column's height at `fontSize`. */ -function columnHeight( - lines: number, - fontSize: number, - { factor, gap = columnGap(fontSize) }: { factor: number; gap?: number } -): number { - return lines * lineHeight(fontSize, factor) + Math.max(0, lines - 1) * gap; -} - -/** - * The count rows as a column. The rows keep their labels unless `labels` is - * false; the wake drops from a row only when the row would not fit the width - * with it. - */ -function countColumn(props: AndroidWidgetProps, palette: Palette, layout: ColumnLayout) { - const { fontSize, gap, width, rtl, labels } = layout; - return ( - - {props.countLines.map(line => { - const withTime = { label: labels, time: props.scheduledTime }; - return countRow(line, { - palette, - fontSize, - rtl, - parts: - rowWidth(line, fontSize, withTime) <= width ? withTime : { label: labels, time: null }, - isPrimary: line.label === props.primaryLabel, - zero: props.zeroKinds.includes(line.kind), - shrinkLabel: true, - }); - })} - - ); -} - -type ColumnLayout = { - fontSize: number; - gap: number; - /** The width the wake must fit in beside its row. */ - width: number; - rtl: boolean; - labels: boolean; -}; - -/** - * How much of a count row a one-line layout keeps, richest first. Each level - * keeps what the next one drops: the wake goes first, then the secondary - * labels, then the primary label. - */ -type LineLevel = { labels: 'all' | 'primary' | 'none'; time: boolean }; -/** Counts only: what a line keeps when nothing else fits. */ -const BARE_LINE: LineLevel = { labels: 'none', time: false }; -const LINE_LEVELS: LineLevel[] = [ - { labels: 'all', time: true }, - { labels: 'all', time: false }, - { labels: 'primary', time: false }, - BARE_LINE, -]; -const LINE_ITEM_GAP_DP = 10; - -function lineParts(props: AndroidWidgetProps, line: CountLine, level: LineLevel): RowParts { - return { - label: - level.labels === 'all' || (level.labels === 'primary' && line.label === props.primaryLabel), - time: level.time ? props.scheduledTime : null, - }; -} - -function lineWidth(props: AndroidWidgetProps, level: LineLevel): number { - return ( - props.countLines.reduce( - (sum, line) => sum + rowWidth(line, SHORT_COUNT_FONT_DP, lineParts(props, line, level)), - 0 - ) + - Math.max(0, props.countLines.length - 1) * LINE_ITEM_GAP_DP - ); -} - -/** The richest line level that fits `width`, or null. */ -function fitLine(props: AndroidWidgetProps, width: number): LineLevel | null { - return LINE_LEVELS.find(level => lineWidth(props, level) <= width) ?? null; -} - -/** The palette and the reading direction a piece draws with. */ -type Paint = { palette: Palette; rtl: boolean }; - -/** The counts run as one row, at the one-row cell's type size. */ -function countLine(props: AndroidWidgetProps, level: LineLevel, { palette, rtl }: Paint) { - const rows = props.countLines.map(line => - countRow(line, { - palette, - fontSize: SHORT_COUNT_FONT_DP, - rtl, - parts: lineParts(props, line, level), - isPrimary: line.label === props.primaryLabel, - zero: props.zeroKinds.includes(line.kind), - shrinkLabel: false, - }) - ); +/** 2x1 keeps the hero, wake/detail and checked slots; actions use the full height. */ +function compact({ props, copy, paint, info }: Frame) { + const innerHeight = info.height - 8; + const wide = info.width >= 266; + const supportSlots = Math.min(3, Math.floor((innerHeight - 34) / 26)); return ( - - {inReadingOrder(rows, rtl)} + + {readingOrder( + [ + + + {text('Kilo', paint, { size: 12, bold: true })} + + + {readingOrder( + [ + + + {hero(copy, paint, info.height < 130 ? 'compact' : 'regular')} + + {detail(copy, paint, { height: 20, fallback: !wide })} + , + wide ? ( + + {copy.secondaryCounts + .slice(0, supportSlots) + .map(line => countRow(line, paint))} + + ) : null, + ], + paint.rtl + )} + + {footer(copy, paint, 18)} + , + + {action(props, 'create', paint)} + {action(props, 'approve', paint)} + , + ], + paint.rtl + )} ); } -/** A single muted line: the newest session, or an action's progress or failure. */ -function slotLine(text: string, palette: Palette, fontSize = SLOT_FONT_DP) { - return ( - - ); -} - -/** The action the state offers, if any. Approve and New agent never coexist. */ -type Action = { kind: 'approve' | 'new-agent'; label: string }; - -function actionOf(props: AndroidWidgetProps): Action | null { - if (props.actions.approve) { - return { kind: 'approve', label: props.actions.approveLabel }; - } - if (props.actions.newAgent) { - return { kind: 'new-agent', label: props.actions.newAgentLabel }; - } - return null; -} - -function actionWidth(action: Action): number { - return Math.max(64, 2 * ACTION_PAD_X_DP + textWidth(action.label, ACTION_FONT_DP)); -} - -/** - * The action chip. Approve is a custom `clickAction`, which makes the library - * launch a headless task (`register.ts`) that answers in place. New agent is an - * `OPEN_URI` deep link: starting an agent needs the composer, so the tap opens - * the app on the new-session screen. The body keeps its own `OPEN_URI` deep - * link, so a tap beside the chip still opens Kilo. - * - * The whole 48 dp box is the tap target; the filled pill inside it is what draws. - */ -function actionChip(action: Action, palette: Palette) { - const click = - action.kind === 'approve' - ? { clickAction: 'approve' } - : { clickAction: 'OPEN_URI', clickActionData: { uri: LAUNCHER_NEW_AGENT_URL } }; +function card({ props, copy, paint, info }: Frame) { + const large = info.height >= 300; + const wide = info.width >= 266; + const heroHeight = large ? 100 : 80; + // Wide cards show the support counts beside the hero instead of below it. + const stackedSupportSlots = large ? 3 : 1; + const supportSlots = wide ? 0 : stackedSupportSlots; + const innerHeight = info.height - 24; + const entryRoom = innerHeight - ACTION_TARGET - heroHeight - 24 - supportSlots * 26 - 20; + const entrySlots = Math.max(0, Math.min(3, Math.floor(entryRoom / ENTRY_ROW_HEIGHT))); return ( - + + {header(props, paint, wide)} - - - - ); -} - -/** - * What sits where the action would: the chip, the chip under a failed - * Approve's note (the chip is the retry), or — while an Approve is in flight — - * its progress line, so the chip is not offered twice. - */ -type Trailing = - | { kind: 'action'; action: Action; note: string | null } - | { kind: 'progress'; text: string }; - -/** The trailing piece; `withNote` false leaves a failed Approve's note out. */ -function trailingOf(props: AndroidWidgetProps, withNote = true): Trailing | null { - if (props.actionFeedback === 'approving' && props.newestLine !== null) { - return { kind: 'progress', text: props.newestLine }; - } - const action = actionOf(props); - if (action === null) { - return null; - } - const note = - withNote && props.actionFeedback === 'couldNotApprove' && action.kind === 'approve' - ? props.newestLine - : null; - return { kind: 'action', action, note }; -} - -const NOTE_GAP_DP = 2; - -function trailingSize(trailing: Trailing, factor: number) { - if (trailing.kind === 'progress') { - return { - width: textWidth(trailing.text, SLOT_FONT_DP + 1), - height: lineHeight(SLOT_FONT_DP + 1, factor), - }; - } - const chip = actionWidth(trailing.action); - return trailing.note === null - ? { width: chip, height: ACTION_TARGET_DP } - : { - width: Math.max(chip, textWidth(trailing.note, SLOT_FONT_DP)), - height: lineHeight(SLOT_FONT_DP, factor) + NOTE_GAP_DP + ACTION_TARGET_DP, - }; -} - -function renderTrailing(trailing: Trailing, palette: Palette, rtl: boolean) { - if (trailing.kind === 'progress') { - return slotLine(trailing.text, palette, SLOT_FONT_DP + 1); - } - if (trailing.note === null) { - return actionChip(trailing.action, palette); - } - return ( - - {slotLine(trailing.note, palette)} - {actionChip(trailing.action, palette)} - - ); -} - -/** The line under the counts: the newest session, never an action's line (that trails). */ -function slotTextOf(props: AndroidWidgetProps): string | null { - return props.actionFeedback === null ? props.newestLine : null; -} - -/** - * The tall card's footer: the newest result under its caption — the newest - * session's own line when one is known, so the card names it without saying - * "newest" twice — or the delayed copy under the caption when stale. Its type - * follows the count rows, so a tall card's footer is not a footnote under - * large rows: the line at about 0.7x the row size, the caption at about 0.6x, - * never below the base sizes. - */ -type Footer = { - caption: string | null; - body: 'result' | 'text'; - text: string; - lines: number; - /** The result's age, dropped whole when the row would not fit with it. */ - showAgo: boolean; - captionFont: number; - lineFont: number; -}; - -function footerOf(props: AndroidWidgetProps, width: number, rowFont: number): Footer | null { - if (props.countLines.length === 0) { - return null; - } - const captionFont = Math.max(FOOTER_CAPTION_FONT_DP, Math.round(rowFont * 0.6)); - const lineFont = Math.max(FOOTER_FONT_DP, Math.round(rowFont * 0.7)); - if (props.statusLine !== null) { - return { - caption: props.newestResultTitle, - body: 'text', - text: props.statusLine, - lines: Math.min(2, lineCount(props.statusLine, lineFont, width)), - showAgo: false, - captionFont, - lineFont, - }; - } - if ( - props.newestResultKind === null || - props.newestResultLabel === null || - props.newestResultAgo === null - ) { - return null; - } - const labelPart = dotSize(lineFont) + 6 + textWidth(props.newestResultLabel, lineFont); - return { - caption: - props.actionFeedback === null && props.newestLine !== null - ? props.newestLine - : props.newestResultTitle, - body: 'result', - text: props.newestResultLabel, - lines: 1, - showAgo: labelPart + 6 + textWidth(props.newestResultAgo, lineFont) <= width, - captionFont, - lineFont, - }; -} - -function footerHeight(footer: Footer, factor: number): number { - return ( - (footer.caption === null ? 0 : lineHeight(footer.captionFont, factor) + 4) + - footer.lines * lineHeight(footer.lineFont, factor) - ); -} - -function renderFooter(props: AndroidWidgetProps, footer: Footer, { palette, rtl }: Paint) { - const body = - footer.body === 'result' && - props.newestResultKind !== null && - props.newestResultLabel !== null && - props.newestResultAgo !== null ? ( - - {inReadingOrder( + {readingOrder( [ - stateDot( - props.newestResultKind, - dotColor(props.newestResultKind, palette), - dotSize(footer.lineFont) - ), - , - footer.showAgo ? ( - + + {hero(copy, paint, large ? 'large' : 'regular')} + , + wide ? ( + + {copy.secondaryCounts.map(line => countRow(line, paint))} + ) : null, ], - rtl + paint.rtl )} - ) : ( - - ); - return ( - - {footer.caption === null ? null : ( - + {detail(copy, paint, { height: 24, fallback: false })} + {wide ? null : ( + + {copy.secondaryCounts.slice(0, supportSlots).map(line => countRow(line, paint))} + )} - {body} + {entries(copy, paint, entrySlots)} + + {footer(copy, paint)} ); } -// --------------------------------------------------------------------------- -// Compositions -// --------------------------------------------------------------------------- - -/** The cell's dp size, reading direction, and the line height factor of its copy. */ -type Cell = { width: number; height: number; rtl: boolean; lineFactor: number }; - -/** - * A composition the cell can hold, with what it keeps. `score` ranks the - * candidates: the action first, then the richest content. - */ -type Plan = { score: number; draw: (palette: Palette) => React.ReactNode }; - -/** What each line level costs a plan: the labels it drops. */ -const LABELS_PENALTY = { all: 0, primary: 8, none: 20 } satisfies Record< - LineLevel['labels'], - number ->; +// Old persisted props have no Home presentation. They get the same hierarchy; +// fresh producers always provide shared Home policy and translated Home copy. +function copyFor(props: AndroidWidgetProps): Copy { + if (props.homeCopy !== undefined) { + return props.homeCopy; + } + const primary = props.countLines.find(line => line.label === props.primaryLabel); + return { + primaryCount: primary?.count ?? '', + primaryLabel: primary?.label ?? null, + status: props.statusLine, + detail: props.scheduledTime, + checked: null, + secondaryCounts: props.countLines.filter(line => line !== primary && line.count !== '0'), + waitingAgents: [], + scheduledAgents: [], + accessibilityLabel: props.accessibilityLabel, + }; +} -/** What a plan loses, as a penalty; the action outweighs everything else. */ -function penalty(dropped: { - action: boolean; - time: boolean; - labels: LineLevel['labels']; - mark: boolean; -}) { - return ( - (dropped.action ? 1000 : 0) + - (dropped.mark ? 40 : 0) + - LABELS_PENALTY[dropped.labels] + - (dropped.time ? 2 : 0) - ); +function layoutFor(frame: Frame) { + if (frame.info.height < 80) { + return strip(frame); + } + if (frame.info.height < SHORT_HEIGHT) { + return compact(frame); + } + return card(frame); } -/** The whole cell: the background, and the deep link a tap anywhere opens. */ -function root( - props: AndroidWidgetProps, - palette: Palette, - { style, children }: { style: FlexWidgetStyle; children: React.ReactNode } -) { +function draw(props: AndroidWidgetProps, info: WidgetInfo, paint: Paint) { + const copy = copyFor(props); + const isStrip = info.height < 80; + const isCompact = info.height < SHORT_HEIGHT; return ( - {children} - - ); -} - -/** A row whose start and end sit at its two edges, mirrored for RTL. */ -function spreadRow(key: string, [start, end]: [React.ReactNode, React.ReactNode], rtl: boolean) { - return ( - - {inReadingOrder([start, spacer(`${key}-gap`), end], rtl)} + {layoutFor({ props, copy, paint, info })} ); } -/** The type sizes a card's count column may take, largest first. */ -function countSizes(large: boolean): number[] { - const sizes: number[] = []; - for ( - let size = large ? MAX_COUNT_FONT_DP : TALL_COUNT_FONT_DP; - size >= MIN_COUNT_FONT_DP; - size -= 1 - ) { - sizes.push(size); - } - return sizes; -} - -/** - * The counts card: header, scaled count rows, and the footer when it fits. - * - * The header holds the mark and the action. When the two do not fit side by - * side the mark keeps the header and the action takes its own row at the - * bottom of the card, so neither sits alone at an edge. Only a card too short - * for that row gives the header to the action alone (`chipOnly`), which ranks - * below every composition that keeps the mark. - */ -function cardPlan( - props: AndroidWidgetProps, - cell: Cell, - { note, chipOnly }: { note: boolean; chipOnly: boolean } -): Plan | null { - const { rtl } = cell; - const pad = TALL_PAD_DP; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const large = cell.height >= LARGE_MIN_HEIGHT_DP; - const markSize = large ? 30 : 26; - const lines = props.countLines.length; - const factor = cell.lineFactor; - const trailing = trailingOf(props, note); - const trailingBox = trailing === null ? null : trailingSize(trailing, factor); - const showTrailing = trailingBox !== null && trailingBox.width <= aw; - if (chipOnly && !showTrailing) { - return null; - } - const actionBelow = !chipOnly && showTrailing && markSize + MARK_GAP_DP + trailingBox.width > aw; - let headerHeight = markSize; - if (chipOnly) { - headerHeight = trailingBox?.height ?? 0; - } else if (showTrailing && !actionBelow) { - headerHeight = Math.max(markSize, trailingBox.height); - } - const bottomHeight = actionBelow ? BLOCK_GAP_DP + trailingBox.height : 0; - - const footerHeightAt = (fontSize: number): number | null => { - const footer = footerOf(props, aw, fontSize); - return footer === null ? null : BLOCK_GAP_DP + footerHeight(footer, factor); - }; - const freeAt = (fontSize: number, withFooter: boolean): number => - ah - headerHeight - bottomHeight - (withFooter ? (footerHeightAt(fontSize) ?? 0) : 0); - const fits = (fontSize: number, withFooter: boolean): boolean => - BLOCK_GAP_DP + columnHeight(lines, fontSize, { factor }) <= freeAt(fontSize, withFooter); - // The rows share one size: the largest that fits the height, leaves the card - // room to breathe, and fits the longest label; the minimum when no size fits - // that label, which then ends in an ellipsis. - const pickSize = (withFooter: boolean): number | null => { - const fitting = countSizes(large).filter(size => fits(size, withFooter)); - if (fitting.length === 0) { - return null; - } - const roomy = fitting.filter( - size => columnHeight(lines, size, { factor }) <= freeAt(size, withFooter) * 0.75 - ); - const pool = roomy.length > 0 ? roomy : fitting.slice(-1); - return ( - pool.find(size => - props.countLines.every(line => rowWidth(line, size, { label: true, time: null }) <= aw) - ) ?? - pool.at(-1) ?? - null - ); - }; - // The footer is the third fact: it may not shrink the rows below the tall - // card's base size (or below what the longest label allows anyway). - const bare = pickSize(false); - if (bare === null) { - return null; - } - const footed = footerHeightAt(TALL_COUNT_FONT_DP) === null ? null : pickSize(true); - const withFooter = footed !== null && footed >= Math.min(bare, TALL_COUNT_FONT_DP); - const fontSize = withFooter ? footed : bare; - const footer = withFooter ? footerOf(props, aw, fontSize) : null; - const keepsTime = - props.scheduledTime === null || - props.countLines.every( - line => rowWidth(line, fontSize, { label: true, time: props.scheduledTime }) <= aw - ); - return { - score: - -penalty({ - action: trailing !== null && !showTrailing, - time: !keepsTime, - labels: 'all', - mark: chipOnly, - }) + (withFooter ? 1 : 0), - draw: palette => { - const action = - showTrailing && trailing !== null ? renderTrailing(trailing, palette, rtl) : null; - const header = chipOnly - ? action - : spreadRow('header', [logo(markSize), actionBelow ? null : action], rtl); - const counts = countColumn(props, palette, { - fontSize, - gap: columnGap(fontSize), - width: aw, - rtl, - labels: true, - }); - return root(props, palette, { - style: { flexDirection: 'column', alignItems: startEdge(rtl), padding: pad }, - children: [ - header, - spacer('above-counts', BLOCK_GAP_DP), - counts, - spacer('below-counts', footer === null ? 0 : BLOCK_GAP_DP), - footer === null ? null : renderFooter(props, footer, { palette, rtl }), - actionBelow ? : null, - actionBelow ? action : null, - ], - }); - }, - }; -} - -/** - * The mark, the count rows as a column, and the action trailing. Without the - * action (`withAction` false) it is the short narrow cell's fallback that keeps - * every label when the counts cannot run as one line beside the action. - */ -function sidePlan( - props: AndroidWidgetProps, - cell: Cell, - { short, withAction, note }: { short: boolean; withAction: boolean; note: boolean } -): Plan | null { - const { rtl } = cell; - const pad = short ? SHORT_PAD_DP : TALL_PAD_DP - 2; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const trailing = withAction ? trailingOf(props, note) : null; - const trailingBox = trailing === null ? null : trailingSize(trailing, cell.lineFactor); - const baseFont = short ? SHORT_COUNT_FONT_DP : 14; - const gapFor = (size: number) => (short ? 2 : columnGap(size)); - const lines = props.countLines.length; - const heightAt = (size: number) => - columnHeight(lines, size, { factor: cell.lineFactor, gap: gapFor(size) }); - if (trailingBox !== null && trailingBox.height > cell.height - 2 * (pad - 6)) { - return null; - } - const colWidth = (option: { labels: boolean; time: boolean; size: number }) => - Math.max( - ...props.countLines.map(line => - rowWidth(line, option.size, { - label: option.labels, - time: option.time ? props.scheduledTime : null, - }) - ) - ); - const trailingPart = trailingBox === null ? 0 : TRAILING_GAP_DP + trailingBox.width; - const markSize = short ? 24 : 26; - const room = (mark: boolean) => aw - trailingPart - (mark ? markSize + MARK_GAP_DP : 0); - // Richest first, in the overflow order: the wake, then the labels (after the - // whole column shrinks to the minimum size for them), then the mark. - const chosen = [ - { labels: true, time: true, mark: true, size: baseFont }, - { labels: true, time: false, mark: true, size: baseFont }, - { labels: true, time: false, mark: true, size: MIN_COUNT_FONT_DP }, - { labels: false, time: false, mark: true, size: baseFont }, - { labels: false, time: false, mark: false, size: baseFont }, - ].find(option => heightAt(option.size) <= ah && colWidth(option) <= room(option.mark)); - if (chosen === undefined) { - return null; - } - const { labels, time, mark: showMark, size: fontSize } = chosen; - const gap = gapFor(fontSize); - const colHeight = heightAt(fontSize); - const slot = slotTextOf(props); - const showSlot = - slot !== null && - colHeight + BODY_GAP_DP + lineHeight(SLOT_FONT_DP, cell.lineFactor) <= ah && - !short; - return { - score: - -penalty({ - action: !withAction && trailingOf(props) !== null, - time: !time && props.scheduledTime !== null, - labels: labels ? 'all' : 'none', - mark: !showMark, - }) - 3, - draw: palette => { - const body = ( - - {countColumn(props, palette, { - fontSize, - gap, - width: time ? Number.POSITIVE_INFINITY : 0, - rtl, - labels, - })} - {showSlot ? slotLine(slot, palette) : null} - - ); - const start = ( - - {inReadingOrder([showMark ? logo(markSize) : null, body], rtl)} - - ); - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - start, - spacer('gap', TRAILING_GAP_DP), - trailing === null ? null : renderTrailing(trailing, palette, rtl), - ], - rtl - ), - }); - }, - }; -} - -/** - * The mark and the counts as one row, with the action beside them, under them, - * or (`alone`) not at all. - */ -function linePlan( - props: AndroidWidgetProps, - cell: Cell, - { layout, note }: { layout: 'beside' | 'below' | 'alone'; note: boolean } -): Plan | null { - const { rtl } = cell; - const pad = cell.height < TALL_MIN_HEIGHT_DP ? SHORT_PAD_DP : TALL_PAD_DP - 2; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const countHeight = lineHeight(SHORT_COUNT_FONT_DP, cell.lineFactor); - const wanted = trailingOf(props, note); - const trailing = layout === 'alone' ? null : wanted; - if (layout !== 'alone' && trailing === null) { - return null; - } - const beside = layout === 'beside'; - const trailingBox = trailing === null ? null : trailingSize(trailing, cell.lineFactor); - const markSize = ah >= 40 ? 28 : 24; - const slot = slotTextOf(props); - const slotHeight = BODY_GAP_DP + lineHeight(SLOT_FONT_DP, cell.lineFactor); - let bodyHeight = countHeight; - if (layout === 'below' && trailingBox !== null) { - bodyHeight += BODY_GAP_DP + trailingBox.height; - } - if (bodyHeight > ah) { - return null; - } - // The 48 dp target may reach into the padding; its drawn pill may not. - if (beside && trailingBox !== null && trailingBox.height > cell.height - 2 * (pad - 6)) { - return null; - } - const showSlot = slot !== null && bodyHeight + slotHeight <= ah; - const trailingWidth = trailingBox?.width ?? 0; - const room = (mark: boolean): number => - aw - - (mark && markSize <= ah ? markSize + MARK_GAP_DP : 0) - - (beside ? TRAILING_GAP_DP + trailingWidth : 0); - let showMark = markSize <= ah; - let level = fitLine(props, room(showMark)); - if (level === null && showMark) { - showMark = false; - level = fitLine(props, room(false)); - } - if (level === null || (layout === 'below' && trailingWidth > room(showMark))) { - return null; - } - const chosen = level; - return { - score: - -penalty({ - action: layout === 'alone' && wanted !== null, - time: !chosen.time && props.scheduledTime !== null, - labels: chosen.labels, - mark: !showMark, - }) - (beside ? 6 : 5), - draw: palette => { - const body = ( - - {countLine(props, chosen, { palette, rtl })} - {showSlot ? slotLine(slot, palette) : null} - {layout === 'below' && trailing !== null ? renderTrailing(trailing, palette, rtl) : null} - - ); - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - showMark ? logo(markSize) : null, - showMark ? : null, - body, - beside && trailing !== null ? spacer('gap', TRAILING_GAP_DP) : null, - beside && trailing !== null ? renderTrailing(trailing, palette, rtl) : null, - ], - rtl - ), - }); - }, - }; -} - -/** - * Every counts composition the cell can hold. A failed Approve's note rides - * above the retry chip when there is room; the variants without it rank lower. - */ -function countPlans(props: AndroidWidgetProps, cell: Cell): Plan[] { - const tall = cell.height >= TALL_MIN_HEIGHT_DP; - const short = !tall; - const failed = props.actionFeedback === 'couldNotApprove'; - return (failed ? [true, false] : [true]).flatMap(note => - [ - tall ? cardPlan(props, cell, { note, chipOnly: false }) : null, - tall ? cardPlan(props, cell, { note, chipOnly: true }) : null, - sidePlan(props, cell, { short, withAction: true, note }), - sidePlan(props, cell, { short, withAction: false, note }), - linePlan(props, cell, { layout: 'below', note }), - linePlan(props, cell, { layout: 'beside', note }), - linePlan(props, cell, { layout: 'alone', note }), - ] - .filter((plan): plan is Plan => plan !== null) - // Without the failure's note a plan ranks below the same plan with it. - .map(plan => (failed && !note ? { score: plan.score - 3, draw: plan.draw } : plan)) - ); -} - -/** The centered composition's type and mark sizes, a step up for each taller bound. */ -function centeredSizes(height: number) { - if (height >= NIGHTSTAND_MIN_HEIGHT_DP) { - return { fontSize: STATUS_FONT_DP.nightstand, markSize: 48 }; - } - if (height >= LARGE_MIN_HEIGHT_DP) { - return { fontSize: STATUS_FONT_DP.large, markSize: 40 }; - } - return { fontSize: STATUS_FONT_DP.tall, markSize: 30 }; -} - -/** A tall cell's count-less composition: mark, copy, and action, centered on both axes. */ -function centeredStatusPlan(props: AndroidWidgetProps, cell: Cell): Plan | null { - const text = props.statusLine ?? ''; - const action = actionOf(props); - const pad = TALL_PAD_DP; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const { fontSize, markSize } = centeredSizes(cell.height); - const lines = Math.min(3, lineCount(text, fontSize, Math.min(aw, 260))); - const textHeight = lines * lineHeight(fontSize, cell.lineFactor); - const showAction = action !== null && actionWidth(action) <= aw; - const actionPart = showAction ? BLOCK_GAP_DP + ACTION_TARGET_DP : 0; - const showMark = markSize + BLOCK_GAP_DP + textHeight + actionPart <= ah; - if (!showMark && textHeight + actionPart > ah) { - return null; - } - return { - score: -penalty({ - action: action !== null && !showAction, - time: false, - labels: 'all', - mark: !showMark, - }), - draw: palette => - root(props, palette, { - style: { - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - flexGap: BLOCK_GAP_DP, - padding: pad, - }, - children: [ - showMark ? logo(markSize) : null, - , - showAction ? actionChip(action, palette) : null, - ], - }), - }; -} - -/** - * A one-row count-less composition (or a tall cell's fallback): the mark, the - * copy, and the action beside the copy, under it, or (`alone`) not at all. - */ -function rowStatusPlan( - props: AndroidWidgetProps, - cell: Cell, - layout: 'beside' | 'below' | 'alone' -): Plan | null { - const { rtl } = cell; - const text = props.statusLine ?? ''; - const action = layout === 'alone' ? null : actionOf(props); - if (layout !== 'alone' && action === null) { - return null; - } - const pad = cell.height >= TALL_MIN_HEIGHT_DP ? TALL_PAD_DP - 2 : SHORT_PAD_DP; - // The 48 dp target may reach into the padding; its drawn pill may not. - if (layout === 'beside' && ACTION_TARGET_DP > cell.height - 2 * (pad - 6)) { - return null; - } - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const fontSize = STATUS_FONT_DP.short; - const markSize = ah >= 40 ? 28 : 24; - const actionWide = action === null ? 0 : actionWidth(action); - const roomFor = (mark: boolean): number => - aw - - (mark ? markSize + MARK_GAP_DP : 0) - - (layout === 'beside' ? TRAILING_GAP_DP + actionWide : 0); - const fitsWith = (mark: boolean): boolean => - roomFor(mark) >= 48 && (layout !== 'below' || actionWide <= roomFor(mark)); - const showMark = fitsWith(true); - if (!showMark && !fitsWith(false)) { - return null; - } - const room = roomFor(showMark); - const reserve = layout === 'below' ? BODY_GAP_DP + ACTION_TARGET_DP : 0; - const maxLines = Math.floor((ah - reserve) / lineHeight(fontSize, cell.lineFactor)); - if (maxLines < 1) { - return null; - } - const wanted = lineCount(text, fontSize, room); - const lines = Math.min(maxLines, 2, wanted); - return { - score: - -penalty({ - action: layout === 'alone' && actionOf(props) !== null, - time: false, - labels: 'all', - mark: !showMark, - }) - - (wanted > lines ? 4 : 0) - - // One line beside the action reads better than two squeezed beside it. - (layout === 'beside' ? 2 * (lines - 1) : 0) - - (layout === 'below' ? 1 : 0) - - 10, - draw: palette => { - const copy = ( - - ); - let body: React.ReactNode = copy; - if (layout === 'beside') { - body = ( - - {copy} - - ); - } else if (layout === 'below' && action !== null) { - body = ( - - {copy} - {actionChip(action, palette)} - - ); - } - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - showMark ? logo(markSize) : null, - showMark ? : null, - body, - layout === 'beside' ? ( - - ) : null, - layout === 'beside' && action !== null ? actionChip(action, palette) : null, - ], - rtl - ), - }); - }, - }; -} - -/** Every count-less composition the cell can hold. */ -function statusPlans(props: AndroidWidgetProps, cell: Cell): Plan[] { - return [ - cell.height >= TALL_MIN_HEIGHT_DP ? centeredStatusPlan(props, cell) : null, - rowStatusPlan(props, cell, 'beside'), - rowStatusPlan(props, cell, 'below'), - rowStatusPlan(props, cell, 'alone'), - ].filter((plan): plan is Plan => plan !== null); -} - -/** The fallback when no composition fits: the counts as a tight line, nothing else. */ -function fallbackPlan(props: AndroidWidgetProps, cell: Cell): Plan { - return { - score: Number.NEGATIVE_INFINITY, - draw: palette => - root(props, palette, { - style: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: SHORT_PAD_DP, - }, - children: - props.countLines.length === 0 - ? slotLine(props.statusLine ?? '', palette, STATUS_FONT_DP.short) - : countLine(props, BARE_LINE, { palette, rtl: cell.rtl }), - }), - }; -} - -function bestPlan(props: AndroidWidgetProps, cell: Cell): Plan { - const plans = props.countLines.length === 0 ? statusPlans(props, cell) : countPlans(props, cell); - let best = fallbackPlan(props, cell); - for (const plan of plans) { - if (plan.score > best.score) { - best = plan; - } - } - return best; -} - -/** - * Distinct light and dark layouts through the library's theme callback, drawn - * from one plan so both themes keep the same composition. - */ export function renderActiveAgentsWidget( props: AndroidWidgetProps, info: WidgetInfo, rtl = false ): WidgetRepresentation { - const plan = bestPlan(props, { - width: info.width, - height: info.height, - rtl, - lineFactor: lineFactorOf(props), - }); return { - light: plan.draw(LIGHT) as React.JSX.Element, - dark: plan.draw(DARK) as React.JSX.Element, + light: draw(props, info, { palette: lightColors, rtl }), + dark: draw(props, info, { palette: darkColors, rtl }), }; } diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index 40d582a7bd..bc84a4ff51 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -6,6 +6,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GlanceablePublisher } from '@/lib/glanceable/publisher'; +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; import { setGlanceableDelivery } from '@/lib/glanceable/sink-registry'; import { _resetWaitingAskForTests, @@ -23,6 +24,10 @@ import { setGlanceableActionNotice, } from './android-sink'; import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission'; +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], + getCalendars: () => [{ uses24hourClock: false }], +})); const mocks = vi.hoisted(() => { let notification: { @@ -260,6 +265,7 @@ beforeEach(() => { vi.setSystemTime(NOW); mocks.native.setWidgetSnapshot('', 0); _resetAndroidSinkForTests(); + _resetHomeWidgetDataForTests(); _resetWaitingAskForTests(); // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); @@ -1190,6 +1196,13 @@ describe('androidSink widget publish and end', () => { vi.setSystemTime(NOW + 28_800_000); expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired'); expect(getCurrentWidgetProps()?.countLines).toEqual([]); + expect(getCurrentWidgetProps()?.home).toMatchObject({ + primaryKind: 'running', + primaryCount: 1, + stale: true, + checkedAt: snapshot.updatedAt, + canCreate: true, + }); } ); diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index 411db2a207..49b3bc404e 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -12,6 +12,10 @@ import { import { requestWidgetUpdate } from 'react-native-android-widget'; import { i18n } from '@/i18n'; +import { + getHomeWidgetDataForSnapshot, + getLastHomeWidgetData, +} from '@/lib/glanceable/home-widget-data'; import { getLiveActivityEnabled } from '@/lib/glanceable/live-activity-switch'; import { getGlanceableDelivery, @@ -153,13 +157,15 @@ function widgetPropsFor(snapshot: GlanceableAgentsSnapshot): AndroidWidgetProps translate, formatGlanceableCount, formatGlanceableAgo, - formatGlanceableClock + formatGlanceableClock, + getHomeWidgetDataForSnapshot(snapshot) ); } /** A delayed render must check the current snapshot and its deadline, not cached props. */ export function getCurrentWidgetProps(): AndroidWidgetProps | null { - return lastWidgetSnapshot === null ? null : widgetPropsFor(lastWidgetSnapshot); + const snapshot = lastWidgetSnapshot ?? getLastHomeWidgetData()?.snapshot ?? null; + return snapshot === null ? null : widgetPropsFor(snapshot); } function renderWidgetNow(props: AndroidWidgetProps): void { diff --git a/apps/mobile/src/glanceable-android/approve-task.test.ts b/apps/mobile/src/glanceable-android/approve-task.test.ts index 2c459aa47e..e028e93c15 100644 --- a/apps/mobile/src/glanceable-android/approve-task.test.ts +++ b/apps/mobile/src/glanceable-android/approve-task.test.ts @@ -1,7 +1,6 @@ -/* eslint-disable max-lines, eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- one cohesive approve-task suite, and the entry, the Kotlin worker and the Kotlin service are sources: running/reading them from disk is the only way to see the registered keys */ +/* eslint-disable max-lines, eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- cohesive approval lifecycle suite; native watchdog contracts read the worker source */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { runInNewContext } from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,7 +10,7 @@ import { type GlanceableApproveResult } from '@/lib/glanceable/approve-ask'; import { getGlanceableSinks } from '@/lib/glanceable/sink-registry'; import { type WaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { type ApproveRunner } from './approve-task'; +import { type ApproveRunner, handleApproveTask, runApproveTask } from './approve-task'; const ASK: WaitingAsk = { kiloSessionId: 'ses_1', @@ -25,7 +24,6 @@ const ASK: WaitingAsk = { const mocks = vi.hoisted(() => { const order: string[] = []; - const tasks = new Map Promise>(); return { readWaitingAsk: vi.fn<() => Promise>(), recordWaitingAsk: vi.fn<(ask: WaitingAsk | null) => void>(), @@ -52,7 +50,6 @@ const mocks = vi.hoisted(() => { startOrUpdate: vi.fn(), }, order, - tasks, applyWidgetLanguage: vi.fn(async () => { await Promise.resolve(); order.push('language'); @@ -61,9 +58,7 @@ const mocks = vi.hoisted(() => { await Promise.resolve(); order.push('approve'); }), - registerHeadlessTask: vi.fn((key: string, provider: () => () => Promise) => { - tasks.set(key, provider()); - }), + registerHeadlessTask: vi.fn(), }; }); @@ -115,17 +110,8 @@ vi.mock('@/lib/glanceable/approve-front-agent', () => ({ approveFrontAgent: mocks.approveFrontAgent, })); -const { - APPROVE_AGENT_TASK_KEY, - APPROVE_HEADLESS_TASK_KEY, - handleApproveTask, - registerApproveTask, - runApproveTask, -} = await import('./approve-task'); - -/** The catalog key for the retryable line, and the copy this slice must show. */ const APPROVE_FAILED_KEY = 'glanceable.approveFailed'; -const APPROVE_FAILED = "Couldn't approve. Tap Approve to try again."; +let APPROVE_FAILED = ''; const NATIVE_SOURCE_DIR = join( __dirname, '..', @@ -141,16 +127,10 @@ const NATIVE_SOURCE_DIR = join( 'activeagentsliveupdate' ); const WORKER_SOURCE = readFileSync(join(NATIVE_SOURCE_DIR, 'ActiveAgentsApproveWorker.kt'), 'utf8'); -const SERVICE_SOURCE = readFileSync( - join(NATIVE_SOURCE_DIR, 'ActiveAgentsApproveTaskService.kt'), - 'utf8' -); -const ENTRY_SOURCE = readFileSync(join(__dirname, '..', '..', 'index.js'), 'utf8'); beforeEach(async () => { vi.clearAllMocks(); mocks.order.length = 0; - mocks.tasks.clear(); mocks.language.whenLanguagePreferenceLoaded.mockResolvedValue(undefined); mocks.language.getResolvedLanguage.mockReturnValue('en'); mocks.readWaitingAsk.mockResolvedValue(ASK); @@ -159,6 +139,7 @@ beforeEach(async () => { mocks.restorePersistedGlanceable.mockResolvedValue(undefined); // The language case switches the shared instance; every other case is English. await i18n.changeLanguage('en'); + APPROVE_FAILED = i18n.t(APPROVE_FAILED_KEY); }); /** A promise a case releases by hand, so a task's await on it is observable. */ @@ -199,10 +180,6 @@ describe('handleApproveTask', () => { expect(mocks.dismissNeedsInputNotification).toHaveBeenCalledWith('ses_1'); }); - it('shows the catalog copy for the retryable key', () => { - expect(i18n.t(APPROVE_FAILED_KEY)).toBe(APPROVE_FAILED); - }); - it('keeps the ask, shows the failure line and republishes on a retryable failure', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); @@ -430,137 +407,6 @@ describe('runApproveTask', () => { }); }); -describe('registerApproveTask', () => { - it('registers the task under the key the Kotlin service starts', async () => { - registerApproveTask(); - - expect(mocks.registerHeadlessTask).toHaveBeenCalledTimes(1); - expect(mocks.registerHeadlessTask.mock.calls[0]?.[0]).toBe(APPROVE_AGENT_TASK_KEY); - - await mocks.tasks.get(APPROVE_AGENT_TASK_KEY)?.(); - - expect(mocks.order).toEqual(['language', 'approve']); - expect(mocks.approveFrontAgent).toHaveBeenCalledTimes(1); - }); -}); - -type Registration = { key: string; factory: () => unknown }; - -/** - * Run the app entry with a stub `require`, so what it registered and when it - * loaded the task module are observable without loading the native graph. - */ -function evaluateEntry(platform: string): { - registrations: Registration[]; - required: string[]; -} { - const required: string[] = []; - const registrations: Registration[] = []; - const requireFn = (id: string): unknown => { - required.push(id); - switch (id) { - case 'react-native': { - return { - Platform: { OS: platform }, - AppRegistry: { - registerHeadlessTask: (key: string, factory: () => unknown): void => { - registrations.push({ key, factory }); - }, - }, - }; - } - case './src/lib/dev-logbox': { - // The entry drops expo-iap's developer copy for a failed - // available-purchases query before the router entry loads; the stub - // answers with the one call it makes. - return { applyDevLogBoxFilters: (): void => undefined }; - } - case 'react-native-android-widget': { - return { registerWidgetTaskHandler: (): void => undefined }; - } - case './src/glanceable-android/register': { - return { handleWidgetTask: (): void => undefined }; - } - case './src/glanceable-android/approve-task': { - return { - APPROVE_HEADLESS_TASK_KEY, - APPROVE_AGENT_TASK_KEY, - handleApproveTask, - // The entry registers the task-service chain through this call; the - // stub records the key it would register. - registerApproveTask: (): void => { - registrations.push({ key: APPROVE_AGENT_TASK_KEY, factory: () => undefined }); - }, - }; - } - case 'expo-router/entry': { - return {}; - } - case './src/lib/app-actions/app-action-dispatch': { - // The entry registers the OS-action dispatcher after the router entry; - // the stub answers with the one call it makes. - return { registerAppActionDispatcher: (): void => undefined }; - } - case './src/lib/notification-background-task': { - // The entry defines and registers the background-notification task last, - // for the same headless-context reason; the stub answers with a resolved - // registration so the entry's `.catch` has a promise to attach to. - return { - registerNotificationBackgroundTask: async (): Promise => { - await Promise.resolve(); - }, - }; - } - case './src/lib/glanceable-refresh-task': { - return { - registerGlanceableRefreshTask: async (): Promise => { - await Promise.resolve(); - }, - }; - } - default: { - throw new Error(`The entry required an unexpected module: ${id}`); - } - } - }; - runInNewContext(ENTRY_SOURCE, { require: requireFn }); - return { registrations, required }; -} - -describe('the headless task keys', () => { - it('registers both Approve tasks when the platform is android', () => { - expect(APPROVE_HEADLESS_TASK_KEY).toBe('KiloActiveAgentsApprove'); - // Only the string crosses into Kotlin; the worker's `TASK_NAME` and the - // entry's literal must name the same task. - expect(WORKER_SOURCE).toContain(`TASK_NAME = "${APPROVE_HEADLESS_TASK_KEY}"`); - - const android = evaluateEntry('android'); - - expect(android.registrations.map(registration => registration.key)).toEqual([ - APPROVE_HEADLESS_TASK_KEY, - APPROVE_AGENT_TASK_KEY, - ]); - const [workerRegistration] = android.registrations; - expect(workerRegistration?.factory()).toBe(handleApproveTask); - // `registerApproveTask` requires the module at entry, so the widget-style - // `require` inside the factory above is not what loads it; the entry always - // has it loaded by the time a task fires. - expect(android.required).toContain('./src/glanceable-android/approve-task'); - }); - - it('names the key the headless task service starts', () => { - expect(APPROVE_AGENT_TASK_KEY).toBe('ActiveAgentsApprove'); - expect(SERVICE_SOURCE).toContain(`TASK_KEY = "${APPROVE_AGENT_TASK_KEY}"`); - }); - - it('registers no headless task off Android', () => { - const ios = evaluateEntry('ios'); - - expect(ios.registrations).toEqual([]); - expect(ios.required).not.toContain('./src/glanceable-android/approve-task'); - }); -}); - /** One member's source, so a lifecycle claim is asserted where it lives. */ function workerMember(header: string): string { const escaped = header.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); diff --git a/apps/mobile/src/glanceable-android/approve-task.ts b/apps/mobile/src/glanceable-android/approve-task.ts index 6202247cc5..546e254783 100644 --- a/apps/mobile/src/glanceable-android/approve-task.ts +++ b/apps/mobile/src/glanceable-android/approve-task.ts @@ -19,24 +19,21 @@ import { * The JS bodies the ongoing notification's Approve action runs headless. * * Two registrations share this module. `handleApproveTask` answers the ask the - * app recorded, and the entry registers it under `APPROVE_HEADLESS_TASK_KEY` - * via the Kotlin worker; `runApproveTask` runs the front-approval service and - * is registered under `APPROVE_AGENT_TASK_KEY` by `registerApproveTask`, the - * key the `ActiveAgentsApproveTaskService` chain starts. Only the strings cross - * the native boundary, so they are asserted equal in `approve-task.test.ts`. + * app recorded, and the entry registers it under `KiloActiveAgentsApprove`, + * the Kotlin worker's `TASK_NAME`; `runApproveTask` runs the front-approval + * service and is registered under `APPROVE_AGENT_TASK_KEY` by + * `registerApproveTask`, the key the `ActiveAgentsApproveTaskService` chain + * starts. Only the strings cross the native boundary. * * The recorded waiting ask is the only thing `handleApproveTask` answers — * never the snapshot's counts — and it also names the ids the republish below * needs. - */ -export const APPROVE_HEADLESS_TASK_KEY = 'KiloActiveAgentsApprove'; - -/** + * * The key the `ActiveAgentsApproveTaskService` chain starts. One literal, * shared by the Kotlin service and this registration: a mismatch would leave * that notification action with no task. */ -export const APPROVE_AGENT_TASK_KEY = 'ActiveAgentsApprove'; +const APPROVE_AGENT_TASK_KEY = 'ActiveAgentsApprove'; /** The approval the task runs. Injected so the flow is unit-testable. */ export type ApproveRunner = () => Promise; diff --git a/apps/mobile/src/glanceable-android/count-format.test.ts b/apps/mobile/src/glanceable-android/count-format.test.ts new file mode 100644 index 0000000000..d9a0046bf5 --- /dev/null +++ b/apps/mobile/src/glanceable-android/count-format.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { formatGlanceableClock } from './count-format'; + +const settings = vi.hoisted(() => ({ + language: 'en-US', + uses24hourClock: false as boolean | null, +})); +vi.mock('@/i18n', () => ({ + i18n: { + get language() { + return settings.language; + }, + }, +})); +vi.mock('expo-localization', () => ({ + getCalendars: () => [{ uses24hourClock: settings.uses24hourClock }], +})); +vi.mock('@/lib/utils', () => ({ parseTimestamp: (at: string) => new Date(at), timeAgo: () => '' })); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 9, 12, 0)); + settings.language = 'en-US'; + settings.uses24hourClock = false; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Home wake and checked clock formatting', () => { + it('honors the device 24-hour setting even when the app language normally uses 12-hour time', () => { + settings.uses24hourClock = true; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toBe('18:30'); + expect(formatGlanceableClock(new Date(2026, 9, 9, 0, 30).toISOString())).toBe('00:30'); + }); + + it('honors the device 12-hour setting even when the app language normally uses 24-hour time', () => { + settings.language = 'de'; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toMatch(/6:30.*PM/u); + }); + + it('uses language defaults only when the native clock preference is unavailable', () => { + settings.language = 'de'; + settings.uses24hourClock = null; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toBe('18:30'); + }); + + it('includes a local calendar day for non-today wakes and old checked timestamps', () => { + settings.uses24hourClock = true; + const tomorrow = new Date(2026, 9, 10, 18, 30); + const yesterday = new Date(2026, 9, 8, 18, 30); + expect(formatGlanceableClock(tomorrow.toISOString())).toBe( + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + hourCycle: 'h23', + }).format(tomorrow) + ); + expect(formatGlanceableClock(yesterday.toISOString())).toBe( + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + hourCycle: 'h23', + }).format(yesterday) + ); + expect(formatGlanceableClock(tomorrow.toISOString())).not.toBe('18:30'); + }); + + it('reads changes to the device clock setting on the next headless redraw', () => { + const at = new Date(2026, 9, 9, 18, 30).toISOString(); + expect(formatGlanceableClock(at)).toMatch(/6:30.*PM/u); + settings.uses24hourClock = true; + expect(formatGlanceableClock(at)).toBe('18:30'); + }); +}); diff --git a/apps/mobile/src/glanceable-android/count-format.ts b/apps/mobile/src/glanceable-android/count-format.ts index e98dfcc715..e67da01829 100644 --- a/apps/mobile/src/glanceable-android/count-format.ts +++ b/apps/mobile/src/glanceable-android/count-format.ts @@ -1,3 +1,4 @@ +import { getCalendars } from 'expo-localization'; import { i18n } from '@/i18n'; import { RTL_LANGUAGES, type SupportedLanguage } from '@/i18n/languages'; import { dateTimeFormat, numberFormat } from '@/lib/intl-cache'; @@ -41,11 +42,21 @@ export function formatGlanceableAgo(at: string): string { return timeAgo(parseTimestamp(at)); } -/** - * The clock time a scheduled count row draws beside its count ("8:00 PM", or - * "20:00" where the language reads a 24-hour clock): the row says when the - * agent wakes, the way the iOS widget's scheduled row does. - */ -export function formatGlanceableClock(at: string): string { - return dateTimeFormat(i18n.language, { timeStyle: 'short' }).format(parseTimestamp(at)); +/** Checked dates stay absolute because a widget bitmap cannot advance a relative age offline. */ +export function formatGlanceableClock(at: string, options?: { includeDate: boolean }): string { + const date = parseTimestamp(at); + const now = new Date(); + const today = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const uses24hourClock = getCalendars().at(0)?.uses24hourClock ?? null; + return dateTimeFormat(i18n.language, { + ...(today && !options?.includeDate + ? { timeStyle: 'short' as const } + : { dateStyle: 'short' as const, timeStyle: 'short' as const }), + ...(uses24hourClock === null + ? {} + : { hourCycle: uses24hourClock ? ('h23' as const) : ('h12' as const) }), + }).format(date); } diff --git a/apps/mobile/src/glanceable-android/home-copy.ts b/apps/mobile/src/glanceable-android/home-copy.ts new file mode 100644 index 0000000000..be67a5ac13 --- /dev/null +++ b/apps/mobile/src/glanceable-android/home-copy.ts @@ -0,0 +1,140 @@ +import { type HomeWidgetPresentation } from '@kilocode/app-shared/home-widget'; + +import { type GlanceableCountKind } from '@/lib/glanceable/presentation'; +import { type GlanceableActionFeedback } from '@/lib/glanceable/surface-extras'; + +export type AndroidWidgetCount = { label: string; kind: GlanceableCountKind; count: string }; +export type GlanceableCountFormat = (value: number) => string; +export type GlanceableClockFormat = (at: string, options?: { includeDate: boolean }) => string; + +/** Translated Home widget copy; the layout never translates or formats on its own. */ +export type AndroidWidgetHomeCopy = { + primaryCount: string; + primaryLabel: string | null; + status: string | null; + detail: string | null; + checked: string | null; + secondaryCounts: AndroidWidgetCount[]; + waitingAgents: { title: string; reason: string }[]; + scheduledAgents: { title: string; time: string | null }[]; + accessibilityLabel: string; +}; + +const HOME_COUNT_KEYS = { + needsInput: 'glanceable.needsInput', + running: 'common.working', + scheduled: 'common.scheduled', + idle: 'common.idle', +} as const; +const WAIT_REASON_KEYS = { + permission: 'agentChat.permissionCard.title', + question: 'glanceable.answerNeeded', + retry: 'glanceable.waitingToRetry', +} as const; +const ACTION_FEEDBACK_KEYS = { + approving: 'glanceable.approving', + couldNotApprove: 'glanceable.couldNotApprove', +} as const; + +function wakeLine( + home: HomeWidgetPresentation, + translate: (key: string) => string, + formatClock: GlanceableClockFormat +): string | null { + if (home.awaitingUpdate) { + return translate('glanceable.awaitingUpdate'); + } + if (home.scheduledAt === null) { + return null; + } + return `${translate('glanceable.nextRun')} ${formatClock(home.scheduledAt)}`; +} + +function scheduledAgentTime( + scheduledAt: string | null, + translate: (key: string) => string, + formatClock: GlanceableClockFormat +): string | null { + if (scheduledAt === null) { + return null; + } + return Date.parse(scheduledAt) <= Date.now() + ? translate('glanceable.awaitingUpdate') + : formatClock(scheduledAt); +} + +// eslint-disable-next-line max-params -- shared policy, translator and injected number/clock formatters keep native/i18n imports out of this builder +export function buildAndroidHomeCopy( + home: HomeWidgetPresentation, + translate: (key: string) => string, + formatCount: GlanceableCountFormat, + formatClock: GlanceableClockFormat, + feedback: GlanceableActionFeedback +): AndroidWidgetHomeCopy { + const primaryLabel = + home.primaryKind === null ? null : translate(HOME_COUNT_KEYS[home.primaryKind]); + const secondaryCounts = home.secondaryCounts.map(line => ({ + ...line, + count: formatCount(line.count), + label: translate(HOME_COUNT_KEYS[line.kind]), + })); + const status = + home.status === 'content' + ? null + : translate( + { + waiting: 'glanceable.waiting', + empty: 'home.noLiveSessions', + unavailable: 'glanceable.privacy', + signed_out: 'glanceable.signedOut', + privacy: 'glanceable.privacy', + }[home.status] + ); + const checked = + home.checkedAt === null + ? null + : `${translate('glanceable.checked')} ${formatClock(home.checkedAt, { includeDate: true })}`; + const wake = wakeLine(home, translate, formatClock); + const actionLine = feedback === null ? null : translate(ACTION_FEEDBACK_KEYS[feedback]); + const title = home.primaryTitle === null ? null : home.primaryTitle || translate('common.agent'); + const knownTitle = home.stale + ? [translate('glanceable.lastKnown'), title].filter(Boolean).join(' · ') + : title; + const detail = + home.status !== 'content' + ? null + : (actionLine ?? (home.primaryKind === 'scheduled' ? (wake ?? knownTitle) : knownTitle)); + const counts = + home.primaryKind === null + ? [] + : [ + `${formatCount(home.primaryCount)} ${primaryLabel}`, + ...secondaryCounts.map(line => `${line.count} ${line.label}`), + ]; + return { + primaryCount: formatCount(home.primaryCount), + primaryLabel, + status, + detail, + checked, + secondaryCounts, + waitingAgents: home.waitingAgents.map(agent => ({ + title: agent.title || translate('common.agent'), + reason: translate(WAIT_REASON_KEYS[agent.kind]), + })), + scheduledAgents: home.scheduledAgents.map(agent => ({ + title: agent.title || translate('common.agent'), + time: scheduledAgentTime(agent.scheduledAt, translate, formatClock), + })), + accessibilityLabel: [ + status, + ...counts, + detail, + home.stale && home.status === 'content' ? translate('glanceable.lastKnown') : null, + checked, + translate('glanceable.openAgents'), + ] + .filter(Boolean) + .join(', '), + }; +} diff --git a/apps/mobile/src/glanceable-android/register.test-helpers.ts b/apps/mobile/src/glanceable-android/register.test-helpers.ts index 6e12784f03..66870f48d9 100644 --- a/apps/mobile/src/glanceable-android/register.test-helpers.ts +++ b/apps/mobile/src/glanceable-android/register.test-helpers.ts @@ -56,7 +56,7 @@ async function runTask( widgetName: 'ActiveAgentsWidget', widgetId: 1, width, - height: 200, + height: 224, screenInfo: { screenWidthDp: 400, screenHeightDp: 800, diff --git a/apps/mobile/src/glanceable-android/register.test.ts b/apps/mobile/src/glanceable-android/register.test.ts index a5f816d2c6..c64e10a8d5 100644 --- a/apps/mobile/src/glanceable-android/register.test.ts +++ b/apps/mobile/src/glanceable-android/register.test.ts @@ -1,8 +1,8 @@ /* eslint-disable max-lines -- one cohesive headless widget-task suite; comments carry the layout reasoning */ -import { - buildGlanceableSnapshot, - type GlanceableAgentsSnapshot, -} from '@kilocode/app-shared/glanceable-agents-snapshot'; +// Dynamic imports deliberately exercise fresh JS process/module-loading boundaries. +import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { EMPTY_HOME_WIDGET_DETAILS } from '@kilocode/app-shared/home-widget'; +import { type JSX } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -14,6 +14,10 @@ import { snapshotFor, store, } from './register.test-helpers'; +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], + getCalendars: () => [{ uses24hourClock: false }], +})); const mocks = vi.hoisted(() => { let snapshot: string | null = null; @@ -25,10 +29,10 @@ const mocks = vi.hoisted(() => { deadline = expiresAt; }, getWidgetSnapshot: () => snapshot, - // The sink's restart adoption reads the durable posted-channel marker; - // this suite never posts a card, so the marker is absent. getPostedChannel: () => null, end: vi.fn(), + startOrUpdate: vi.fn(), + update: vi.fn(), }, getDeadline: () => deadline, resetNativeState: () => { @@ -38,47 +42,58 @@ const mocks = vi.hoisted(() => { language: { value: 'en' }, runWidgetApprove: vi.fn(), linking: { openURL: vi.fn().mockResolvedValue(undefined) }, + applyResponse: vi.fn(), + refresh: { widgetsChanged: vi.fn(), isCurrent: vi.fn() }, + registerHeadlessTask: vi.fn(), + getWidgetInfo: vi.fn(), + requestById: vi.fn(), }; }); -// The approve core is native (tRPC, SecureStore); this suite covers the -// headless dispatch and the redraw around it, and -// `lib/glanceable/widget-actions.test.ts` proves the approve itself. -vi.mock('@/lib/glanceable/widget-actions', () => ({ - runWidgetApprove: mocks.runWidgetApprove, +vi.mock('@/lib/glanceable/widget-actions', () => ({ runWidgetApprove: mocks.runWidgetApprove })); +vi.mock('@/lib/glanceable/home-widget-refresh', () => ({ + HomeWidgetRefresh: mocks.refresh, + applyHomeWidgetResponse: mocks.applyResponse, + restoreNativeHomeWidgetData: vi.fn().mockResolvedValue(undefined), })); - vi.mock('expo', () => ({ requireOptionalNativeModule: () => mocks.native })); -// The widget task resolves the language itself; the real store needs natives. vi.mock('@/lib/hooks/use-language-preference', () => ({ getResolvedLanguage: () => mocks.language.value, whenLanguagePreferenceLoaded: vi.fn().mockResolvedValue(undefined), })); vi.mock('react-native', () => ({ + AppRegistry: { registerHeadlessTask: mocks.registerHeadlessTask }, AppState: { addEventListener: vi.fn() }, Alert: { alert: vi.fn() }, Linking: { openSettings: vi.fn(), openURL: mocks.linking.openURL }, })); vi.mock('react-native-android-widget', () => ({ requestWidgetUpdate: vi.fn().mockResolvedValue(undefined), + requestWidgetUpdateById: mocks.requestById, + getWidgetInfo: mocks.getWidgetInfo, FlexWidget: () => null, TextWidget: () => null, - ImageWidget: () => null, })); async function registerAfterRestart(snapshot: GlanceableAgentsSnapshot | null) { const persist = await import('@/lib/glanceable/persist'); + const home = await import('@/lib/glanceable/home-widget-data'); persist._setSecureStoreForTests(secureStore); + home._setHomeWidgetStoreForTests(secureStore); if (snapshot !== null) { persist.persistGlanceableSink.publish(snapshot); + home.rememberHomeWidgetData({ + snapshot, + details: { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: 'a'.repeat(64) }, + }); + await Promise.resolve(); } - - // Keep only native storage across the simulated JS process restart. vi.resetModules(); const freshPersist = await import('@/lib/glanceable/persist'); + const freshHome = await import('@/lib/glanceable/home-widget-data'); freshPersist._setSecureStoreForTests(secureStore); - const { handleWidgetTask } = await import('./register'); - return handleWidgetTask; + freshHome._setHomeWidgetStoreForTests(secureStore); + return import('./register'); } beforeEach(() => { @@ -88,319 +103,259 @@ beforeEach(() => { vi.setSystemTime(NOW); mocks.resetNativeState(); store.clear(); - secureStore.getItemAsync.mockReset().mockImplementation(async key => { - await Promise.resolve(); - return store.get(key) ?? null; - }); + mocks.language.value = 'en'; + mocks.applyResponse.mockReset().mockResolvedValue(false); + mocks.refresh.isCurrent.mockReset().mockResolvedValue(true); + mocks.refresh.widgetsChanged.mockReset().mockResolvedValue(undefined); + mocks.getWidgetInfo.mockReset().mockResolvedValue([]); + mocks.requestById.mockReset().mockResolvedValue(undefined); + mocks.runWidgetApprove.mockReset(); + // eslint-disable-next-line require-await -- mock returning a resolved promise + secureStore.getItemAsync.mockReset().mockImplementation(async key => store.get(key) ?? null); }); - afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); -// A widget redraw runs headless: nothing else applies the language. -it('applies the resolved language before it renders', async () => { - mocks.language.value = 'ar'; - const handler = await registerAfterRestart(snapshotFor()); - const { i18n } = await import('@/i18n'); - - await runWidgetTask(handler, 250); - mocks.language.value = 'en'; - - expect(i18n.language).toBe('ar'); -}); - -describe.each([120, 250])('registered widget handler at %d dp', width => { - it('restores unexpired persisted counts after a fresh process starts', async () => { - const handler = await registerAfterRestart(snapshotFor()); - const rendered = await runWidgetTask(handler, width); - // Two agents wait, so the widget offers the in-place approval with the - // counts. Where the chip sits is the layout's call, so only the content is - // compared here. - const expected = ['2', 'Needs input', '2', 'Working', '0', 'Scheduled', '0', 'Idle', 'Approve']; - - expect(collectText(rendered.light).toSorted()).toEqual(expected.toSorted()); - expect(collectText(rendered.dark).toSorted()).toEqual(expected.toSorted()); +describe.each([172, 360])('headless Home widget at %d dp', width => { + it('restores counts and Home timestamps after a process restart', async () => { + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + const rendered = await runWidgetTask(handleWidgetTask, width); + const text = collectText(rendered.light); + expect(text).toEqual( + expect.arrayContaining(['Kilo', '2', 'Needs input', '+', width >= 266 ? 'Approve' : '✓']) + ); + expect(text).not.toContain('0'); + expect(text.some(line => line.startsWith('Checked'))).toBe(true); expect(rendered.light.props).toMatchObject({ - clickAction: 'OPEN_URI', clickActionData: { uri: 'kiloapp:///cloud/sessions' }, }); }); - it.each([ - ['happy', 0], - ['happy', 1], - ['stale', 0], - ['stale', 1], - ] as const)('hides %s counts %d ms after expiry', async (status, elapsed) => { - const stored = snapshotFor(undefined, status); - const handler = await registerAfterRestart(stored); - vi.setSystemTime(Date.parse(stored.expiresAt) + elapsed); - - const rendered = await runWidgetTask(handler, width); - - expect(collectText(rendered.light)).toEqual(['Status expired']); - expect(collectText(rendered.dark)).toEqual(['Status expired']); - }); - - it('renders the existing placeholder when no snapshot is persisted', async () => { - const handler = await registerAfterRestart(null); - - const rendered = await runWidgetTask(handler, width); - - // No snapshot means no account behind the widget: the sign-in copy, never - // the generic "No work in progress" that asserts a signed-in empty tray. - expect(collectText(rendered.light)).toEqual(['Sign in to see agents']); - expect(collectText(rendered.dark)).toEqual(['Sign in to see agents']); - }); - - it('renders the existing placeholder when native storage cannot be read', async () => { - const handler = await registerAfterRestart(snapshotFor()); - secureStore.getItemAsync.mockRejectedValueOnce(new Error('SecureStore unavailable')); - - const rendered = await runWidgetTask(handler, width); - - expect(collectText(rendered.light)).toEqual(['Sign in to see agents']); - expect(collectText(rendered.dark)).toEqual(['Sign in to see agents']); - }); - - it.each([ - ['privacy', 'Open Kilo to see agents'], - ['signed_out', 'Sign in to see agents'], - ] as const)('preserves the %s blank even after expiry', async (status, copy) => { - const stored = snapshotFor([], status); - const handler = await registerAfterRestart(stored); + it('retains counts beyond activity expiry without extending the original deadline', async () => { + const stored = snapshotFor(); + mocks.native.setWidgetSnapshot(JSON.stringify(stored), Date.parse(stored.expiresAt)); + const { handleWidgetTask } = await registerAfterRestart(stored); vi.setSystemTime(Date.parse(stored.expiresAt) + 1); - - const rendered = await runWidgetTask(handler, width); - - expect(collectText(rendered.light)).toEqual([copy]); - expect(collectText(rendered.dark)).toEqual([copy]); + const rendered = await runWidgetTask(handleWidgetTask, width); + expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['2', 'Needs input', '+'])); + expect(collectText(rendered.light)).not.toContain('Status expired'); + expect(mocks.getDeadline()).toBe(Date.parse(stored.expiresAt)); }); - it('prefers newer live widget props to the persisted snapshot', async () => { - const stored = snapshotFor(); - const handler = await registerAfterRestart(stored); - const { androidSink } = await import('./android-sink'); - androidSink.publish({ - ...snapshotFor([{ status: 'busy' }]), - revision: stored.revision + 1, - }); - - const rendered = await runWidgetTask(handler, width); - const expected = ['0', 'Needs input', '1', 'Working', '0', 'Scheduled', '0', 'Idle']; + it.each(['privacy', 'signed_out'] as const)( + 'prefers a native %s fence to older private storage', + async status => { + const old = snapshotFor(); + const { handleWidgetTask } = await registerAfterRestart(old); + mocks.native.setWidgetSnapshot(JSON.stringify(snapshotFor([], status)), 0); + const rendered = await runWidgetTask(handleWidgetTask, width); + expect(collectText(rendered.light)).toContain( + status === 'privacy' ? 'Open Kilo to see agents' : 'Sign in to see agents' + ); + expect(collectText(rendered.light)).not.toContain('2'); + expect(collectText(rendered.light)).not.toContain('✓'); + } + ); - expect(collectText(rendered.light)).toEqual(expected); - expect(collectText(rendered.dark)).toEqual(expected); - }); + it.each(['{', JSON.stringify({ status: 'happy' })])( + 'rejects malformed native storage %s', + async raw => { + const { handleWidgetTask } = await registerAfterRestart(null); + mocks.native.setWidgetSnapshot(raw, 0); + const rendered = await runWidgetTask(handleWidgetTask, width); + expect(collectText(rendered.light)).toContain('Sign in to see agents'); + expect(collectText(rendered.light)).not.toContain('2'); + } + ); - it('re-reads native state when an old expiry task reaches newer work', async () => { + it('rereads newer native data after an old alarm queues a task', async () => { const old = snapshotFor(); - const handler = await registerAfterRestart(old); - const { androidSink } = await import('./android-sink'); - androidSink.publish(old); - const newer = buildGlanceableSnapshot({ - sessions: [{ status: 'busy' }], - userId: 'u2', - organizationId: null, - now: NOW + 60_000, - previousRevision: old.revision, - }); + const { handleWidgetTask } = await registerAfterRestart(old); + const newer = { + ...snapshotFor([{ status: 'busy' }]), + updatedAt: new Date(NOW + 60_000).toISOString(), + revision: old.revision + 1, + }; mocks.native.setWidgetSnapshot(JSON.stringify(newer), Date.parse(newer.expiresAt)); - vi.setSystemTime(Date.parse(old.expiresAt)); - - const rendered = await runWidgetTask(handler, width); - const expected = ['0', 'Needs input', '1', 'Working', '0', 'Scheduled', '0', 'Idle']; - expect(collectText(rendered.light)).toEqual(expected); - expect(collectText(rendered.dark)).toEqual(expected); + const rendered = await runWidgetTask(handleWidgetTask, width); + expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['1', 'Working'])); + expect(collectText(rendered.light)).not.toContain('Needs input'); }); - it.each([ - ['privacy', 'Open Kilo to see agents'], - ['signed_out', 'Sign in to see agents'], - ] as const)('reads a native %s blank instead of stale legacy storage', async (status, copy) => { - const old = snapshotFor(); - const handler = await registerAfterRestart(old); - mocks.native.setWidgetSnapshot(JSON.stringify(snapshotFor([], status)), 0); - vi.setSystemTime(Date.parse(old.expiresAt)); - - const rendered = await runWidgetTask(handler, width); - expect(collectText(rendered.light)).toEqual([copy]); - expect(collectText(rendered.dark)).toEqual([copy]); - expect(mocks.getDeadline()).toBe(0); + it('draws progress before approving and settles without opening an Activity', async () => { + mocks.runWidgetApprove.mockResolvedValue({ kind: 'approved' }); + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + const renders = await runWidgetClickTask(handleWidgetTask, width, 'approve'); + expect(mocks.runWidgetApprove).toHaveBeenCalledTimes(1); + expect(renders).toHaveLength(2); + expect(collectText(renders[0]?.light)).toContain('Approving…'); + expect(collectText(renders[0]?.light)).not.toContain(width >= 266 ? 'Approve' : '✓'); + expect(collectText(renders[1]?.light)).toContain(width >= 266 ? 'Approve' : '✓'); + expect(mocks.linking.openURL).not.toHaveBeenCalled(); }); - it.each(['happy', 'stale'] as const)( - 'renders native %s state after a JS reload without extending its expiry', - async status => { - const snapshot = snapshotFor(undefined, status); - const expiresAt = Date.parse(snapshot.expiresAt); - mocks.native.setWidgetSnapshot(JSON.stringify(snapshot), expiresAt); - vi.setSystemTime(NOW + 7_200_000); - const handler = await registerAfterRestart(null); - - const current = await runWidgetTask(handler, width); - expect(collectText(current.light)).toEqual(expect.arrayContaining(['2', 'Needs input'])); - expect(collectText(current.dark)).toEqual(expect.arrayContaining(['2', 'Needs input'])); - expect(mocks.getDeadline()).toBe(expiresAt); - - vi.setSystemTime(expiresAt); - const reloaded = await registerAfterRestart(null); - const expired = await runWidgetTask(reloaded, width); - expect(collectText(expired.light)).toEqual(['Status expired']); - expect(collectText(expired.dark)).toEqual(['Status expired']); - } - ); - - it('expires counts in an already-running handler without a JavaScript timer', async () => { - const snapshot = snapshotFor(); - const handler = await registerAfterRestart(snapshot); - await runWidgetTask(handler, width); - expect(mocks.getDeadline()).toBe(Date.parse(snapshot.expiresAt)); - vi.setSystemTime(Date.parse(snapshot.expiresAt)); - - const rendered = await runWidgetTask(handler, width); - expect(collectText(rendered.light)).toEqual(['Status expired']); - expect(collectText(rendered.dark)).toEqual(['Status expired']); + it('keeps a retry control and failure copy when approval fails', async () => { + mocks.runWidgetApprove.mockResolvedValue({ kind: 'failed' }); + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + const renders = await runWidgetClickTask(handleWidgetTask, width, 'approve'); + expect(collectText(renders.at(-1)?.light)).toEqual( + expect.arrayContaining(['Could not approve', width >= 266 ? 'Approve' : '✓', '+']) + ); + expect(mocks.linking.openURL).not.toHaveBeenCalled(); }); - it.each([ - ['invalid JSON', '{'], - ['missing fields', JSON.stringify({ status: 'happy' })], - ['negative counts', JSON.stringify({ ...snapshotFor(), running: -1 })], - ])('rejects a native snapshot with %s', async (_reason, raw) => { - const handler = await registerAfterRestart(null); - mocks.native.setWidgetSnapshot(raw, 0); - - const rendered = await runWidgetTask(handler, width); - expect(collectText(rendered.light)).toEqual(['Sign in to see agents']); - expect(collectText(rendered.dark)).toEqual(['Sign in to see agents']); + it.each(['none', 'no-permission'])('opens agents when approval cannot answer %s', async kind => { + mocks.runWidgetApprove.mockResolvedValue({ kind }); + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + await runWidgetClickTask(handleWidgetTask, width, 'approve'); + expect(mocks.linking.openURL).toHaveBeenCalledWith('kiloapp:///cloud/sessions'); }); +}); - it('keeps live widget props published while restoration is pending', async () => { - const stored = snapshotFor(); - const handler = await registerAfterRestart(stored); - const { androidSink } = await import('./android-sink'); - const read = Promise.withResolvers(); - secureStore.getItemAsync.mockReturnValueOnce(read.promise); - - const rendering = runWidgetTask(handler, width); - androidSink.publish({ - ...snapshotFor([{ status: 'busy' }]), - revision: stored.revision + 1, - }); - read.resolve(JSON.stringify(stored)); - const rendered = await rendering; - const expected = ['0', 'Needs input', '1', 'Working', '0', 'Scheduled', '0', 'Idle']; +it('applies the selected language before a headless render', async () => { + mocks.language.value = 'ar'; + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + await runWidgetTask(handleWidgetTask, 172); + const { i18n } = await import('@/i18n'); + expect(i18n.language).toBe('ar'); +}); - expect(collectText(rendered.light)).toEqual(expected); - expect(collectText(rendered.dark)).toEqual(expected); +const WIDGET = { + widgetName: 'ActiveAgentsWidget', + widgetId: 1, + width: 172, + height: 224, + screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, +}; +const CONTEXT = { + scopeKey: snapshotFor().scopeKey, + accountEpoch: 1, + generation: 'fence-generation', +}; + +describe('WorkManager Home-only refresh', () => { + it('does not redraw a response rejected by the authenticated generation gate', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(null); + await handleHomeWidgetRefresh({ response: {}, ...CONTEXT }); + expect(mocks.getWidgetInfo).not.toHaveBeenCalled(); + expect(mocks.requestById).not.toHaveBeenCalled(); }); - it('runs the approve action headlessly and redraws around it', async () => { - mocks.runWidgetApprove.mockResolvedValue({ kind: 'approved' }); - const handler = await registerAfterRestart(snapshotFor()); - - const renders = await runWidgetClickTask(handler, width, 'approve'); - - expect(mocks.runWidgetApprove).toHaveBeenCalledTimes(1); - expect(renders).toHaveLength(2); - const [inFlight, settledRender] = renders; - // The request goes out with the progress line already drawn... - expect(collectText(inFlight?.light)).toContain('Approving…'); - // ...and the redraw after it drops the line without moving the rows. - const settled = collectText(settledRender?.light); - expect(settled).not.toContain('Approving…'); - expect(settled.toSorted()).toEqual( - ['2', 'Needs input', '2', 'Working', '0', 'Scheduled', '0', 'Idle', 'Approve'].toSorted() + it('awaits each actual widget draw without starting an ongoing notification', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(snapshotFor()); + const home = await import('@/lib/glanceable/home-widget-data'); + home.rememberHomeWidgetData({ snapshot: snapshotFor(), details: EMPTY_HOME_WIDGET_DETAILS }); + mocks.applyResponse.mockResolvedValue(true); + mocks.getWidgetInfo.mockResolvedValue([WIDGET, { ...WIDGET, widgetId: 2 }]); + const drawn: unknown[] = []; + mocks.requestById.mockImplementation( + async ({ renderWidget }: { renderWidget: (info: typeof WIDGET) => Promise }) => { + drawn.push(await renderWidget(WIDGET)); + } ); - expect(settledRender?.light.props).toMatchObject({ - clickAction: 'OPEN_URI', - clickActionData: { uri: 'kiloapp:///cloud/sessions' }, - }); + await handleHomeWidgetRefresh({ response: {}, ...CONTEXT }); + expect(drawn).toHaveLength(2); + expect(mocks.refresh.isCurrent).toHaveBeenCalledTimes(4); + expect(mocks.native.startOrUpdate).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); }); - it('redraws the republished counts after a successful approve', async () => { - // A successful action republishes the tray through the sink, which writes - // the new snapshot to native storage; the redraw must read that instead of - // the snapshot this task started with. - const approved = buildGlanceableSnapshot({ - sessions: [{ status: 'busy' }], - userId: 'u1', - organizationId: null, - now: NOW, - previousRevision: 2, - }); - mocks.runWidgetApprove.mockImplementation(async () => { - await Promise.resolve(); - mocks.native.setWidgetSnapshot(JSON.stringify(approved), Date.parse(approved.expiresAt)); - return { kind: 'approved' }; - }); - const handler = await registerAfterRestart(snapshotFor()); - - const renders = await runWidgetClickTask(handler, width, 'approve'); - - expect(collectText(renders.at(-1)?.light)).toEqual([ - '0', - 'Needs input', - '1', - 'Working', - '0', - 'Scheduled', - '0', - 'Idle', - ]); + it('checks the native generation again after widget enumeration', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(snapshotFor()); + const home = await import('@/lib/glanceable/home-widget-data'); + home.rememberHomeWidgetData({ snapshot: snapshotFor(), details: EMPTY_HOME_WIDGET_DETAILS }); + mocks.applyResponse.mockResolvedValue(true); + mocks.getWidgetInfo.mockResolvedValue([WIDGET]); + mocks.refresh.isCurrent.mockResolvedValue(false); + await handleHomeWidgetRefresh({ response: {}, ...CONTEXT }); + expect(mocks.requestById).not.toHaveBeenCalled(); }); - it('keeps Approve offered and says so when the approve call fails', async () => { - mocks.runWidgetApprove.mockResolvedValue({ kind: 'failed' }); - const handler = await registerAfterRestart(snapshotFor()); - - const renders = await runWidgetClickTask(handler, width, 'approve'); - const text = collectText(renders.at(-1)?.light); + it('cancels native scheduling when no widget remains', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(snapshotFor()); + const home = await import('@/lib/glanceable/home-widget-data'); + home.rememberHomeWidgetData({ snapshot: snapshotFor(), details: EMPTY_HOME_WIDGET_DETAILS }); + mocks.applyResponse.mockResolvedValue(true); + await handleHomeWidgetRefresh({ response: {}, ...CONTEXT }); + expect(mocks.refresh.widgetsChanged).toHaveBeenCalledOnce(); + }); - // The chip stays to retry. The failure rides over it where the cell has - // room; a 120 dp cell (narrower than any launcher cell) has none. - expect(text.filter(line => line !== 'Could not approve').toSorted()).toEqual( - ['2', 'Needs input', '2', 'Working', '0', 'Scheduled', '0', 'Idle', 'Approve'].toSorted() + it('draws a neutral privacy refusal with no server response or private counts', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(null); + mocks.applyResponse.mockResolvedValue(true); + mocks.getWidgetInfo.mockResolvedValue([WIDGET]); + const rendered: string[] = []; + mocks.requestById.mockImplementation( + async ({ + renderWidget, + }: { + renderWidget: (info: typeof WIDGET) => Promise<{ light: JSX.Element }>; + }) => { + const representation = await renderWidget(WIDGET); + rendered.push(...collectText(representation.light)); + } ); - expect(text.includes('Could not approve')).toBe(width >= 172); - // A failure stays on the widget, whose retry row and body tap remain. - expect(mocks.linking.openURL).not.toHaveBeenCalled(); + await handleHomeWidgetRefresh({ terminal: 'privacy', ...CONTEXT }); + expect(mocks.applyResponse).toHaveBeenCalledWith({ terminal: 'privacy' }, CONTEXT); + expect(rendered).toContain('Open Kilo to see agents'); + expect(rendered).not.toContain('2'); + expect(rendered).not.toContain('+'); }); +}); - it.each([ - ['none', 'kiloapp:///cloud/sessions'], - ['no-permission', 'kiloapp:///cloud/sessions'], - ] as const)( - 'opens the agents list when approve cannot answer (%s)', - async (kind, expectedUri) => { - mocks.runWidgetApprove.mockResolvedValue({ kind }); - const handler = await registerAfterRestart(snapshotFor()); - - await runWidgetClickTask(handler, width, 'approve'); +it('renders an authorized native/Home-only record even without any activity snapshot', async () => { + const { handleWidgetTask } = await registerAfterRestart(null); + const home = await import('@/lib/glanceable/home-widget-data'); + home.rememberHomeWidgetData({ + snapshot: snapshotFor([{ status: 'busy' }]), + details: EMPTY_HOME_WIDGET_DETAILS, + }); + const rendered = await runWidgetTask(handleWidgetTask, 172); + expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['1', 'Working', '+'])); + expect(mocks.native.startOrUpdate).not.toHaveBeenCalled(); +}); - expect(mocks.linking.openURL).toHaveBeenCalledWith(expectedUri); +it('aborts an obsolete generation inside the final draw callback without an unhandled task failure', async () => { + const { handleHomeWidgetRefresh } = await registerAfterRestart(null); + const home = await import('@/lib/glanceable/home-widget-data'); + home.rememberHomeWidgetData({ snapshot: snapshotFor(), details: EMPTY_HOME_WIDGET_DETAILS }); + mocks.applyResponse.mockResolvedValue(true); + mocks.getWidgetInfo.mockResolvedValue([WIDGET]); + mocks.refresh.isCurrent.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const drawn: unknown[] = []; + mocks.requestById.mockImplementation( + async ({ renderWidget }: { renderWidget: (info: typeof WIDGET) => Promise }) => { + drawn.push(await renderWidget(WIDGET)); } ); + await handleHomeWidgetRefresh({ response: {}, ...CONTEXT }); + expect(drawn).toEqual([]); + expect(mocks.refresh.isCurrent).toHaveBeenCalledTimes(2); +}); - it('never opens the app after a completed approve', async () => { - mocks.runWidgetApprove.mockResolvedValue({ kind: 'approved' }); - const handler = await registerAfterRestart(snapshotFor()); - - await runWidgetClickTask(handler, width, 'approve'); - - expect(mocks.linking.openURL).not.toHaveBeenCalled(); - }); - - it('renders normally when a click action belongs to no widget row', async () => { - const handler = await registerAfterRestart(snapshotFor()); - - const renders = await runWidgetClickTask(handler, width, 'OPEN_URI'); +it('updates native widget membership without trying to draw a deleted widget', async () => { + const { handleWidgetTask } = await registerAfterRestart(null); + const renderWidget = vi.fn<(widgetComponent: unknown) => void>(); + await handleWidgetTask({ widgetInfo: WIDGET, widgetAction: 'WIDGET_DELETED', renderWidget }); + expect(mocks.refresh.widgetsChanged).toHaveBeenCalledOnce(); + expect(renderWidget).not.toHaveBeenCalled(); +}); - expect(mocks.runWidgetApprove).not.toHaveBeenCalled(); - expect(renders).toHaveLength(1); - expect(collectText(renders[0]?.light)).toContain('Needs input'); +it('rereads the newly published tray after approval instead of reusing the tapped snapshot', async () => { + const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); + const home = await import('@/lib/glanceable/home-widget-data'); + const { androidSink } = await import('./android-sink'); + const approved = { ...snapshotFor([{ status: 'busy' }]), revision: 2 }; + // eslint-disable-next-line require-await -- mock returning a resolved promise + mocks.runWidgetApprove.mockImplementation(async () => { + home.rememberHomeWidgetSnapshot(approved); + androidSink.publish(approved); + return { kind: 'approved' }; }); + const renders = await runWidgetClickTask(handleWidgetTask, 172, 'approve'); + expect(collectText(renders.at(-1)?.light)).toEqual(expect.arrayContaining(['1', 'Working'])); + expect(collectText(renders.at(-1)?.light)).not.toContain('Needs input'); + expect(collectText(renders.at(-1)?.light)).not.toContain('✓'); }); diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts index 74fef22998..12159cf732 100644 --- a/apps/mobile/src/glanceable-android/register.ts +++ b/apps/mobile/src/glanceable-android/register.ts @@ -1,8 +1,25 @@ import { AppState, Linking } from 'react-native'; -import { type WidgetTaskHandlerProps } from 'react-native-android-widget'; +import { + getWidgetInfo, + requestWidgetUpdateById, + type WidgetTaskHandlerProps, +} from 'react-native-android-widget'; +import { z } from 'zod'; import { i18n } from '@/i18n'; +import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; import { applyStoredLanguage } from '@/lib/glanceable/apply-stored-language'; +import { getTerminalBlankEpoch } from '@/lib/glanceable/cleanup'; +import { + getHomeWidgetDataForSnapshot, + getLastHomeWidgetData, + restoreHomeWidgetData, +} from '@/lib/glanceable/home-widget-data'; +import { + applyHomeWidgetResponse, + HomeWidgetRefresh, + restoreNativeHomeWidgetData, +} from '@/lib/glanceable/home-widget-refresh'; import { getLiveActivityEnabled, subscribeLiveActivityEnabled, @@ -12,7 +29,7 @@ import { registerGlanceableSink } from '@/lib/glanceable/sink-registry'; import { getSurfaceExtras, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; import { runWidgetApprove } from '@/lib/glanceable/widget-actions'; -import { renderActiveAgentsWidget } from './active-agents-widget'; +import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget'; import { androidSink, getCurrentWidgetProps, handleAppStateActive } from './android-sink'; import { formatGlanceableAgo, @@ -89,7 +106,7 @@ const OPEN_AGENTS_URI = 'kiloapp:///cloud/sessions'; * instead of dead-ending on the widget. */ async function handleWidgetApprove( - task: Pick, + task: Pick, currentProps: () => AndroidWidgetProps ): Promise { const { renderWidget, widgetInfo } = task; @@ -98,7 +115,9 @@ async function handleWidgetApprove( }; setSurfaceExtras({ ...getSurfaceExtras(), actionFeedback: 'approving' }); draw(); - const result = await runWidgetApprove(); + const approvalKey = task.clickActionData?.approvalKey; + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- untyped native click payload + const result = await runWidgetApprove(typeof approvalKey === 'string' ? approvalKey : null); setSurfaceExtras({ ...getSurfaceExtras(), // The failure line is Approve's own retry copy, and Approve stays offered; @@ -109,7 +128,7 @@ async function handleWidgetApprove( // Nothing to act on, or the agent asked a free-form question the widget must // never invent an answer to: open the agents list. A failed call stays on the // widget, whose retry row and body tap remain offered. - if (result.kind === 'none' || result.kind === 'no-permission') { + if (result.kind === 'none' || result.kind === 'no-permission' || result.kind === 'stale') { try { await Linking.openURL(OPEN_AGENTS_URI); } catch { @@ -128,9 +147,24 @@ export async function handleWidgetTask(task: WidgetTaskHandlerProps): Promise { + const parsed = homeWidgetRefreshPayloadSchema.safeParse(rawPayload); + if (!parsed.success || HomeWidgetRefresh === null) { + return; + } + const payload = parsed.data; + const context = { + scopeKey: payload.scopeKey, + accountEpoch: payload.accountEpoch, + generation: payload.generation, + }; + const terminal = payload.terminal === 'privacy'; + if (!terminal && !('response' in payload)) { + return; + } + const response = terminal ? { terminal: 'privacy' } : payload.response; + if (!(await applyHomeWidgetResponse(response, context))) { + return; + } + const authEpoch = currentAuthEpoch(); + const blankEpoch = getTerminalBlankEpoch(); + await applyStoredLanguage(); + const data = getLastHomeWidgetData(); + if (!terminal && data === null) { + return; + } + const widgets = await getWidgetInfo(WIDGET_NAME); + if (widgets.length === 0) { + await HomeWidgetRefresh.widgetsChanged(); + return; + } + for (const widget of widgets) { + if ( + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch || + // eslint-disable-next-line no-await-in-loop -- each widget re-checks the native generation before its own redraw + !(await HomeWidgetRefresh.isCurrent( + context.scopeKey, + context.accountEpoch, + context.generation + )) + ) { + return; + } + let superseded = false; + try { + // eslint-disable-next-line no-await-in-loop -- widgets redraw one at a time so a superseded refresh stops the rest + await requestWidgetUpdateById({ + widgetName: WIDGET_NAME, + widgetId: widget.widgetId, + renderWidget: async info => { + if ( + !(await HomeWidgetRefresh?.isCurrent( + context.scopeKey, + context.accountEpoch, + context.generation + )) || + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch + ) { + superseded = true; + throw new Error('Home widget refresh superseded'); + } + const props = + terminal || data === null + ? buildGenericWidgetProps(key => + translate(key === 'glanceable.signedOut' ? 'glanceable.privacy' : key) + ) + : buildCurrentWidgetProps( + data.snapshot, + translate, + formatGlanceableCount, + formatGlanceableAgo, + formatGlanceableClock, + getHomeWidgetDataForSnapshot(data.snapshot) + ); + return renderActiveAgentsWidget(props, info, isWidgetRtl()); + }, + }); + } catch (error) { + // oxlint-disable-next-line typescript-eslint/no-unnecessary-condition -- the renderWidget closure sets `superseded` before this catch can run + if (!superseded) { + throw error; + } + return; + } + } +} diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts index 205a2a5fe5..c97f23ae12 100644 --- a/apps/mobile/src/glanceable-android/widget-props.test.ts +++ b/apps/mobile/src/glanceable-android/widget-props.test.ts @@ -1,10 +1,12 @@ -/* eslint-disable max-lines -- one cohesive props-builder suite sharing the copy-map translator */ import { - buildGlanceableSnapshot, - GLANCEABLE_STALE_MS, - type GlanceableAgentsSnapshot, -} from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { afterEach, describe, expect, it, vi } from 'vitest'; + buildHomeWidgetData, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetData, + type HomeWidgetSessionRow, +} from '@kilocode/app-shared/home-widget'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; @@ -15,18 +17,14 @@ import { buildGenericWidgetProps, buildOngoingNotificationText, } from './widget-props'; -import widgetConfig from './widget-config.json'; const NOW = 1_750_000_000_000; - -/** The newest result's timestamp, forwarded to the age formatter below. */ -const NEWEST_AT = new Date(NOW - 180_000).toISOString(); - +const WAKE = new Date(NOW + 7_200_000).toISOString(); const COPY: Record = { 'glanceable.needsInput': 'Needs input', - 'common.idle': 'Idle', 'common.working': 'Working', 'common.scheduled': 'Scheduled', + 'common.idle': 'Idle', 'glanceable.waiting': 'Waiting for agents', 'glanceable.empty': 'No work in progress', 'glanceable.stale': 'Updates delayed', @@ -34,791 +32,287 @@ const COPY: Record = { 'glanceable.signedOut': 'Sign in to see agents', 'glanceable.privacy': 'Open Kilo to see agents', 'glanceable.openAgents': 'Open agents', - 'glanceable.newestResult': 'Newest result', - 'glanceable.noneWaiting': 'No agents waiting', 'glanceable.newAgent': 'New agent', + 'common.approve': 'Approve', + 'glanceable.checked': 'Checked', + 'glanceable.lastKnown': 'Last known', + 'glanceable.nextRun': 'Next run', + 'glanceable.awaitingUpdate': 'Awaiting update', + 'common.agent': 'Agent', 'glanceable.approving': 'Approving…', 'glanceable.couldNotApprove': 'Could not approve', - 'glanceable.newestSession': 'Newest: {{title}}', - 'common.approve': 'Approve', + 'agentChat.permissionCard.title': 'Permission required', + 'glanceable.answerNeeded': 'Answer needed', + 'glanceable.waitingToRetry': 'Waiting to retry', 'glanceable.scheduledWakes': 'wakes {{time}}', }; -const translate = (key: string): string => COPY[key] ?? key; - -/** - * The three formatters the app injects. The builder stays free of i18n and of - * `Intl`, so the suite hands it the same shapes `count-format.ts` supplies. - * - * `formatAgo` and `formatClock` echo their argument: the time the props carry - * is the timestamp the builder forwarded, so a regression that passed - * `updatedAt` or any other field would produce a different string and fail. - */ -const formatAgo = (at: string): string => `ago:${at}`; -const formatClock = (at: string): string => `clock:${at}`; -const AGO = formatAgo(NEWEST_AT); - -// The extras are module state shared by the publisher and every surface; a -// case that sets them resets them here so it cannot colour the next one. -afterEach(() => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); -}); - -function snapshotFor( - sessions: { status: string; statusUpdatedAt?: string; scheduledAt?: string }[], - revision = 0, - status?: GlanceableAgentsSnapshot['status'] -): GlanceableAgentsSnapshot { - return buildGlanceableSnapshot({ +const translate = (key: string) => COPY[key] ?? key; +const clock = (at: string) => `clock:${at}`; +function dataFor(sessions: HomeWidgetSessionRow[], status?: HomeWidgetData['snapshot']['status']) { + return buildHomeWidgetData({ sessions, - userId: 'u1', - organizationId: null, + userId: 'user-private-id', + organizationId: 'org-private-id', now: NOW, - previousRevision: revision, - ...(status === undefined ? {} : { status }), + ...(status ? { status } : {}), }); } +function propsFor(data: HomeWidgetData) { + return buildAndroidWidgetProps(data.snapshot, {}, translate, String, String, clock, data); +} -// A snapshot from an older producer: it carries the counts but omits -// `needsApproval`, which is what the Approve gate reads. Action cases that need -// an approvable tray add the field explicitly. -const MIXED = { - ...snapshotFor([], 0, 'happy'), - needsInput: 2, - idle: 3, - running: 4, -}; - -describe('buildAndroidWidgetProps', () => { - it('ranks the compact primary count and keeps all expanded numeric counts', () => { - const props = buildAndroidWidgetProps(MIXED, {}, translate, String, formatAgo); - expect(props.primaryLabel).toBe('Needs input'); - expect(props.countLines).toEqual([ - { label: 'Needs input', kind: 'needsInput', count: '2' }, - { label: 'Working', kind: 'running', count: '4' }, - { label: 'Scheduled', kind: 'scheduled', count: '0' }, - { label: 'Idle', kind: 'idle', count: '3' }, - ]); - expect(props.zeroKinds).toEqual(['scheduled']); - }); - - it.each([ - ['happy', '2 Needs input, 4 Working, 3 Idle, Open agents'], - ['stale', 'Updates delayed, 2 Needs input, 4 Working, 3 Idle, Open agents'], - ] as const)( - 'includes numeric counts and the action in the %s spoken label', - (status, expected) => { - const props = buildAndroidWidgetProps({ ...MIXED, status }, {}, translate, String, formatAgo); - expect(props.accessibilityLabel).toBe(expected); - } - ); - - it('applies the locked copy matrix per status', () => { - const cases: [ - GlanceableAgentsSnapshot['status'], - { status: string }[], - string, - number, - boolean, - ][] = [ - ['waiting', [], 'Waiting for agents', 0, false], - // Android's empty state is the one that offers New agent, so it says - // what that action is about instead of the generic no-work copy. - ['empty', [], 'No agents waiting', 0, false], - // Counts show for stale, and all three rows draw whenever they show, so - // the widget's rows never reflow as work moves between states. - ['stale', [{ status: 'busy' }], 'Updates delayed', 4, true], - ['expired', [], 'Status expired', 0, false], - ['signed_out', [], 'Sign in to see agents', 0, false], - ['privacy', [], 'Open Kilo to see agents', 0, false], - ]; - for (const [status, sessions, statusLine, counts] of cases) { - const props = buildAndroidWidgetProps( - snapshotFor(sessions, 0, status), - {}, - translate, - String, - formatAgo - ); - expect(props.statusLine).toBe(statusLine); - expect(props.countLines).toHaveLength(counts); - } - }); +beforeEach(() => { + _resetHomeWidgetDataForTests(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); +}); - it('carries no title, organization name, or raw id into the widget payload', () => { - const snapshot = buildGlanceableSnapshot({ - sessions: [{ status: 'question', statusUpdatedAt: new Date(NOW - 60_000).toISOString() }], - userId: 'user-9f3a-leak', - organizationId: 'org-acme-7-leak', - now: NOW, +const MIXED = dataFor([ + { status: 'permission', title: 'Permission title', approvalKey: 'a'.repeat(64) }, + { status: 'question', title: 'Question title' }, + { status: 'busy', title: 'Running title' }, + { status: 'scheduled', title: 'Schedule title', scheduledAt: WAKE }, + { status: 'idle', title: 'Idle title' }, +]); + +describe('shared Home presentation localization', () => { + it('shows the ranked primary and only nonzero supporting counts, with checkedAt', () => { + const props = propsFor(MIXED); + expect(props.home).toMatchObject({ + primaryKind: 'needsInput', + primaryCount: 2, + canCreate: true, + canApprove: true, }); - - const props = buildAndroidWidgetProps(snapshot, {}, translate, String, formatAgo); - const json = JSON.stringify(props); - - expect(Object.keys(props).toSorted()).toEqual([ - 'accessibilityLabel', - 'actionFeedback', - 'actions', - 'countLines', - 'newestLine', - 'newestResultAgo', - 'newestResultKind', - 'newestResultLabel', - 'newestResultTitle', - 'primaryLabel', - 'scheduledTime', - 'statusLine', - 'zeroKinds', + expect(props.homeCopy).toMatchObject({ + primaryCount: '2', + primaryLabel: 'Needs input', + detail: 'Permission title', + checked: `Checked clock:${MIXED.snapshot.updatedAt}`, + }); + expect(props.homeCopy?.secondaryCounts).toEqual([ + { kind: 'running', count: '1', label: 'Working' }, + { kind: 'scheduled', count: '1', label: 'Scheduled' }, + { kind: 'idle', count: '1', label: 'Idle' }, ]); - expect(json).not.toContain('user-9f3a-leak'); - expect(json).not.toContain('org-acme-7-leak'); - expect(json).not.toContain(snapshot.scopeKey); - expect(json).not.toContain(snapshot.updatedAt); - expect(json).not.toContain('revision'); - expect(json).not.toContain('title'); - }); -}); - -describe('newest-result props', () => { - it('carries the ranked kind, the matching row label, and the injected age', () => { - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'busy', statusUpdatedAt: NEWEST_AT }], 0), - {}, - translate, - String, - formatAgo - ); - - expect(props.newestResultKind).toBe('running'); - expect(props.newestResultTitle).toBe('Newest result'); - expect(props.newestResultLabel).toBe('Working'); - expect(props.newestResultAgo).toBe(AGO); + expect(propsFor(dataFor([{ status: 'busy' }])).homeCopy?.secondaryCounts).toEqual([]); }); - it('reads the label from the same count line the rows draw', () => { + it('localizes numbers for the hero, supporting rows and accessibility together', () => { const props = buildAndroidWidgetProps( - { ...MIXED, newestResultKind: 'idle', newestResultAt: NEWEST_AT }, + MIXED.snapshot, {}, translate, + value => `digit:${value}`, String, - formatAgo + clock, + MIXED ); - - const idle = props.countLines.find(line => line.kind === 'idle'); - expect(props.newestResultLabel).toBe(idle?.label); - expect(props.newestResultLabel).toBe('Idle'); + expect(props.homeCopy?.primaryCount).toBe('digit:2'); + expect(props.homeCopy?.secondaryCounts.every(line => line.count === 'digit:1')).toBe(true); + expect(props.homeCopy?.accessibilityLabel).toContain('digit:2 Needs input'); + expect(props.homeCopy?.accessibilityLabel).toContain('Checked'); + }); + + it('retains counts, action visibility and the confirmed timestamp beyond activity expiry', () => { + vi.setSystemTime(Date.parse(MIXED.snapshot.expiresAt) + 1); + const props = buildCurrentWidgetProps(MIXED.snapshot, translate, String, String, clock, MIXED); + expect(props.home).toMatchObject({ + primaryCount: 2, + stale: true, + checkedAt: MIXED.snapshot.updatedAt, + canCreate: true, + canApprove: true, + }); + expect(props.homeCopy?.checked).toBe(`Checked clock:${MIXED.snapshot.updatedAt}`); + expect(props.homeCopy?.accessibilityLabel).toContain('Last known'); + // Activity expiry still wins for the counts-only props used by notification lifetime. + expect(props.statusLine).toBe('Status expired'); + expect(props.countLines).toEqual([]); }); - // A locked frame carries one fact: no counts, so no third fact either. - it.each(['waiting', 'empty', 'expired', 'signed_out', 'privacy'] as const)( - 'blanks the caption and the newest-result fields on %s', + it('reads scoped retained Home data rather than the zero-count expired activity snapshot', () => { + const expired = { + ...MIXED.snapshot, + status: 'expired' as const, + running: 0, + needsInput: 0, + scheduled: 0, + idle: 0, + }; + const props = buildCurrentWidgetProps(expired, translate, String, String, clock, MIXED); + expect(props.home?.primaryCount).toBe(2); + const noRetained = buildCurrentWidgetProps(expired, translate, String, String, clock); + expect(noRetained.home?.primaryCount).toBe(0); + expect(noRetained.home?.status).toBe('unavailable'); + }); + + it.each(['signed_out', 'privacy'] as const)( + 'never allows retained titles/counts to override %s', status => { const props = buildAndroidWidgetProps( - { ...MIXED, status, newestResultKind: 'running', newestResultAt: NEWEST_AT }, + { ...MIXED.snapshot, status }, {}, translate, String, - formatAgo + String, + clock, + MIXED ); - - expect(props.newestResultKind).toBeNull(); - expect(props.newestResultTitle).toBeNull(); - expect(props.newestResultLabel).toBeNull(); - expect(props.newestResultAgo).toBeNull(); + expect(props.home).toMatchObject({ + status, + primaryCount: 0, + primaryTitle: null, + waitingAgents: [], + scheduledAgents: [], + canCreate: false, + canApprove: false, + }); + expect(props.homeCopy?.secondaryCounts).toEqual([]); + expect(props.homeCopy?.checked).toBeNull(); + expect(JSON.stringify(props)).not.toContain('Permission title'); } ); - // Stale keeps its counts, so the third fact stays in the payload; the large - // cell is what swaps the footer for the warning. - it('keeps the newest result beside the stale timestamp', () => { - const props = buildAndroidWidgetProps( - { ...MIXED, status: 'stale', newestResultKind: 'needsInput', newestResultAt: NEWEST_AT }, - {}, - translate, - String, - formatAgo - ); - - expect(props.statusLine).toBe('Updates delayed'); - expect(props.countLines).toHaveLength(4); - expect(props.newestResultTitle).toBe('Newest result'); - expect(props.newestResultLabel).toBe('Needs input'); - expect(props.newestResultAgo).toBe(AGO); - }); - - it('carries no newest result when no row had a status timestamp', () => { - const props = buildAndroidWidgetProps(MIXED, {}, translate, String, formatAgo); - - // The caption belongs to the footer, not to the result: it is there - // whenever the rows are, and the cell has nothing to put under it. - expect(props.newestResultTitle).toBe('Newest result'); - expect(props.newestResultKind).toBeNull(); - expect(props.newestResultLabel).toBeNull(); - expect(props.newestResultAgo).toBeNull(); - }); -}); - -describe('scheduled wake props', () => { - /** Two hours ahead of the suite's clock, so the stub formatter echoes it. */ - const WAKE = new Date(NOW + 7_200_000).toISOString(); - - it('carries the scheduled row and the wake as a clock time beside it', () => { - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'scheduled', scheduledAt: WAKE }], 0), - {}, - translate, - String, - formatAgo, - formatClock - ); - - // The count line exists whether or not a wake is known, so the surface - // never reflows when the CLI reports a wake for a session it had none for; - // only the time beside the row is conditional. - expect(props.countLines.find(line => line.kind === 'scheduled')).toEqual({ - label: 'Scheduled', - kind: 'scheduled', - count: '1', - }); - expect(props.scheduledTime).toBe(formatClock(WAKE)); - expect(props.primaryLabel).toBe('Scheduled'); - }); - - it('keeps the scheduled row but no wake when the CLI reported none', () => { - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'scheduled' }], 0), - {}, - translate, - String, - formatAgo - ); - - expect(props.countLines.find(line => line.kind === 'scheduled')?.count).toBe('1'); - expect(props.scheduledTime).toBeNull(); - }); - - it('carries no wake when nothing is scheduled', () => { - expect( - buildAndroidWidgetProps(MIXED, {}, translate, String, formatAgo, formatClock).scheduledTime - ).toBeNull(); - }); - - it.each(['waiting', 'empty', 'expired', 'signed_out', 'privacy'] as const)( - 'drops the wake on the locked %s frame that keeps no counts', - status => { + it.each([{ signedOut: true }, { orgInvalid: true }])( + 'honors a direct auth/scope blank flag %j', + flags => { const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'scheduled', scheduledAt: WAKE }], 0, status), - {}, + MIXED.snapshot, + flags, translate, String, - formatAgo, - formatClock + String, + clock, + MIXED ); - - expect(props.scheduledTime).toBeNull(); + expect(props.home?.primaryCount).toBe(0); + expect(props.home?.primaryTitle).toBeNull(); } ); - it('drops the wake with the counts at the data deadline', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 28_800_000); - const props = buildCurrentWidgetProps( - snapshotFor([{ status: 'scheduled', scheduledAt: WAKE }], 0), - translate, - String, - formatAgo, - formatClock - ); - vi.useRealTimers(); - - expect(props.statusLine).toBe('Status expired'); - expect(props.countLines).toEqual([]); - expect(props.scheduledTime).toBeNull(); + it('carries authorized bounded Home titles but no account/session scope identifiers', () => { + const serialized = JSON.stringify(propsFor(MIXED)); + expect(serialized).toContain('Permission title'); + expect(serialized).not.toContain('user-private-id'); + expect(serialized).not.toContain('org-private-id'); + expect(serialized).not.toContain(MIXED.snapshot.scopeKey); }); -}); -describe('current widget deadline rendering', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it.each(['happy', 'stale'] as const)('hides expired %s counts', status => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 28_800_000); - const props = buildCurrentWidgetProps( - { ...MIXED, status }, - translate, - String, - formatAgo, - formatClock - ); - expect(props.statusLine).toBe('Status expired'); - expect(props.countLines).toEqual([]); - expect(props.accessibilityLabel).toBe('Status expired, Open agents'); - }); - - it.each([ - ['privacy', 'Open Kilo to see agents'], - ['signed_out', 'Sign in to see agents'], - ['empty', 'No agents waiting'], - ['waiting', 'Waiting for agents'], - ] as const)('preserves %s copy beyond an old deadline', (status, expected) => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 28_800_001); - const props = buildCurrentWidgetProps( - { ...MIXED, status }, - translate, - String, - formatAgo, - formatClock - ); - expect(props.statusLine).toBe(expected); - expect(props.accessibilityLabel).toBe(`${expected}, Open agents`); - expect(props.countLines).toEqual([]); - }); - - it('hides counts when the stored expiry is not a valid date', () => { - const props = buildCurrentWidgetProps( - { ...MIXED, expiresAt: 'invalid' }, - translate, - String, - formatAgo, - formatClock - ); - expect(props.statusLine).toBe('Status expired'); - expect(props.countLines).toEqual([]); - }); - - it('drops the newest result with the counts at the deadline', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 28_800_000); - const props = buildCurrentWidgetProps( - { ...MIXED, newestResultKind: 'running', newestResultAt: NEWEST_AT }, - translate, - String, - formatAgo, - formatClock - ); - expect(props.newestResultTitle).toBeNull(); - expect(props.newestResultLabel).toBeNull(); - expect(props.newestResultAgo).toBeNull(); - expect(props.countLines).toEqual([]); - }); -}); - -describe('current widget lapsed frame', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - // The Android twin of the iOS stale timeline frame: a redraw a whole stale - // window after the snapshot was taken stops asserting the counts are current. - it.each([ - ['exactly at the window', GLANCEABLE_STALE_MS], - ['past the window', 31 * 60_000], - ])('swaps the age for the delayed copy %s', (_label, elapsed) => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + elapsed); - const props = buildCurrentWidgetProps( - { ...MIXED, newestResultKind: 'running', newestResultAt: NEWEST_AT }, - translate, - String, - formatAgo, - formatClock - ); - - expect(props.statusLine).toBe('Updates delayed'); - // The counts stay: they are still the last thing the device knew, and the - // expiry frame is not yet due at 31 minutes. - expect(props.countLines).toEqual([ - { label: 'Needs input', kind: 'needsInput', count: '2' }, - { label: 'Working', kind: 'running', count: '4' }, - { label: 'Scheduled', kind: 'scheduled', count: '0' }, - { label: 'Idle', kind: 'idle', count: '3' }, + it('uses the earliest scheduled wake ahead of idle details and does not invent overdue running', () => { + const data = dataFor([ + { status: 'scheduled', title: 'Wake', scheduledAt: WAKE }, + { status: 'idle' }, + ]); + expect(propsFor(data).homeCopy?.detail).toBe(`Next run clock:${WAKE}`); + vi.setSystemTime(Date.parse(WAKE)); + const props = propsFor(data); + expect(props.homeCopy?.detail).toBe('Awaiting update'); + expect(props.home?.primaryKind).toBe('scheduled'); + expect(props.home?.primaryCount).toBe(1); + }); + + it('keeps unknown wake times absent while retaining the scheduled count', () => { + const props = propsFor(dataFor([{ status: 'scheduled' }])); + expect(props.home?.primaryKind).toBe('scheduled'); + expect(props.homeCopy?.detail).toBe('Agent'); + expect(props.home?.scheduledAt).toBeNull(); + expect(props.home?.awaitingUpdate).toBe(false); + }); + + it('localizes missing titles generically and carries each waiting reason', () => { + const data = dataFor([{ status: 'permission' }, { status: 'question' }, { status: 'retry' }]); + expect(propsFor(data).homeCopy?.waitingAgents).toEqual([ + { title: 'Agent', reason: 'Permission required' }, + { title: 'Agent', reason: 'Answer needed' }, + { title: 'Agent', reason: 'Waiting to retry' }, ]); - expect(props.primaryLabel).toBe('Needs input'); - expect(props.newestResultTitle).toBe('Newest result'); - }); - - it('keeps the happy frame inside the stale window', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 29 * 60_000); - const props = buildCurrentWidgetProps( - { ...MIXED, newestResultKind: 'running', newestResultAt: NEWEST_AT }, - translate, - String, - formatAgo, - formatClock - ); - - expect(props.statusLine).toBeNull(); - expect(props.newestResultAgo).toBe(AGO); - expect(props.countLines).toHaveLength(4); - }); - - // The deadline keeps its precedence: a lapsed snapshot past `expiresAt` still - // draws the expired frame, never the delayed one behind it. - it('prefers the expired frame past the data deadline', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 28_800_000); - const props = buildCurrentWidgetProps( - { ...MIXED, newestResultKind: 'running', newestResultAt: NEWEST_AT }, - translate, - String, - formatAgo, - formatClock - ); - - expect(props.statusLine).toBe('Status expired'); - expect(props.countLines).toEqual([]); - expect(props.newestResultTitle).toBeNull(); }); - // A locked frame asserts nothing that can lapse, so the lapsed branch skips it. - it.each(['waiting', 'empty', 'signed_out', 'privacy'] as const)( - 'leaves %s copy alone past the stale window', - status => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 31 * 60_000); - const props = buildCurrentWidgetProps( - { ...MIXED, status }, - translate, - String, - formatAgo, - formatClock - ); - expect(props.statusLine).not.toBe('Updates delayed'); - expect(props.countLines).toEqual([]); - } - ); -}); - -describe('widget config', () => { - /** - * The age and the delayed frame are time facts: a surface that redraws only - * when an update is delivered keeps asserting both forever. The platform's - * own periodic redraw is what recomputes them, so the declared period must - * not be longer than the stale window it exists to catch. 30 minutes is - * Android's floor and exactly `GLANCEABLE_STALE_MS`. - */ - it('declares a periodic redraw no longer than the stale window', () => { - const widget = widgetConfig.widgets.find(entry => entry.name === 'ActiveAgentsWidget'); - - expect(widget?.updatePeriodMillis).toBeLessThanOrEqual(GLANCEABLE_STALE_MS); + it('shows approve feedback ahead of a title without claiming that title was approved', () => { + setSurfaceExtras({ newestSessionTitle: 'not Home data', actionFeedback: 'approving' }); + expect(propsFor(MIXED).homeCopy?.detail).toBe('Approving…'); + setSurfaceExtras({ newestSessionTitle: 'not Home data', actionFeedback: 'couldNotApprove' }); + expect(propsFor(MIXED).homeCopy?.detail).toBe('Could not approve'); + expect(propsFor(dataFor([], 'privacy')).homeCopy?.detail).toBeNull(); }); -}); -describe('buildGenericWidgetProps', () => { - // A placed widget with no snapshot has no account behind it — a fresh install - // or a signed-out one — so it must not claim "No work in progress", which - // asserts a signed-in empty tray. It reads the sign-in copy instead. - it('reads the sign-in copy with no counts and no actions', () => { + it('does not attach private titles to the generic no-account fallback', () => { const props = buildGenericWidgetProps(translate); - expect(props.statusLine).toBe('Sign in to see agents'); - expect(props.accessibilityLabel).toBe('Sign in to see agents'); expect(props.countLines).toEqual([]); - expect(props.primaryLabel).toBeNull(); - expect(props.newestResultTitle).toBeNull(); - expect(props.newestLine).toBeNull(); expect(props.actions.approve).toBe(false); expect(props.actions.newAgent).toBe(false); }); }); -describe('buildOngoingNotificationText', () => { - it('lists every ranked numeric count for happy work', () => { - expect(buildOngoingNotificationText(MIXED, {}, translate)).toBe( - '2 Needs input, 4 Working, 3 Idle' - ); - }); - - it('adds the translated stale warning without losing eligible counts', () => { - expect(buildOngoingNotificationText({ ...MIXED, status: 'stale' }, {}, translate)).toBe( - 'Updates delayed, 2 Needs input, 4 Working, 3 Idle' +describe('notification privacy and activity policy remain independent', () => { + it('lists ranked nonzero counts without Home titles or private identifiers', () => { + expect(buildOngoingNotificationText(MIXED.snapshot, {}, translate)).toBe( + `2 Needs input, 1 Working, 1 Scheduled wakes ${WAKE}, 1 Idle` ); + const notification = buildOngoingNotificationText(MIXED.snapshot, {}, translate); + expect(notification).not.toContain('Permission title'); + expect(notification).not.toContain('Schedule title'); + expect(notification).not.toContain('user-private-id'); }); - it('keeps stale copy when there are no retained counts', () => { - expect(buildOngoingNotificationText(snapshotFor([], 0, 'stale'), {}, translate)).toBe( - 'Updates delayed' + it('keeps the stale warning and retry notice in the notification only', () => { + const stale = { ...MIXED.snapshot, status: 'stale' as const }; + expect(buildOngoingNotificationText(stale, {}, translate, String, 'Could not approve')).toBe( + `Could not approve Updates delayed, 2 Needs input, 1 Working, 1 Scheduled wakes ${WAKE}, 1 Idle` ); }); - it('uses empty copy when there is no eligible work', () => { - expect(buildOngoingNotificationText(snapshotFor([]), {}, translate)).toBe( - 'No work in progress' - ); - }); - - it('prefixes a pending action notice to the counts', () => { - expect(buildOngoingNotificationText(MIXED, {}, translate, String, 'Approval failed')).toBe( - 'Approval failed 2 Needs input, 4 Working, 3 Idle' - ); - }); - - /** Two hours ahead of the suite's clock, so the stub formatter echoes it. */ - const WAKE = new Date(NOW + 7_200_000).toISOString(); - - it('appends the wake time to a scheduled count', () => { - const snapshot = snapshotFor([{ status: 'scheduled', scheduledAt: WAKE }], 0); - expect(buildOngoingNotificationText(snapshot, {}, translate, String, null, formatAgo)).toBe( - `1 Scheduled wakes ${formatAgo(WAKE)}` - ); - }); - - it('leaves the scheduled count bare when the CLI reported no wake', () => { - const snapshot = snapshotFor([{ status: 'scheduled' }], 0); - expect(buildOngoingNotificationText(snapshot, {}, translate, String, null, formatAgo)).toBe( - '1 Scheduled' - ); - }); - - it('keeps the stale warning and the pending notice around the wake', () => { - const snapshot = snapshotFor([{ status: 'scheduled', scheduledAt: WAKE }], 0, 'stale'); - expect( - buildOngoingNotificationText(snapshot, {}, translate, String, 'Approval failed', formatAgo) - ).toBe(`Approval failed Updates delayed, 1 Scheduled wakes ${formatAgo(WAKE)}`); - }); + it.each(['signed_out', 'privacy', 'expired', 'waiting', 'empty'] as const)( + 'does not leak retained Home counts on %s', + status => { + const snapshot = { ...MIXED.snapshot, status }; + expect(buildOngoingNotificationText(snapshot, {}, translate)).toBe( + translate( + { + signed_out: 'glanceable.signedOut', + privacy: 'glanceable.privacy', + expired: 'glanceable.expired', + waiting: 'glanceable.waiting', + empty: 'glanceable.empty', + }[status] + ) + ); + expect(buildCompactNotificationText(snapshot, {})).toBeNull(); + } + ); - it('prefixes the notice to the stale warning and to the locked copy', () => { + it('keeps notification relative wake formatting and literal placeholder insertion', () => { + const data = dataFor([{ status: 'scheduled', scheduledAt: WAKE }]); expect( buildOngoingNotificationText( - { ...MIXED, status: 'stale' }, + data.snapshot, {}, translate, String, - 'Approval failed' + null, + () => '$& in 2 hours' ) - ).toBe('Approval failed Updates delayed, 2 Needs input, 4 Working, 3 Idle'); - expect( - buildOngoingNotificationText(snapshotFor([]), {}, translate, String, 'Approval failed') - ).toBe('Approval failed No work in progress'); - }); -}); - -describe('buildCompactNotificationText', () => { - it.each([ - { needsInput: 2, idle: 3, running: 4, expected: '2' }, - { needsInput: 0, idle: 3, running: 4, expected: '4' }, - { needsInput: 0, idle: 3, running: 0, expected: '3' }, - { needsInput: 0, idle: 0, running: 0, expected: null }, - ])('uses the ranked primary number $expected, not the total or full summary', counts => { - const snapshot = { ...MIXED, ...counts }; - expect(buildCompactNotificationText(snapshot, {})).toBe(counts.expected); - expect(buildCompactNotificationText({ ...snapshot, status: 'stale' }, {})).toBe( - counts.expected - ); - }); -}); - -describe('status precedence and count hiding', () => { - it.each([ - ['waiting', 'Waiting for agents', 'Waiting for agents'], - // The widget and the ongoing notification read the same status line; - // `buildOngoingNotificationText`'s own empty fallback only ever renders - // for eligible work, so it keeps the shared no-work copy. - ['empty', 'No agents waiting', 'No work in progress'], - ['expired', 'Status expired', 'Status expired'], - ['signed_out', 'Sign in to see agents', 'Sign in to see agents'], - ['privacy', 'Open Kilo to see agents', 'Open Kilo to see agents'], - ] as const)('hides counts on every Android surface for %s', (status, expected, notification) => { - const snapshot = { ...MIXED, status }; - const props = buildAndroidWidgetProps(snapshot, {}, translate, String, formatAgo); - expect(props.statusLine).toBe(expected); - expect(props.countLines).toEqual([]); - expect(props.primaryLabel).toBeNull(); - expect(buildOngoingNotificationText(snapshot, {}, translate)).toBe(notification); - expect(buildCompactNotificationText(snapshot, {})).toBeNull(); - }); - - it.each([ - [{ signedOut: true, orgInvalid: true }, 'Sign in to see agents'], - [{ orgInvalid: true }, 'Open Kilo to see agents'], - ] as const)('honors auth overrides before stale counts: %j', (flags, expected) => { - const snapshot = { ...MIXED, status: 'stale' as const }; - const props = buildAndroidWidgetProps(snapshot, flags, translate, String, formatAgo); - expect(props.statusLine).toBe(expected); - expect(props.countLines).toEqual([]); - expect(props.primaryLabel).toBeNull(); - expect(buildOngoingNotificationText(snapshot, flags, translate)).toBe(expected); - expect(buildCompactNotificationText(snapshot, flags)).toBeNull(); + ).toBe('1 Scheduled wakes $& in 2 hours'); }); -}); - -describe('widget actions and the newest line', () => { - it('offers Approve while a permission waits, and New agent only then', () => { - const props = buildAndroidWidgetProps({ ...MIXED, needsApproval: 2 }, {}, translate); - expect(props.actions).toEqual({ - approve: true, - newAgent: false, - approveLabel: 'Approve', - newAgentLabel: 'New agent', - }); - }); - - it.each(['question', 'retry'])( - 'offers no Approve for a %s wait the action cannot answer', - status => { - // `needsInput` folds in questions and retries: a question needs an answer - // and a retry needs the provider back, so neither may draw a button whose - // press only finds nothing to approve and opens the app instead. - const props = buildAndroidWidgetProps(snapshotFor([{ status }]), {}, translate); - expect(props.actions.approve).toBe(false); - expect(props.actions.newAgent).toBe(false); - } - ); - it('offers Approve for a permission wait even beside a question', () => { - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'question' }, { status: 'permission' }]), - {}, - translate + it('formats the promoted notification primary count without titles', () => { + expect(buildCompactNotificationText(MIXED.snapshot, {}, value => `digit:${value}`)).toBe( + 'digit:2' ); - expect(props.actions.approve).toBe(true); - }); - - it('does not offer New agent when a session is scheduled', () => { - const props = buildAndroidWidgetProps(snapshotFor([{ status: 'scheduled' }]), {}, translate); - expect(props.actions.approve).toBe(false); - expect(props.actions.newAgent).toBe(false); - expect(props.primaryLabel).toBe('Scheduled'); - }); - - it('offers New agent when every connected agent is idle and nothing waits', () => { - // The idle-only tray has status happy: it keeps a card alive, and nothing - // waiting means the only action the surface can offer is a new agent. The - // counts stay, so the rows do not reflow as work moves between states. - const props = buildAndroidWidgetProps(snapshotFor([{ status: 'idle' }]), {}, translate); - expect(props.actions).toEqual({ - approve: false, - newAgent: true, - approveLabel: 'Approve', - newAgentLabel: 'New agent', - }); - expect(props.statusLine).toBeNull(); - expect(props.countLines).toHaveLength(4); - }); - - it('offers New agent when nothing is eligible, instead of Approve', () => { - const props = buildAndroidWidgetProps(snapshotFor([], 0, 'empty'), {}, translate); - expect(props.actions).toEqual({ - approve: false, - newAgent: true, - approveLabel: 'Approve', - newAgentLabel: 'New agent', - }); - expect(props.statusLine).toBe('No agents waiting'); - }); - - it('offers neither action while the first fetch is still in flight', () => { - const props = buildAndroidWidgetProps(snapshotFor([], 0, 'waiting'), {}, translate); - expect(props.actions.approve).toBe(false); - expect(props.actions.newAgent).toBe(false); + const scheduled = dataFor([{ status: 'scheduled' }, { status: 'idle' }]); + expect(buildCompactNotificationText(scheduled.snapshot, {})).toBe('1'); }); - it.each(['expired', 'signed_out', 'privacy'] as const)( - 'offers no action for %s, where there is nothing to trust yet', - status => { - const props = buildAndroidWidgetProps({ ...MIXED, status }, {}, translate); - expect(props.actions.approve).toBe(false); - expect(props.actions.newAgent).toBe(false); - } - ); - - it('offers no action to a signed-out widget even with retained counts', () => { - const props = buildAndroidWidgetProps( - { ...MIXED, status: 'stale' }, - { signedOut: true }, - translate + it('does not require Home details to render privacy-minimal activity text', () => { + const noDetails = { ...MIXED, details: EMPTY_HOME_WIDGET_DETAILS }; + expect(buildOngoingNotificationText(noDetails.snapshot, {}, translate)).toBe( + buildOngoingNotificationText(MIXED.snapshot, {}, translate) ); - expect(props.actions.approve).toBe(false); - expect(props.actions.newAgent).toBe(false); - expect(props.statusLine).toBe('Sign in to see agents'); - }); - - it('draws the newest session line from the surface extras', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - expect(buildAndroidWidgetProps(MIXED, {}, translate).newestLine).toBe( - 'Newest: Fix the flaky test' - ); - }); - - it('inserts a title containing a replacement pattern literally', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix $& the build', actionFeedback: null }); - expect(buildAndroidWidgetProps(MIXED, {}, translate).newestLine).toBe( - 'Newest: Fix $& the build' - ); - }); - - it('keeps the reserved line empty when nothing is stored', () => { - expect(buildAndroidWidgetProps(MIXED, {}, translate).newestLine).toBeNull(); - }); - - it('shows the action state ahead of the newest session, and says which it is', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: 'approving' }); - const approving = buildAndroidWidgetProps(MIXED, {}, translate); - expect(approving.newestLine).toBe('Approving…'); - expect(approving.actionFeedback).toBe('approving'); - - setSurfaceExtras({ - newestSessionTitle: 'Fix the flaky test', - actionFeedback: 'couldNotApprove', - }); - const failed = buildAndroidWidgetProps(MIXED, {}, translate); - expect(failed.newestLine).toBe('Could not approve'); - expect(failed.actionFeedback).toBe('couldNotApprove'); - - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - expect(buildAndroidWidgetProps(MIXED, {}, translate).actionFeedback).toBeNull(); - }); - - it('carries no action feedback on a surface without counts', () => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'couldNotApprove' }); - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'empty'), {}, translate).actionFeedback - ).toBeNull(); - }); - - it('never shows the newest-session title on the empty surface', () => { - // Empty offers no in-place action, so its slot stays blank rather than - // carrying a stale title. - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'empty'), {}, translate).newestLine - ).toBeNull(); - }); - - it('keeps the failure line and the offered action on a retryable surface', () => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'couldNotApprove' }); - const props = buildAndroidWidgetProps( - { ...MIXED, needsApproval: 1, status: 'stale' }, - {}, - translate - ); - expect(props.newestLine).toBe('Could not approve'); - expect(props.actions.approve).toBe(true); - expect(props.countLines).toHaveLength(4); - }); - - it('draws the reserved line only on the count surfaces', () => { - setSurfaceExtras({ - newestSessionTitle: 'Fix the flaky test', - actionFeedback: 'couldNotApprove', - }); - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'signed_out'), {}, translate).newestLine - ).toBeNull(); - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'expired'), {}, translate).newestLine - ).toBeNull(); - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'waiting'), {}, translate).newestLine - ).toBeNull(); - // Empty offers New agent, which opens the app instead of answering in - // place, so a leftover Approve failure has no business in its slot. - expect( - buildAndroidWidgetProps(snapshotFor([], 0, 'empty'), {}, translate).newestLine - ).toBeNull(); }); }); diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts index 4151f7ce41..5782f18def 100644 --- a/apps/mobile/src/glanceable-android/widget-props.ts +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -1,321 +1,169 @@ import { GLANCEABLE_STALE_MS, type GlanceableAgentsSnapshot, - isIdleOnlyGlanceableWork, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + buildHomeWidgetPresentation, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetData, + type HomeWidgetPresentation, +} from '@kilocode/app-shared/home-widget'; import { - type GlanceableCountKind, glanceableCountLines, glanceableScheduledAt, glanceableSpokenLabel, - type GlanceableStatus, glanceableStatusCopyKey, type GlanceableSurfaceFlags, primaryGlanceableCount, resolveGlanceableStatus, } from '@/lib/glanceable/presentation'; -import { - getSurfaceExtras, - type GlanceableActionFeedback, - type GlanceableSurfaceExtras, -} from '@/lib/glanceable/surface-extras'; - -/** One translated count line for an Android surface. `kind` picks dot and color. */ -type AndroidWidgetCount = { - label: string; - kind: GlanceableCountKind; - count: string; -}; +import { getSurfaceExtras, type GlanceableActionFeedback } from '@/lib/glanceable/surface-extras'; -/** - * Format a count in the active language's own digits. - * - * The default writes them the way `String` does, which is what the 80 languages - * with Latin default digits need; the app injects an `Intl` formatter so fa, ps, - * ckb, my, ne, bn, and mr read in their own numerals. Injected rather than - * imported so this module stays free of i18n and of React Native. - */ -export type GlanceableCountFormat = (value: number) => string; +import { + type AndroidWidgetCount, + type AndroidWidgetHomeCopy, + buildAndroidHomeCopy, + type GlanceableClockFormat, + type GlanceableCountFormat, +} from './home-copy'; -/** - * Format a timestamp as the active language's relative time. - * - * Injected the same way as `formatCount` and for the same reason: this module - * stays free of i18n and of `Intl`, so its tests need no language bootstrap and - * the app passes `formatGlanceableAgo`. - */ export type GlanceableAgoFormat = (at: string) => string; -/** - * Format a timestamp as the active language's short clock time ("8:00 PM"). - * Injected for the same reason as `formatAgo`; the app passes - * `formatGlanceableClock`. - */ -export type GlanceableClockFormat = (at: string) => string; - -/** - * The two in-place actions a state offers, plus the translated row labels the - * widget host draws. A disabled action's label is still carried so the widget - * never composes copy of its own. - */ -type AndroidWidgetActions = { - /** A session is waiting: the widget can answer its permission in place. */ - approve: boolean; - /** Nothing waiting: the widget can start a new agent in place. */ - newAgent: boolean; - approveLabel: string; - newAgentLabel: string; -}; - -/** - * The props the Android widget renders. The builder below is the only producer, - * so a title, organization name, account id, or raw session id can never reach - * the widget host. The newest-result label is a translated state word, not a - * session title. Android has no elapsed timer, so there is no elapsed anchor. - */ +/** Home-only titles never enter the counts-only ongoing notification snapshot. */ export type AndroidWidgetProps = { - /** - * Translated locked copy, drawn only when no counts are. Stale carries both, - * and the widget then draws the counts; the ongoing notification is the one - * surface that says the counts are delayed. - */ + home?: HomeWidgetPresentation; + homeCopy?: AndroidWidgetHomeCopy; + // Existing count props remain readable for persisted pre-Home presentations. statusLine: string | null; - /** Every count line in rank order (needs-input, running, scheduled, idle), zeros included. */ countLines: AndroidWidgetCount[]; - /** The kinds no agent is in: their rows keep their place but draw muted. */ - zeroKinds: GlanceableCountKind[]; - /** Top-ranked count label; the only row that keeps the foreground color. */ primaryLabel: string | null; - /** Kind of the most recent state change; null when no row carried a timestamp. */ - newestResultKind: GlanceableCountKind | null; - /** - * Caption of the newest-result footer. Null while no counts show: a locked - * frame carries one fact, and the caption is the third fact's. - */ - newestResultTitle: string | null; - /** The kind label read from `countLines`, never a second spelling of the word. */ - newestResultLabel: string | null; - /** Preformatted relative time of that change, from the injected formatter. */ - newestResultAgo: string | null; - /** - * Preformatted clock time of the soonest scheduled wake ("8:00 PM"), from the - * injected clock formatter: a row shows the moment the agent wakes, not a - * countdown. Null when nothing is scheduled or the CLI reported a scheduled - * count with no `scheduledAt`: the scheduled row draws either way, and only - * the time beside it is conditional. - */ scheduledTime: string | null; - /** - * The reserved slot under the counts: the newest session's title, the - * in-flight action's progress or failure, or null. Its height is reserved in - * every size bucket, so a loading→content swap cannot move the count rows. - * The only copy here that carries user content is a session title, which the - * snapshot contract keeps out of the snapshot itself (see surface-extras). - */ - newestLine: string | null; - /** - * Which in-flight action, if any, `newestLine` reports. The layout draws an - * Approve in progress where the chip was, and a failed one where the newest - * result was, so it needs to tell the action's line from a session title. - */ actionFeedback: GlanceableActionFeedback; - actions: AndroidWidgetActions; - /** Spoken label: status words, counts, then Open agents. Never a title or id. */ + actions: { + approve: boolean; + newAgent: boolean; + approveLabel: string; + newAgentLabel: string; + }; accessibilityLabel: string; }; -/** - * Resolve the reserved slot's line. - * - * The slot is visible on the two count statuses, the ones that can offer the - * in-place Approve, because its progress and failure have nowhere else to - * appear. A locked or empty surface never carries a stale title; an approve in - * flight or a failed approve owns the slot ahead of the title, so the widget - * never shows the newest session as if it were the approve's result. - */ -function newestLineFor( - extras: GlanceableSurfaceExtras, - status: GlanceableStatus, - translate: (key: string) => string -): string | null { - if (status !== 'happy' && status !== 'stale') { - return null; - } - if (extras.actionFeedback === 'approving') { - return translate('glanceable.approving'); +/** Signed-out and privacy surfaces override the separately scoped Home data. */ +function homeSnapshotFor( + snapshot: GlanceableAgentsSnapshot, + flags: GlanceableSurfaceFlags, + homeData: HomeWidgetData +): HomeWidgetData['snapshot'] { + if (flags.signedOut) { + return { ...snapshot, status: 'signed_out' }; } - if (extras.actionFeedback === 'couldNotApprove') { - return translate('glanceable.couldNotApprove'); + if (flags.orgInvalid) { + return { ...snapshot, status: 'privacy' }; } - const title = extras.newestSessionTitle; - if (title === null) { - return null; + if (snapshot.status === 'signed_out' || snapshot.status === 'privacy') { + return snapshot; } - // The translator owns the word order around the placeholder. The replacer is - // a function so a title containing `$&` or `$'` is inserted literally - // instead of being read as a replacement pattern. - return translate('glanceable.newestSession').replace('{{title}}', () => title); + return homeData.snapshot; } -/** Build the Android widget props from a snapshot, surface flags, and a translator. */ -// eslint-disable-next-line max-params -- snapshot, flags, the translator, and the three injected formatters +/** Platform localization only; priority, privacy, retention and wake policy are shared. */ +// eslint-disable-next-line max-params -- retain existing injected formatters and accept separately scoped Home-only data export function buildAndroidWidgetProps( snapshot: GlanceableAgentsSnapshot, flags: GlanceableSurfaceFlags, translate: (key: string) => string, formatCount: GlanceableCountFormat = String, - formatAgo: GlanceableAgoFormat = String, - formatClock: GlanceableClockFormat = String + _formatAgo: GlanceableAgoFormat = String, + formatClock: GlanceableClockFormat = String, + homeData?: HomeWidgetData ): AndroidWidgetProps { const status = resolveGlanceableStatus(snapshot, flags); - const statusKey = glanceableStatusCopyKey(snapshot, flags); const showCounts = status === 'happy' || status === 'stale'; const primary = showCounts ? primaryGlanceableCount(snapshot) : null; - const countLines = (showCounts ? glanceableCountLines(snapshot) : []).map(line => ({ - label: translate(line.key), - kind: line.kind, - count: formatCount(line.count), - })); - // A zero row keeps its place in the grid but draws muted. Carried beside the - // lines, which every surface shares, rather than inside them. - const zeroKinds = (showCounts ? glanceableCountLines(snapshot) : []) - .filter(line => line.count === 0) - .map(line => line.kind); - // The three facts locked frames must not carry: with no counts there is no - // newest result either, so a waiting or privacy-blanked widget keeps one fact. - const newestKind = showCounts ? snapshot.newestResultKind : null; - const newestAt = showCounts ? snapshot.newestResultAt : null; - // The shared helper decides the wake, so a scheduled count with no usable - // time reads the same way here as on every other surface: the row draws and - // only the time beside it is conditional. + const statusKey = glanceableStatusCopyKey(snapshot, flags); + const resolvedHomeData = homeData ?? { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }; + const home = buildHomeWidgetPresentation( + { ...resolvedHomeData, snapshot: homeSnapshotFor(snapshot, flags, resolvedHomeData) }, + Date.now() + ); + const feedback = home.status === 'content' ? getSurfaceExtras().actionFeedback : null; const scheduledAt = showCounts ? glanceableScheduledAt(snapshot) : null; - - const extras = getSurfaceExtras(); - // Android's empty surface is the one that offers `New agent`, so its copy - // says what that action is about — nothing waiting — instead of the generic - // no-work copy. Every Android surface resolves its status copy through this - // mapping (body, spoken label, ongoing notification), so they cannot disagree. - const androidCopy = (key: string): string => - key === 'glanceable.empty' ? translate('glanceable.noneWaiting') : translate(key); - + // Both platforms name the empty state by its New agent action. + const copy = (key: string): string => + translate(key === 'glanceable.empty' ? 'glanceable.noneWaiting' : key); return { - statusLine: statusKey === null ? null : androidCopy(statusKey), - countLines, - zeroKinds, + home, + homeCopy: buildAndroidHomeCopy(home, translate, formatCount, formatClock, feedback), + statusLine: statusKey === null ? null : copy(statusKey), + countLines: (showCounts ? glanceableCountLines(snapshot) : []).map(line => ({ + kind: line.kind, + count: formatCount(line.count), + label: translate(line.key), + })), primaryLabel: primary === null ? null : translate(primary.key), - newestResultKind: newestKind, - newestResultTitle: showCounts ? translate('glanceable.newestResult') : null, - newestResultLabel: - newestKind === null - ? null - : (countLines.find(line => line.kind === newestKind)?.label ?? null), - newestResultAgo: newestKind === null || newestAt === null ? null : formatAgo(newestAt), scheduledTime: scheduledAt === null ? null : formatClock(scheduledAt), - newestLine: newestLineFor(extras, status, translate), - actionFeedback: showCounts ? extras.actionFeedback : null, + actionFeedback: feedback, actions: { - // Only a permission wait can be answered from the widget, so the button - // gates on `needsApproval` — the same count as the ongoing notification's - // Approve action. A `question` needs an answer and a `retry` needs the - // provider back: neither is approvable, so neither may offer a button the - // action can only answer by opening the app. - approve: showCounts && (snapshot.needsApproval ?? 0) > 0, - newAgent: status === 'empty' || (showCounts && isIdleOnlyGlanceableWork(snapshot)), + approve: home.canApprove, + newAgent: home.canCreate, approveLabel: translate('common.approve'), newAgentLabel: translate('glanceable.newAgent'), }, - accessibilityLabel: glanceableSpokenLabel(snapshot, flags, androidCopy), + accessibilityLabel: glanceableSpokenLabel(snapshot, flags, copy), }; } -/** Every redraw checks the data deadline, including a task queued by an older alarm. */ -// eslint-disable-next-line max-params -- snapshot, the translator, and the three injected formatters +/** Aging the Home widget never overwrites confirmed counts or checkedAt. */ +// eslint-disable-next-line max-params -- preserve existing injected-format API and separate Home-only data export function buildCurrentWidgetProps( snapshot: GlanceableAgentsSnapshot, translate: (key: string) => string, formatCount: GlanceableCountFormat, formatAgo: GlanceableAgoFormat, - formatClock: GlanceableClockFormat + formatClock: GlanceableClockFormat, + homeData?: HomeWidgetData ): AndroidWidgetProps { const expiresAt = Date.parse(snapshot.expiresAt); - if ( - (snapshot.status === 'happy' || snapshot.status === 'stale') && - (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) - ) { - return buildExpiredWidgetProps(snapshot, translate, formatCount, formatAgo, formatClock); - } - // The Android twin of the iOS stale timeline frame: a redraw past - // `updatedAt + GLANCEABLE_STALE_MS` stops asserting the counts are current. - // The counts stay — they are still the last thing the device knew — and only - // the age goes. The platform's own redraw is what runs this check, so the - // claim stays honest without the app running. The deadline above wins, so a - // lapsed snapshot past `expiresAt` still draws the expired frame. - const staleAt = Date.parse(snapshot.updatedAt) + GLANCEABLE_STALE_MS; - if (snapshot.status === 'happy' && staleAt <= Date.now()) { - return buildAndroidWidgetProps( - { ...snapshot, status: 'stale' }, - {}, - translate, - formatCount, - formatAgo, - formatClock - ); + let activitySnapshot = snapshot; + if (snapshot.status === 'happy' || snapshot.status === 'stale') { + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + activitySnapshot = { + ...snapshot, + status: 'expired', + running: 0, + needsInput: 0, + idle: 0, + scheduled: 0, + needsInputSince: null, + scheduledAt: null, + }; + } else if ( + snapshot.status === 'happy' && + Date.parse(snapshot.updatedAt) + GLANCEABLE_STALE_MS <= Date.now() + ) { + activitySnapshot = { ...snapshot, status: 'stale' }; + } } - return buildAndroidWidgetProps(snapshot, {}, translate, formatCount, formatAgo, formatClock); -} - -/** Zero-count expired props: the single future redraw hides counts at expiresAt. */ -// eslint-disable-next-line max-params -- snapshot, the translator, and the three injected formatters -function buildExpiredWidgetProps( - snapshot: GlanceableAgentsSnapshot, - translate: (key: string) => string, - formatCount: GlanceableCountFormat, - formatAgo: GlanceableAgoFormat, - formatClock: GlanceableClockFormat -): AndroidWidgetProps { return buildAndroidWidgetProps( - { - ...snapshot, - status: 'expired', - running: 0, - needsInput: 0, - idle: 0, - scheduled: 0, - needsInputSince: null, - scheduledAt: null, - }, + activitySnapshot, {}, translate, formatCount, formatAgo, - formatClock + formatClock, + homeData ?? { snapshot, details: EMPTY_HOME_WIDGET_DETAILS } ); } -/** - * Gallery placeholder and no-snapshot fallback: the sign-in copy and no counts. - * - * A placed widget with no snapshot has no account behind it — a fresh install or - * a signed-out one — so the honest claim is "Sign in to see agents", not the - * generic "No work in progress", which asserts the user is signed in with an - * empty tray. Drawing the sign-in copy here also keeps the terminal signed-out - * frame (which writes the same key) and this fallback from ever disagreeing. No - * snapshot means no state to act on, so the placeholder offers nothing. - */ +/** No account behind a gallery/new-install widget: never invent an empty authenticated tray. */ export function buildGenericWidgetProps(translate: (key: string) => string): AndroidWidgetProps { const signedOut = translate('glanceable.signedOut'); return { statusLine: signedOut, countLines: [], - zeroKinds: [], primaryLabel: null, - newestResultKind: null, - newestResultTitle: null, - newestResultLabel: null, - newestResultAgo: null, scheduledTime: null, - newestLine: null, actionFeedback: null, actions: { approve: false, @@ -327,15 +175,8 @@ export function buildGenericWidgetProps(translate: (key: string) => string): And }; } -/** - * Ongoing notification: every ranked count, with a warning when stale, otherwise - * the locked status copy. The scheduled count carries the wake time beside it, - * so the card says when the next agent wakes. A pending action notice (an - * approve attempt that has to be retried) prefixes the line, separated by a - * space because the notice is a full sentence; the compact and promoted - * surfaces never carry it. Never a title, organization name, or id. - */ -// eslint-disable-next-line max-params -- snapshot, flags, the translator, the three injected formatters, and the notice +/** Notification content stays privacy-minimal and follows activity lifetime, not Home retention. */ +// eslint-disable-next-line max-params -- existing notification API takes snapshot, flags, copy, formatter and notice export function buildOngoingNotificationText( snapshot: GlanceableAgentsSnapshot, flags: GlanceableSurfaceFlags, @@ -346,12 +187,8 @@ export function buildOngoingNotificationText( ): string { const status = resolveGlanceableStatus(snapshot, flags); if (status === 'happy' || status === 'stale') { - // A sentence, not a layout: a zero row holds a widget's rows still, but - // "0 Working" in a notification line is only noise. const lines = glanceableCountLines(snapshot).filter(line => line.count > 0); if (lines.length > 0) { - // The wake rides only the scheduled count, and only when the CLI reported - // one: a scheduled count with no usable time reads as the bare count. const scheduledAt = glanceableScheduledAt(snapshot); const counts = lines .map(line => { @@ -360,21 +197,18 @@ export function buildOngoingNotificationText( return label; } const wake = formatAgo(scheduledAt); - // The translator owns the word order around the placeholder; the - // replacer is a function so a formatted time containing `$&` is - // inserted literally instead of being read as a replacement pattern. return `${label} ${translate('glanceable.scheduledWakes').replace('{{time}}', () => wake)}`; }) .join(', '); - const text = status === 'stale' ? `${translate('glanceable.stale')}, ${counts}` : counts; - return notice === null ? text : `${notice} ${text}`; + const value = status === 'stale' ? `${translate('glanceable.stale')}, ${counts}` : counts; + return notice === null ? value : `${notice} ${value}`; } } - const text = translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty'); - return notice === null ? text : `${notice} ${text}`; + const value = translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty'); + return notice === null ? value : `${notice} ${value}`; } -/** The promoted chip shows only the primary number; the full text keeps all labels. */ +/** Promoted notifications only show the primary number, never a Home-only title. */ export function buildCompactNotificationText( snapshot: GlanceableAgentsSnapshot, flags: GlanceableSurfaceFlags, diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index b93d4a36da..92870b0855 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- every family, the in-place action buttons, and the newest-result footer compose inside one stringified 'widget' layout, which cannot be split across modules */ +/* eslint-disable max-lines -- the 'widget' layout function is stringified whole, so its helpers cannot be extracted to module scope; every family stays in one function */ import { Button, type ButtonProps, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { accessibilityElement, @@ -7,7 +7,6 @@ import { containerBackground, controlSize, cornerRadius, - type dynamicTypeSize, environment, font, foregroundStyle, @@ -15,7 +14,6 @@ import { layoutPriority, lineLimit, monospacedDigit, - multilineTextAlignment, resizable, widgetURL, } from '@expo/ui/swift-ui/modifiers'; @@ -26,660 +24,580 @@ import { withGlanceableCopy } from './layout-copy'; import { type GlanceableWidgetAction, type GlanceableWidgetProps } from './view-props'; import { withWidgetLogo } from './widget-logo'; -/* eslint-disable new-cap -- PlatformColor is a React Native factory function, not a constructor */ +/* eslint-disable new-cap -- PlatformColor is a React Native factory */ -// The layout function below is marked with the `'widget'` directive, so Babel -// stringifies it and the widget extension re-evaluates the source. Everything -// it references must be a widget global (`Text`, `VStack`, the modifiers, -// `PlatformColor`) or a built-in. Do not call `@/` helpers or i18n from here — -// translated copy arrives through `props`, and the gallery placeholder (which -// has no props) falls back to the baked copy below. -// -// Two values are resolved after stringification, both from literals below: -// `withWidgetLogo` swaps `__KILO_WIDGET_LOGO_URI__` for the app-group path of -// the mark, and `withGlanceableCopy` swaps `__KILO_GLANCEABLE_COPY__` for the -// translated copy. - -// The timeline props: the builder's props plus the press marker a widget -// button's App Intent patches into the pressed entry (see GlanceableWidgetProps -// in view-props). -type WidgetProps = GlanceableWidgetProps; - -/** - * The press patch. `@expo/ui` types `onPress` as `() => void`, but in the - * widget process the bundle calls the handler and merges the returned patch - * into the pressed entry's props (its `findAndCallOnPress`), so the return - * value is load-bearing. The app maps the marker back to the action (see - * `pendingActionOf` in widget-actions). - */ -type WidgetPressPatch = { - pendingAction: GlanceableWidgetAction; -}; - -/** - * The shared Button with the widget press's real `onPress` contract: the - * returned patch is load-bearing there. A local narrow cast, not a widening of - * the shared UI types. - */ +export type WidgetProps = GlanceableWidgetProps; +type WidgetPressPatch = { pendingAction: GlanceableWidgetAction; pendingApprovalKey?: string }; type WidgetButtonProps = Omit & { onPress?: () => WidgetPressPatch; }; -export type { WidgetProps }; - -// Babel replaces the annotated arrow with its source string, so `layout` is a -// string at runtime while TypeScript still checks it as a component. -const layout: (props: WidgetProps, widgetEnvironment: WidgetEnvironment) => React.JSX.Element = ( - props, - widgetEnvironment -) => { +// Everything this stringified function uses must be a widget global or a builtin. +const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React.JSX.Element => { 'widget'; - // The literal, not the imported constant: the widget transform stringifies - // this function's source, so an imported binding would be an undefined - // global in the widget process. `withGlanceableCopy` replaces the token, - // quotes included, with the translated copy as a JSON source literal. Until - // then the value is the bare token, which is not JSON: falling back to an - // empty copy renders the fallback literals below instead of throwing a - // blank surface. - // eslint-disable-next-line typescript-eslint/no-inferrable-types -- see above + // Both literals are replaced after Babel stringifies this function. + // eslint-disable-next-line typescript-eslint/no-inferrable-types -- replaced source literal const copySource: string = '__KILO_GLANCEABLE_COPY__'; const COPY = JSON.parse(copySource.startsWith('{') ? copySource : '{}') as Record; - // The tag SwiftUI formats the relative wait with; English when the bake is - // somehow missing it, which is what the widget process would have used anyway. - const locale = COPY.locale ?? 'en'; - - // The counts are stringified here, not formatted: a pushed content state - // carries raw numbers and this process has no formatter. `COPY.digits` is the - // language's own ten, empty when it writes them the way `String` already - // does, so an Arabic count reads "١" beside the "٢٦ د" SwiftUI formats. + // eslint-disable-next-line typescript-eslint/no-inferrable-types -- replaced source literal + const logoUri: string = '__KILO_WIDGET_LOGO_URI__'; const digits = COPY.digits ?? ''; - const count = (value: number) => - digits.length === 10 + const count = (value: number) => { + const text = String(value); + return digits.length === 10 ? // eslint-disable-next-line unicorn/prefer-spread -- `replaceAll` and a spread both failed in the widget process; this form is the one verified on device - String(value) + text .split('') - .map(character => digits[Number(character)] ?? character) + .map(character => (/[0-9]/.test(character) ? digits[Number(character)] : character)) .join('') - : String(value); - - const family = widgetEnvironment.widgetFamily; - // Guarded, not a bare `?? []`: a widget extension can evaluate this layout - // against props a *different* app version wrote to the app group (a placed - // widget keeps its stored timeline across an app update), and a non-array or - // a null row would throw out of `counts.map` — which expo-widgets renders as - // a red error box in every family, the gallery placeholder included. - const counts = (Array.isArray(props.countLines) ? props.countLines : []).filter( - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - line => line !== null && typeof line === 'object' - ); - const primaryLabel = props.primaryLabel ?? null; - const primaryKind = props.primaryKind ?? null; - // Only the medium row and the tall large card are wide enough for a time - // beside the label; in the small square the pair wraps and truncates both - // halves, so it draws neither (the time is the first thing a narrow cell - // drops). - const large = family === 'systemLarge'; - const square = family === 'systemSmall'; - const wide = family === 'systemMedium'; - const timedRows = wide || large; - const needsInputSince = props.needsInputSince ?? null; - const scheduledAt = props.scheduledAt ?? null; - // Labels draw at their full text size and never scale: in the widget - // renderer a label with `minimumScaleFactor` always resolved at its minimum - // scale, room or not, which drew the labels far smaller than their counts. - // The square and the Lock Screen rectangle are the cells a wide row can - // overflow, so there the mark gives its width up before a label truncates - // (the count never does). The width measure available in this process is - // the row's character count, count digits included: 12 keeps "2 Needs - // input" beside the mark, and "128 Needs input" or German's "Eingabe - // erforderlich" take its place. - const widestRow = Math.max( - 0, - ...counts.map( - line => - // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders - (typeof line.label === 'string' ? line.label.length : 0) + String(line.count).length - ) - ); - const crowdedRows = widestRow > 12; - // The rows carry zeros too, so their number never says whether work exists — - // the ranked primary does, because it is null only when every count is zero. - const hasCounts = primaryKind !== null; - const primaryCount = props.primaryCount ?? 0; - // A real frame always carries its status line. An entry with no status line - // and no counts is the native fallback: no app has written a timeline yet - // (never signed in) or the gallery placeholder. The Android widget shows - // the same sign-in copy for a widget with no snapshot. - const statusLine = props.statusLine ?? (hasCounts ? null : COPY.signed_out); - - // Circle-based glyphs whose shapes differ as well as their colors, because - // the Lock Screen families render in an accented mode that flattens tint. - const GLYPH = { + : text; + }; + const labels = { + needsInput: COPY.needsInput ?? 'Needs input', + running: COPY.running ?? 'Working', + scheduled: COPY.scheduled ?? 'Scheduled', + idle: COPY.idle ?? 'Idle', + }; + const glyphs = { needsInput: { icon: 'exclamationmark.circle.fill', color: PlatformColor('systemOrange') }, running: { icon: 'circle.fill', color: PlatformColor('systemGreen') }, scheduled: { icon: 'clock', color: PlatformColor('label') }, idle: { icon: 'circle', color: PlatformColor('label') }, } as const; - - // Total, never a bare index: a stale timeline entry or an older app version - // can name a kind this build does not draw, and `GLYPH[unknown].icon` would - // throw during stringified evaluation — the red error box the widget shows in - // every family when the layout raises. An unknown kind falls back to the - // neutral idle mark instead of blanking the surface. - const glyphFor = (kind: string | null | undefined) => { - if (kind !== null && kind !== undefined && Object.hasOwn(GLYPH, kind)) { - return GLYPH[kind as keyof typeof GLYPH]; + type Kind = keyof typeof labels; + // The widget process reads raw app-group JSON, so these parse each field at that boundary. + const kindOf = (value: unknown): Kind | null => + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + typeof value === 'string' && Object.hasOwn(labels, value) ? (value as Kind) : null; + // eslint-disable-next-line anti-slop/no-runtime-typeof, unicorn/consistent-function-scoping -- the widget process reads raw app-group JSON, and the stringified 'widget' layout cannot hoist helpers to module scope + const safeText = (value: unknown): string | null => (typeof value === 'string' ? value : null); + const filledText = (value: unknown): string | null => { + const text = safeText(value); + return text === null || text.length === 0 ? null : text; + }; + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const safeCount = (value: unknown): number => + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const validDate = (value: unknown): Date | null => { + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + if (typeof value !== 'string') { + return null; } - return GLYPH.idle; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; }; - const primaryForeground = foregroundStyle(PlatformColor('label')); - // `secondaryLabel` in both appearances: `tertiaryLabel` on the light widget - // background left the ranked-down rows too faint to read. const mutedForeground = foregroundStyle(PlatformColor('secondaryLabel')); - const a11y = [ - // The widget process takes its locale from the device language, so without - // this the relative wait would be formatted in a different language than - // the labels the app translated into the props. - environment({ key: 'locale', value: locale }), + const localeModifier = environment({ key: 'locale', value: COPY.locale ?? 'en' }); + const bodyURL = widgetURL('kiloapp:///cloud/sessions'); + const genericA11y = [ + localeModifier, accessibilityElement('combine'), - accessibilityLabel(props.accessibilityLabel ?? ''), + accessibilityLabel(safeText(props.accessibilityLabel) ?? ''), ]; + const family = widgetEnvironment.widgetFamily; - // The literal, not the imported constant: the widget transform stringifies - // this function's source, so an imported binding would be an undefined global - // in the widget process. It must stay equal to `WIDGET_LOGO_PLACEHOLDER`, which - // `withWidgetLogo` replaces with the app-group path. - // The annotation widens the literal: the token is replaced after this file is - // stringified, so the empty-path branch below is reachable at runtime. - // eslint-disable-next-line typescript-eslint/no-inferrable-types -- see above - const logoUri: string = '__KILO_WIDGET_LOGO_URI__'; - const logo = (size: number) => - logoUri.length === 0 ? null : ( - - ); - - // The row text size per surface. Every row shares one type size and one - // glyph size so the counts and the labels line up on a grid; only the label - // colour ranks them. The tall large card scales the rows up with its height - // so it does not read empty, the medium row takes `footnote`, and the square - // steps down to `caption2` so "Needs input" fits beside the mark. - type RowStyle = 'caption' | 'caption2' | 'footnote' | 'title3'; - let rowStyle: RowStyle = 'footnote'; - let glyphSize = 13; - let rowSpacing = 7; - if (large) { - rowStyle = 'title3'; - glyphSize = 17; - rowSpacing = 9; - } else if (square) { - rowStyle = 'caption2'; - } - // A time that cannot fit beside its label is dropped whole, never - // truncated. The medium row holds every shipped label plus its time at - // `footnote`; the large card's `title3` holds a time beside labels up to 15 - // characters (Spanish "En espera de respuesta" drops it). - const timeLabelBudget = large ? 15 : 24; - // `elapsed` is the repo-local @expo/ui style (patches/@expo+ui): the wait in - // its one largest unit, seconds only under a minute ("31 minutes", "20 - // seconds"); before iOS 18 it is the relative style. - const waitStyle = 'elapsed'; - // The rows a state actually has lead: a zero row is kept (the grid never - // reflows as work appears) but sinks below every non-zero one, and draws - // fully muted — glyph, number and label in the secondary colour, the number - // without its emphasis. A stable sort keeps the kind order inside each group. - const ledgerRows = [ - ...counts.filter(line => line.count > 0), - ...counts.filter(line => line.count === 0), - ]; - const mutedColor = PlatformColor('secondaryLabel'); - // `compact` is the Lock Screen rectangle: four `caption` rows beside the mark. - const countRow = ( - line: { label: string; kind: string; count: number }, - isPrimary: boolean, - compact: boolean - ) => { - const glyph = glyphFor(line.kind); - const textStyle: RowStyle = compact ? 'caption' : rowStyle; - const zero = line.count === 0; - // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders - const labelLength = typeof line.label === 'string' ? line.label.length : 0; - const timed = !compact && timedRows && labelLength <= timeLabelBudget; - // One time at most per row: a needs-input wait or a scheduled wake, never - // both. The wait is a duration ("31 minutes") and the wake a clock time - // ("9:00 AM"): a wait is an interval the user is enduring, a wake is the - // moment the user asked for. The medium and large cards draw both in the - // row's trailing column, so the two times line up. - let timeAt: string | null = null; - let timeStyle: 'elapsed' | 'time' = waitStyle; - if (timed && line.kind === 'needsInput') { - timeAt = needsInputSince; - } else if (timed && line.kind === 'scheduled') { - timeAt = scheduledAt; - timeStyle = 'time'; - } - return ( - - - - {count(line.count)} - - - {line.label} - - {timedRows && !compact ? : null} - {timeAt === null ? null : ( - - )} - - ); - }; - - // accessoryCircular has room for one number, and accessoryInline for one - // glyph plus one line of text, so neither carries the mark. + // Accessories deliberately ignore Home titles, feedback and retained work. + const genericKind = kindOf(props.primaryKind); + const genericCount = safeCount(props.primaryCount); + const genericStatus = safeText(props.statusLine) ?? COPY.signed_out ?? 'Sign in to see agents'; + const genericLabel = + safeText(props.primaryLabel) ?? (genericKind === null ? '' : labels[genericKind]); if (family === 'accessoryCircular') { return ( - - {primaryKind === null ? null : ( - - )} + + {genericKind === null ? null : } - {hasCounts ? count(primaryCount) : '—'} + {genericKind === null ? '—' : count(genericCount)} ); } - if (family === 'accessoryInline') { - const label = hasCounts - ? `${count(primaryCount)}${primaryLabel !== null ? ` ${primaryLabel}` : ''}` - : (statusLine ?? ''); return ( - - {primaryKind === null ? null : } - {label} + + {genericKind === null ? null : } + + {genericKind === null ? genericStatus : `${count(genericCount)} ${genericLabel}`} + ); } - - // accessoryRectangular is the Lock Screen row: the mark plus the two - // top-ranked lines is all that fits. if (family === 'accessoryRectangular') { + const rows = (Array.isArray(props.countLines) ? props.countLines : []) + .filter( + row => + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + row !== null && + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + typeof row === 'object' && + kindOf(row.kind) !== null && + safeCount(row.count) > 0 + ) + .slice(0, 2); return ( - - {crowdedRows ? null : logo(18)} - {hasCounts ? ( - - {ledgerRows.map(line => countRow(line, line.kind === primaryKind, true))} - + + Kilo + {rows.length === 0 ? ( + {genericStatus} ) : ( - {statusLine} + rows.map(row => ( + + + + {count(safeCount(row.count))} + + + {safeText(row.label) ?? labels[kindOf(row.kind) ?? 'idle']} + + + )) )} - - + ); } - const systemRows = hasCounts ? ( - // The Home Screen families keep the four rows inside one fixed card height, - // so the medium row and the small square run them tighter than the tall - // large card, whose extra height affords the wider gap. - - {ledgerRows.map(line => countRow(line, line.kind === primaryKind, false))} - - ) : ( - // Only the medium row draws a status this way; the square and the large - // card centre theirs. `subheadline` so the line is not lost in the card. - {statusLine} - ); - - // The newest-session slot and the action row are Home Screen families only: - // the Lock Screen families keep their generic layout, which is what the - // snapshot privacy contract protects. - // - // The height is declared inside this function, not at module scope: the - // widget transform stringifies this function's source alone and the widget - // process evaluates it against widget globals, so a module-scope binding - // would be a `ReferenceError` at render. - const NEWEST_SLOT_HEIGHT = 16; - const newestLine = props.newestTitle ?? null; - const actions = props.actions ?? { approve: false, newAgent: false }; - // The slot is laid out whether or not it carries a line, so a title arriving - // after a process restart, or an approve's progress line replacing it, moves - // neither the count rows above nor the actions below. - const newestSlot = ( - - {newestLine === null ? null : ( - - {newestLine} - - )} - - ); - - // The patch a press returns marks the action in the pressed entry's props; - // the app sweeps it up and runs it. `onPress` is the shared prop that - // @expo/ui's widget Button turns into the native `onButtonPress` event the - // widget bundle dispatches — the body keeps its own `widgetURL` deep link for - // taps beside the buttons. `WidgetButton` is the narrow local view of the - // shared Button whose `onPress` carries the patch the bundle reads; the - // shared `onPress: () => void` type would forbid the value-returning handler - // the widget press depends on. - // - // Approve answers in place, so its intent stays in the background. New agent - // needs the composer: `openAppWhenRun` selects the foregrounding intent in - // the patched expo-widgets button view, so the tap brings Kilo up and the - // app's press listener opens the new-session screen. Without it the intent - // only marked the timeline, and nothing happened until the next launch. It - // is a prop of that view, not of `@expo/ui`'s `Button`, so it travels as the - // plain extra prop the widget process serialises with the rest. + // A placed widget can carry props from an older app. This compatibility path + // only displays its existing values; freshness policy belongs to the shared builder. + const rawHome = props.home; + const home = + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + rawHome !== null && typeof rawHome === 'object' ? rawHome : undefined; + const primaryKind = home === undefined ? genericKind : kindOf(home.primaryKind); + const primaryCount = home === undefined ? genericCount : safeCount(home.primaryCount); + const status = home?.status ?? (genericKind === null ? 'signed_out' : 'content'); + const content = status === 'content' && primaryKind !== null; + const stale = home?.stale === true; + const primaryLabel = primaryKind === null ? '' : labels[primaryKind]; + let statusLabel = COPY.signed_out ?? genericStatus; + if (status === 'empty') { + statusLabel = COPY.homeEmpty ?? COPY.noneWaiting ?? 'No agents waiting'; + } else if (status === 'waiting') { + statusLabel = COPY.waiting ?? 'Checking agents'; + } else if (status === 'privacy' || status === 'unavailable') { + statusLabel = COPY.privacy ?? 'Open Kilo to see agents'; + } + const scheduledAt = validDate(home?.scheduledAt ?? props.scheduledAt); + const checkedAt = validDate(home?.checkedAt); + const awaitingUpdate = home?.awaitingUpdate === true; + const actionLine = content ? safeText(props.actionLine) : null; + const title = filledText(home === undefined ? props.newestTitle : home.primaryTitle); + let secondarySource: readonly { kind: string; count: number }[] = []; + if (Array.isArray(home?.secondaryCounts)) { + secondarySource = home.secondaryCounts; + } else if (home === undefined && Array.isArray(props.countLines)) { + secondarySource = props.countLines; + } + const secondaryRows = secondarySource + .filter( + row => + content && + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + row !== null && + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + typeof row === 'object' && + kindOf(row.kind) !== null && + safeCount(row.count) > 0 && + row.kind !== primaryKind + ) + .slice(0, 3); + const canCreate = + home === undefined + ? props.actions?.newAgent === true + : // eslint-disable-next-line typescript-eslint/no-unnecessary-boolean-literal-compare -- the widget process reads raw app-group JSON, not the typed props vitest renders + (status === 'content' || status === 'empty') && home.canCreate === true; + const approvalKey = home === undefined ? null : safeText(home.approvalKey); + const canApprove = content && home?.canApprove === true && approvalKey !== null; + const large = family === 'systemLarge'; + const wide = family === 'systemMedium'; + const square = !large && !wide; const WidgetButton = Button as (props: WidgetButtonProps) => React.JSX.Element; - const openButtonProps = { openAppWhenRun: true }; - // `regular` is the tall card's centred empty composition, whose mark and - // status line scale up with the card; every count-bearing family takes the - // small control. - const actionButtons = (size: 'small' | 'regular') => ( - - {actions.approve ? ( - ({ pendingAction: 'approve' })} - /> - ) : null} - {actions.newAgent ? ( - ({ pendingAction: 'new-agent' })} - /> - ) : null} - - ); - - // Home Screen text cannot grow without bound: the four count rows, the - // reserved slot and the action row share one fixed card height, so the small - // square and the medium row cap growth at the default body size and the tall - // large card caps at an accessibility size. The factory is read off the - // widget global, not the imported binding: the stringified layout is - // evaluated in the widget process against widget globals, and the vitest - // swift-ui mock does not list `dynamicTypeSize`, so the import is only a type - // and a missing factory (an older `@expo/ui`, or the test harness) simply - // leaves the text uncapped. - const typeCeiling = family === 'systemLarge' ? 'accessibility2' : 'large'; - const widgetGlobals = globalThis as typeof globalThis & { - dynamicTypeSize?: typeof dynamicTypeSize; - }; - const typeCap = widgetGlobals.dynamicTypeSize; - const typeModifiers = typeCap === undefined ? [] : [typeCap({ max: typeCeiling })]; - const systemModifiers = [ - widgetURL('kiloapp:///cloud/sessions'), + const foregroundIntent = { openAppWhenRun: true }; + const modifiers = [ + bodyURL, containerBackground(PlatformColor('systemBackground'), 'widget'), - ...typeModifiers, - ...a11y, + localeModifier, ]; - - // The large card's lower third. A stale frame keeps the counts but drops the - // claim that they are current, so the footer prefers the delayed copy where - // the relative time would otherwise assert a freshness the snapshot no - // longer has. - const newestResultKind = props.newestResultKind ?? null; - const newestResultLabel = props.newestResultLabel ?? null; - const newestResultAt = props.newestResultAt ?? null; - const newestResultBody = () => { - if (statusLine !== null) { + const today = widgetEnvironment.date instanceof Date ? widgetEnvironment.date : new Date(); + const wakeTime = (date: Date, stacked = false) => { + const day = + date.toDateString() === today.toDateString() ? null : ( + + ); + const time = ( + + ); + return stacked ? ( + + {day} + {time} + + ) : ( + + {day} + {time} + + ); + }; + const supportLine = () => { + if (actionLine !== null) { return ( - {statusLine} + + {actionLine} + ); } - if (newestResultKind === null || newestResultLabel === null || newestResultAt === null) { - return null; + if (content && primaryKind === 'scheduled') { + if (awaitingUpdate || scheduledAt === null) { + return ( + + {COPY.awaitingUpdate ?? 'Awaiting update'} + + ); + } + return ( + + {large ? ( + + {COPY.nextRun ?? 'Next run'} + + ) : null} + {wakeTime(scheduledAt, !large)} + + ); } - return ( - - - - {newestResultLabel} + if (content && large && primaryKind === 'needsInput') { + return ( + + {COPY.waitingForYou ?? 'Waiting for you'} - + ); + } + if (title !== null && content) { + return ( + + {title} + + ); + } + if (secondaryRows[0] !== undefined && square) { + return ( - + modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]} + >{`${count(safeCount(secondaryRows[0].count))} ${labels[kindOf(secondaryRows[0].kind) ?? 'idle']}`} + ); + } + let fallbackLine = COPY.openAgents ?? 'Open agents'; + if (!content && status === 'empty') { + fallbackLine = COPY.newAgent ?? 'New agent'; + } else if (!content && status === 'waiting') { + fallbackLine = COPY.awaitingUpdate ?? 'Awaiting update'; + } + return ( + + {fallbackLine} + ); }; - - // The locked frames draw their status line through `systemRows` instead, so - // the footer is absent entirely there rather than repeating that copy. - const footerBody = newestResultBody(); - const newestResultFooter = - !hasCounts || footerBody === null ? null : ( - - - {COPY.newestResult} - - {footerBody} - - ); - - const hasAction = actions.approve || actions.newAgent; - - // A state with no counts (empty, signed out, waiting, locked, expired) in - // the square and the tall card: one centred composition — the mark, the - // status line, and New agent when offered — instead of one line stuck in a - // corner of a blank card. The tall card scales the mark, the status line and - // the control up with its height, or the composition reads as a stamp in a - // big card. - if (!hasCounts && (large || square)) { - return ( - - - {logo(large ? 56 : 28)} + const supportSlot = ( + + {supportLine()} + + ); + const heroDigits = String(primaryCount).length; + let heroSize = 40; + if (heroDigits > 9) { + heroSize = 16; + } else if (heroDigits > 6) { + heroSize = 22; + } else if (heroDigits > 4) { + heroSize = 26; + } else if (large) { + heroSize = 48; + } else if (square) { + heroSize = 26; + } + let heroMinHeight = 61; + if (large) { + heroMinHeight = 80; + } else if (square) { + heroMinHeight = 44; + } + let heroValue = '—'; + if (content) { + heroValue = count(primaryCount); + } else if (status === 'empty') { + heroValue = '0'; + } + const hero = ( + + + {heroValue} + + + {content ? primaryLabel : statusLabel} + + + ); + const secondary = ( + + {secondaryRows.map(row => ( + + + + {count(safeCount(row.count))} + + + {labels[kindOf(row.kind) ?? 'idle']} + + + ))} + + ); + const waitingAgents = (Array.isArray(home?.waitingAgents) ? home.waitingAgents : []) + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + .filter(row => row !== null && typeof row === 'object') + .slice(0, 3); + const scheduledAgents = (Array.isArray(home?.scheduledAgents) ? home.scheduledAgents : []) + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + .filter(row => row !== null && typeof row === 'object') + .slice(0, 3); + const detailSource = waitingAgents.length > 0 ? waitingAgents : scheduledAgents; + const detailRows = content ? detailSource : []; + return ( + + + {logoUri.length === 0 ? null : ( + + )} - {statusLine} + Kilo - {hasAction ? actionButtons(large ? 'regular' : 'small') : null} - - ); - } - - // StandBy draws this family on a charging phone in landscape. A header row - // holds the mark and the action, the count rows sit in the middle at a size - // that fills the card, and the newest result owns the bottom. An Approve in - // flight or failed says so under its button: the large card has no reserved - // slot for it. The spacers hold the rows in place as the footer appears and - // disappears, so a state change cannot shift the counts. - const actionLine = props.actionLine ?? null; - if (large) { - return ( - - - {logo(28)} - - {hasAction ? actionButtons('small') : null} + + {canApprove ? ( + ({ pendingAction: 'approve', pendingApprovalKey: approvalKey })} + > + + + ) : null} - {actionLine === null ? null : ( - - - - {actionLine} + + {canCreate ? ( + ({ pendingAction: 'new-agent' })} + > + + + ) : null} + + + {wide || large ? ( + + {hero} + + {secondary} + {wide && primaryKind === 'scheduled' && title !== null ? ( + + {title} + + ) : null} + {wide ? supportSlot : null} + + + ) : ( + + {hero} + {supportSlot} + + )} + {large ? supportSlot : null} + {large ? ( + + {detailRows.map((row, index) => { + const date = 'scheduledAt' in row ? validDate(row.scheduledAt) : null; + let detailLine: React.JSX.Element | null = null; + if ('scheduledAt' in row) { + detailLine = + date === null || date.getTime() <= today.getTime() ? ( + + {COPY.awaitingUpdate ?? 'Awaiting update'} + + ) : ( + wakeTime(date) + ); + } else if ('kind' in row) { + let waitLine = COPY.waitingToRetry ?? 'Waiting to retry'; + if (row.kind === 'permission') { + waitLine = COPY.permissionRequired ?? 'Permission required'; + } else if (row.kind === 'question') { + waitLine = COPY.answerNeeded ?? 'Answer needed'; + } + detailLine = ( + + {waitLine} + + ); + } + return ( + + + + + {filledText(row.title) ?? filledText(COPY.agent) ?? 'Agent'} + + + {detailLine} + + ); + })} + + ) : null} + + + {checkedAt === null ? null : ( + + + {stale ? (COPY.lastKnown ?? 'Last known') : ''} - + + + {COPY.checked ?? 'Checked'} + + + + )} - - {systemRows} - - {newestResultFooter} - - ); - } - - // The medium family is wide, not tall: the mark sits beside the rows and the - // whole block centres, the same composition as the Live Activity banner. A - // vertical layout there left the right half of the card empty. The body - // column's spacing is tightened so the four rows, the reserved slot and the - // action row stay inside the card's ~126 pt content box. - if (wide) { - return ( - - {logo(28)} - - {systemRows} - {hasCounts ? newestSlot : null} - {hasAction ? actionButtons('small') : null} - - - ); - } - - // The small square is the tightest Home Screen family: four count rows, the - // reserved slot and the action row fill its ~126 pt content box, so the mark - // sits beside the body rather than above it. A label too long for the square - // takes the mark's width instead. - return ( - - {crowdedRows ? null : logo(18)} - - {systemRows} - {newestSlot} - {hasAction ? actionButtons('small') : null} - + ); }; export const WIDGET_NAME = 'ActiveAgentsWidget'; - -/** - * The unpatched layout function, exported for unit tests: under vitest no - * widget transform runs, so this still holds the real function, and the - * unpatched copy token parses to an empty copy (see the COPY fallback above). - * Registration wraps it with `withGlanceableCopy(withWidgetLogo(...))`. - */ export const activeAgentsWidgetLayout = layout; - const registerLayout = () => createWidget(WIDGET_NAME, withGlanceableCopy(withWidgetLogo(layout))); - export const ActiveAgentsWidget = registerLayout(); -/** - * Re-bake the stored layout in the active language. Only the gallery - * placeholder reads this copy — a placed widget gets translated copy through - * its timeline props — but the placeholder is the first thing the user sees in - * the widget picker, so it must not stay English after a language change. - */ export function refreshActiveAgentsWidgetCopy(): void { registerLayout(); } diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index 394ca6ed00..fccffb6e22 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -7,6 +7,11 @@ import { type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { type GlanceableLiveActivityContentState } from '@kilocode/notifications'; +import { EMPTY_HOME_WIDGET_DETAILS } from '@kilocode/app-shared/home-widget'; +import { + _resetHomeWidgetDataForTests, + _setHomeWidgetStoreForTests, +} from '@/lib/glanceable/home-widget-data'; import { _resetLiveActivitySwitchForTests, @@ -238,6 +243,8 @@ beforeEach(() => { setNotificationPermissionGrantedValue(true); _resetIosSinkForTests(); _resetGlanceablePersistForTests(); + _resetHomeWidgetDataForTests(); + _setHomeWidgetStoreForTests(secureStoreMock); _resetWaitingAskForTests(); _resetGlanceablePersistForTests(); secureStore.clear(); @@ -847,7 +854,7 @@ describe('iosSink end', () => { describe('iosSink widget publish', () => { it.each(['happy', 'stale'] as const)( - 'writes a %s snapshot plus one expired frame at expiresAt with zero counts', + 'expires %s accessory counts while retaining last-known Home work', status => { vi.useFakeTimers(); vi.setSystemTime(NOW); @@ -869,6 +876,13 @@ describe('iosSink widget publish', () => { expect(expiredProps.statusLine).toBe('Status expired'); // Omitted, not null: UserDefaults rejects a null value. See toWidgetProps. expect(expiredProps.primaryKind).toBeUndefined(); + expect(expiredProps.home).toMatchObject({ + primaryKind: 'running', + primaryCount: 1, + stale: true, + checkedAt: snapshot.updatedAt, + canCreate: true, + }); } ); @@ -893,13 +907,20 @@ describe('iosSink widget publish', () => { }); }); - it('retracts nothing on a surface that asserts no counts', () => { + it('keeps Home empty rather than replacing it with an activity-expired message', () => { vi.useFakeTimers(); vi.setSystemTime(NOW); iosSink.publish(snapshotFor([])); - expect(mockState.timeline).toHaveLength(2); + expect( + mockState.timeline.every( + entry => (entry.props as GlanceableViewProps).home?.primaryCount === 0 + ) + ).toBe(true); + expect( + (mockState.timeline.at(-1)?.props as GlanceableViewProps | undefined)?.home?.status + ).toBe('empty'); expect(mockState.timeline[0]?.props).toMatchObject({ statusLine: 'No agents waiting' }); }); @@ -968,37 +989,6 @@ describe('iosSink widget publish', () => { expect(props.primaryKind === undefined).toBe(!hasPrimary); } }); - - it('writes the newest-result fields for the large card and drops them on locked frames', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW); - const newestAt = new Date(NOW - 180_000).toISOString(); - iosSink.publish(snapshotFor([{ status: 'question', statusUpdatedAt: newestAt }])); - - // Every publish replaces the snapshot and the timeline, so a state change - // repaints the card at once; no timer carries the newest result. - const happy = mockState.snapshots.at(-1) as Partial; - expect(happy).toMatchObject({ - newestResultKind: 'needsInput', - newestResultLabel: 'Needs input', - newestResultAt: newestAt, - }); - - // The stale frame keeps the counts and the delayed copy; the layout prefers - // that copy over a relative time claiming freshness the snapshot lost. - const stale = mockState.timeline[1]?.props as Partial; - expect(stale).toMatchObject({ - statusLine: "Can't update now", - newestResultKind: 'needsInput', - newestResultAt: newestAt, - }); - - iosSink.publish(snapshotFor([], 1, 'empty')); - const empty = mockState.snapshots.at(-1) as Partial; - expect(empty.newestResultKind).toBeUndefined(); - expect(empty.newestResultLabel).toBeUndefined(); - expect(empty.newestResultAt).toBeUndefined(); - }); }); describe('iosSink Live Activity content-state', () => { @@ -1365,26 +1355,10 @@ describe('buildGlanceableViewProps', () => { const props = buildGlanceableViewProps(snapshot, {}, key => key); const json = JSON.stringify(props); - expect(Object.keys(props).toSorted()).toEqual([ - 'accessibilityLabel', - 'actionLine', - 'actions', - 'countLines', - 'needsInputSince', - 'newestResultAt', - 'newestResultKind', - 'newestResultLabel', - 'newestTitle', - 'primaryCount', - 'primaryKind', - 'primaryLabel', - 'scheduledAt', - 'statusLine', - ]); expect(json).not.toContain('user-9f3a-leak'); expect(json).not.toContain('org-acme-7-leak'); expect(json).not.toContain(snapshot.scopeKey); - expect(json).not.toContain(snapshot.updatedAt); + expect(props.home?.checkedAt).toBe(snapshot.updatedAt); expect(json).not.toContain('revision'); // The newest session's title is the one exception the owner granted, and // it never rides in the snapshot: without the surface extra there is no @@ -1450,13 +1424,13 @@ describe('buildGlanceableViewProps', () => { ); }); - it('offers Approve for a permission wait and nothing else', () => { - const props = buildGlanceableViewProps( - snapshotFor([{ status: 'permission' }], 0), - {}, - key => key - ); - expect(props.actions).toEqual({ approve: true, newAgent: false }); + it('offers Approve and New agent for a permission wait', () => { + const snapshot = snapshotFor([{ status: 'permission' }], 0); + const props = buildGlanceableViewProps(snapshot, {}, key => key, { + snapshot, + details: { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: 'a'.repeat(64) }, + }); + expect(props.actions).toEqual({ approve: true, newAgent: true }); expect(props.statusLine).toBeNull(); }); @@ -1464,13 +1438,13 @@ describe('buildGlanceableViewProps', () => { 'offers no Approve for a %s wait the action cannot answer', status => { const props = buildGlanceableViewProps(snapshotFor([{ status }], 0), {}, key => key); - expect(props.actions).toEqual({ approve: false, newAgent: false }); + expect(props.actions).toEqual({ approve: false, newAgent: true }); } ); - it('offers no action for a tray that is working and needs nothing', () => { + it('keeps New agent available while another agent works', () => { const props = buildGlanceableViewProps(snapshotFor([{ status: 'busy' }], 0), {}, key => key); - expect(props.actions).toEqual({ approve: false, newAgent: false }); + expect(props.actions).toEqual({ approve: false, newAgent: true }); }); it('offers New agent and none of the others for the empty state', () => { @@ -1479,7 +1453,7 @@ describe('buildGlanceableViewProps', () => { expect(props.statusLine).toBe('glanceable.noneWaiting'); }); - it.each(['waiting', 'expired', 'signed_out', 'privacy'] as const)( + it.each(['waiting', 'signed_out', 'privacy'] as const)( 'offers no action and no title for a locked %s surface', status => { const props = buildGlanceableViewProps(snapshotFor([], 1, status), {}, key => key); @@ -1494,13 +1468,13 @@ describe('buildGlanceableViewProps', () => { newestSessionTitle: 'Fix the flaky test', actionFeedback: 'couldNotApprove', }); - const props = buildGlanceableViewProps( - snapshotFor([{ status: 'permission' }], 0), - {}, - key => key - ); + const snapshot = snapshotFor([{ status: 'permission' }], 0); + const props = buildGlanceableViewProps(snapshot, {}, key => key, { + snapshot, + details: { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: 'a'.repeat(64) }, + }); expect(props.newestTitle).toBe('glanceable.couldNotApprove'); - expect(props.actions).toEqual({ approve: true, newAgent: false }); + expect(props.actions).toEqual({ approve: true, newAgent: true }); }); it('holds the in-flight action in the reserved slot', () => { diff --git a/apps/mobile/src/glanceable-ios/ios-sink.ts b/apps/mobile/src/glanceable-ios/ios-sink.ts index c873a4734d..e7b519f53a 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.ts @@ -8,6 +8,7 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { i18n } from '@/i18n'; +import { getHomeWidgetDataForSnapshot } from '@/lib/glanceable/home-widget-data'; import { getLastGlanceableSnapshot, isGlanceableRestoreSettled, @@ -473,13 +474,14 @@ export const iosSink: GlanceableSink = { }, publish(snapshot) { - const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate)); + const homeData = getHomeWidgetDataForSnapshot(snapshot); + const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate, homeData)); ActiveAgentsWidget.updateSnapshot(props); // updateSnapshot leaves a single frame behind, so the shared builder adds // the delayed and expiry frames every timeline writer owes WidgetKit (see // `widgetTimelineFrames`). Null means a terminal blank, whose copy needs no // further frame. - const frames = widgetTimelineFrames(snapshot, props, translate); + const frames = widgetTimelineFrames(snapshot, props, translate, homeData); if (frames !== null) { ActiveAgentsWidget.updateTimeline(frames); } diff --git a/apps/mobile/src/glanceable-ios/layout-copy.test.ts b/apps/mobile/src/glanceable-ios/layout-copy.test.ts index 22dd116629..0214ca0a46 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.test.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.test.ts @@ -178,26 +178,17 @@ describe('withGlanceableCopy', () => { expect(glanceableLayoutCopy().locale).not.toContain('-'); }); - it('covers every status the layouts render, the action labels, and the language tag', () => { - expect(Object.keys(glanceableLayoutCopy()).toSorted()).toEqual([ - 'approve', - 'digits', - 'empty', - 'expired', - 'idle', - 'locale', - 'needsInput', - 'newAgent', - 'newestResult', - 'open', - 'openAgents', - 'privacy', - 'running', - 'scheduled', - 'signed_out', - 'stale', - 'waiting', - ]); + it('bakes Home freshness and fallback copy alongside privacy-minimal accessory copy', () => { + const copy = glanceableLayoutCopy(); + expect(copy).toMatchObject({ + checked: i18n.t('glanceable.checked'), + lastKnown: i18n.t('glanceable.lastKnown'), + awaitingUpdate: i18n.t('glanceable.awaitingUpdate'), + agent: i18n.t('common.agent'), + noneWaiting: i18n.t('glanceable.noneWaiting'), + }); + expect(copy.checked).not.toBe('glanceable.checked'); + expect(copy.awaitingUpdate).not.toBe('glanceable.awaitingUpdate'); }); it('bakes both Live Activity action labels from the reviewed keys', () => { diff --git a/apps/mobile/src/glanceable-ios/layout-copy.ts b/apps/mobile/src/glanceable-ios/layout-copy.ts index dc70a9ce89..54b251afb5 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.ts @@ -58,7 +58,6 @@ export function glanceableLayoutCopy() { scheduled: i18n.t('common.scheduled'), idle: i18n.t('common.idle'), openAgents: i18n.t('glanceable.openAgents'), - newestResult: i18n.t('glanceable.newestResult'), // The Live Activity's two buttons and the widget's in-place actions all read // `common.approve`: the permission card, the wrist control, and the widget // button name the same action, and `check:i18n` forbids a second key with the @@ -69,6 +68,16 @@ export function glanceableLayoutCopy() { approve: i18n.t('common.approve'), open: i18n.t('glanceable.openSession'), newAgent: i18n.t('glanceable.newAgent'), + checked: i18n.t('glanceable.checked'), + lastKnown: i18n.t('glanceable.lastKnown'), + awaitingUpdate: i18n.t('glanceable.awaitingUpdate'), + nextRun: i18n.t('glanceable.nextRun'), + agent: i18n.t('common.agent'), + noneWaiting: i18n.t('glanceable.noneWaiting'), + waitingForYou: i18n.t('glanceable.waitingForYou'), + permissionRequired: i18n.t('agentChat.permissionCard.title'), + answerNeeded: i18n.t('glanceable.answerNeeded'), + waitingToRetry: i18n.t('glanceable.waitingToRetry'), locale: resolveGlanceableLocale(i18n.language), digits: glanceableDigits(), }; diff --git a/apps/mobile/src/glanceable-ios/view-props.test.ts b/apps/mobile/src/glanceable-ios/view-props.test.ts index 07bf6c1e99..b1ddba2e08 100644 --- a/apps/mobile/src/glanceable-ios/view-props.test.ts +++ b/apps/mobile/src/glanceable-ios/view-props.test.ts @@ -3,7 +3,8 @@ import { type GlanceableAgentsSnapshot, type GlanceableSessionRow, } from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { afterEach, describe, expect, it } from 'vitest'; +import { EMPTY_HOME_WIDGET_DETAILS, type HomeWidgetData } from '@kilocode/app-shared/home-widget'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { withStatus } from '@/lib/glanceable/publisher'; import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; @@ -12,6 +13,7 @@ import { buildGlanceableLiveActivityContentState, buildGlanceableViewProps, toWidgetProps, + widgetTimelineFrames, } from './view-props'; const NOW = Date.parse('2026-01-02T00:00:00Z'); @@ -27,6 +29,7 @@ const translate = (key: string): string => COPY[key] ?? key; // that sets them resets them here so it cannot colour the next one. afterEach(() => { setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); + vi.useRealTimers(); }); function snapshotFor( @@ -99,9 +102,9 @@ describe('actions', () => { expect(props.actions).toEqual({ approve: false, newAgent: true }); }); - it('does not offer New agent when a session is scheduled', () => { + it('keeps New agent available when a session is scheduled', () => { const props = buildGlanceableViewProps(snapshotFor([{ status: 'scheduled' }]), {}, translate); - expect(props.actions).toEqual({ approve: false, newAgent: false }); + expect(props.actions).toEqual({ approve: false, newAgent: true }); expect(props.primaryKind).toBe('scheduled'); }); @@ -132,19 +135,28 @@ describe('actions', () => { expect(noWake.scheduledAt).toBeNull(); }); - it('keeps New agent disabled while a permission waits', () => { + it('offers New agent alongside Approve while the displayed permission waits', () => { + const snapshot = snapshotFor([PERMISSION_ROW]); + const props = buildGlanceableViewProps(snapshot, {}, translate, { + snapshot, + details: { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: 'a'.repeat(64) }, + }); + expect(props.actions).toEqual({ approve: true, newAgent: true }); + }); + + it('offers no blind Approve while the displayed request is unknown', () => { const props = buildGlanceableViewProps(snapshotFor([PERMISSION_ROW]), {}, translate); - expect(props.actions).toEqual({ approve: true, newAgent: false }); + expect(props.actions).toEqual({ approve: false, newAgent: true }); }); it.each(['question', 'retry'] as const)( - 'offers no button for a %s wait the action cannot answer', + 'offers no Approve for a %s wait the action cannot answer', status => { // `needsInput` folds in questions and retries: a question needs an answer // and a retry needs the provider back, so neither may draw a button whose // press only finds nothing to approve and opens the app instead. const props = buildGlanceableViewProps(snapshotFor([{ status }]), {}, translate); - expect(props.actions).toEqual({ approve: false, newAgent: false }); + expect(props.actions).toEqual({ approve: false, newAgent: true }); } ); }); @@ -206,24 +218,108 @@ describe('buildGlanceableLiveActivityContentState needsApproval', () => { // key set so the approvable count (or any other field) cannot silently // ship onto a widget shape that has no use for it; the newest-result trio // is the deliberate read-only data the large card draws in its footer. - const props = buildGlanceableViewProps(snapshotFor([PERMISSION_ROW]), {}, translate); - expect(Object.keys(props).toSorted()).toEqual([ - 'accessibilityLabel', - 'actionLine', - 'actions', - 'countLines', - 'needsInputSince', - 'newestResultAt', - 'newestResultKind', - 'newestResultLabel', - 'newestTitle', - 'primaryCount', - 'primaryKind', - 'primaryLabel', - 'scheduledAt', - 'statusLine', - ]); + const snapshot = snapshotFor([PERMISSION_ROW]); + const props = buildGlanceableViewProps(snapshot, {}, translate, { + snapshot, + details: { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: 'a'.repeat(64) }, + }); + expect(props.home?.canApprove).toBe(true); + expect(props.home?.canCreate).toBe(true); expect('needsApproval' in props).toBe(false); expect(toWidgetProps(props)).not.toHaveProperty('needsApproval'); }); }); + +describe('Home-only presentation and timeline', () => { + it('keeps retained Home work separate from expired accessory counts', () => { + const retained: HomeWidgetData = { + snapshot: snapshotFor([PERMISSION_ROW]), + details: { + approvalKey: 'a'.repeat(64), + primaryTitle: 'Private title', + waitingAgents: [{ title: 'Private title', kind: 'permission' }], + scheduledAgents: [], + }, + }; + const expired = withStatus(retained.snapshot, 'stale', Date.parse(retained.snapshot.expiresAt)); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'couldNotApprove' }); + const props = buildGlanceableViewProps( + expired, + {}, + translate, + retained, + Date.parse(expired.expiresAt) + ); + expect(props.countLines).toEqual([]); + expect(props.home).toMatchObject({ + primaryKind: 'needsInput', + primaryCount: 1, + stale: true, + primaryTitle: 'Private title', + checkedAt: retained.snapshot.updatedAt, + canCreate: true, + canApprove: true, + }); + expect(props.actionLine).toBe('Could not approve'); + }); + + it.each([{ signedOut: true }, { orgInvalid: true }])( + 'clears Home private content when surface flags override retained data', + flags => { + const retained = { + snapshot: snapshotFor([PERMISSION_ROW]), + details: { ...EMPTY_HOME_WIDGET_DETAILS, primaryTitle: 'Private title' }, + }; + const props = buildGlanceableViewProps(retained.snapshot, flags, translate, retained, NOW); + expect(props.home).toMatchObject({ + primaryCount: 0, + primaryTitle: null, + waitingAgents: [], + scheduledAgents: [], + canApprove: false, + canCreate: false, + }); + } + ); + + it('turns a missed wake into Awaiting update without advancing the checked time', () => { + const wake = new Date(NOW + 60_000).toISOString(); + const snapshot = snapshotFor([{ status: 'scheduled', scheduledAt: wake }]); + const data = { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }; + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate, data, NOW)); + const frames = widgetTimelineFrames(snapshot, props, translate, data) ?? []; + expect(frames.map(frame => frame.date.getTime())).toEqual( + frames.map(frame => frame.date.getTime()).toSorted((left, right) => left - right) + ); + const atWake = frames.find(frame => frame.date.getTime() === Date.parse(wake)); + expect(atWake?.props.home).toMatchObject({ + awaitingUpdate: true, + primaryKind: 'scheduled', + primaryCount: 1, + checkedAt: snapshot.updatedAt, + }); + const expired = frames.find(frame => frame.date.getTime() === Date.parse(snapshot.expiresAt)); + expect(expired?.props.countLines).toEqual([]); + expect(expired?.props.home).toMatchObject({ + primaryKind: 'scheduled', + primaryCount: 1, + stale: true, + }); + }); + + it('omits nested nulls at the UserDefaults boundary, without deleting list entries', () => { + const snapshot = snapshotFor([{ status: 'scheduled' }]); + const data = { + snapshot, + details: { + ...EMPTY_HOME_WIDGET_DETAILS, + scheduledAgents: [{ title: '', scheduledAt: null }], + }, + }; + const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate, data, NOW)); + expect(JSON.stringify(props)).not.toContain(':null'); + expect(props.home?.scheduledAgents).toEqual([{ title: '' }]); + }); +}); diff --git a/apps/mobile/src/glanceable-ios/view-props.ts b/apps/mobile/src/glanceable-ios/view-props.ts index 2514c9c462..c107c8bf87 100644 --- a/apps/mobile/src/glanceable-ios/view-props.ts +++ b/apps/mobile/src/glanceable-ios/view-props.ts @@ -1,8 +1,14 @@ import { GLANCEABLE_STALE_MS, type GlanceableAgentsSnapshot, - isIdleOnlyGlanceableWork, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + buildHomeWidgetPresentation, + buildHomeWidgetPresentationTimeline, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetData, + type HomeWidgetPresentation, +} from '@kilocode/app-shared/home-widget'; import { type GlanceableLiveActivityContentState } from '@kilocode/notifications'; import { @@ -21,12 +27,10 @@ import { getSurfaceExtras, type GlanceableSurfaceExtras } from '@/lib/glanceable /** One translated count line. `kind` picks the glyph and the color. */ type GlanceableCount = { label: string; kind: GlanceableCountKind; count: number }; -/** - * The props every iOS surface renders. The builder below is the only producer, - * so a title, organization name, account id, or raw session id can never reach - * the widget extension. - */ +/** Generic accessory props and a separate Home-only presentation. */ export type GlanceableViewProps = { + /** Home-only titles and retained counts never enter accessory rendering. */ + home?: HomeWidgetPresentation; /** Translated locked copy; null while counts show (happy). Stale carries both. */ statusLine: string | null; /** Non-zero count lines in rank order (needs-input, running, scheduled, idle). */ @@ -96,6 +100,8 @@ export type GlanceableWidgetAction = 'approve' | 'new-agent'; export type GlanceableWidgetProps = Partial & { pendingAction?: GlanceableWidgetAction; + /** The exact request the pressed Approve displayed. */ + pendingApprovalKey?: string; }; /** @@ -131,10 +137,13 @@ function newestTitleFor( } /** Build the surface props from a snapshot, surface flags, and a translator. */ +// eslint-disable-next-line max-params -- snapshot, flags, the translator, separately scoped Home-only data, and the frame time export function buildGlanceableViewProps( snapshot: GlanceableAgentsSnapshot, flags: GlanceableSurfaceFlags, - translate: (key: string) => string + translate: (key: string) => string, + homeData?: HomeWidgetData, + now = Date.now() ): GlanceableViewProps { const statusKey = glanceableStatusCopyKey(snapshot, flags); const primary = primaryGlanceableCount(snapshot); @@ -160,25 +169,36 @@ export function buildGlanceableViewProps( const copy = (key: string): string => key === 'glanceable.empty' ? translate('glanceable.noneWaiting') : translate(key); - const newestTitle = newestTitleFor(getSurfaceExtras(), status, translate); + const extras = getSurfaceExtras(); + const newestTitle = newestTitleFor(extras, status, translate); + const home = buildHomeWidgetPresentation( + flags.signedOut || flags.orgInvalid + ? { + snapshot: { ...snapshot, status: flags.signedOut ? 'signed_out' : 'privacy' }, + details: EMPTY_HOME_WIDGET_DETAILS, + } + : (homeData ?? { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }), + now + ); return { + home, statusLine: statusKey === null ? null : copy(statusKey), countLines, primaryLabel: primary === null ? null : translate(primary.key), primaryKind: primary === null ? null : primary.kind, primaryCount: primary === null ? 0 : primary.count, newestTitle, - actionLine: getSurfaceExtras().actionFeedback === null ? null : newestTitle, + actionLine: + home.status !== 'content' || extras.actionFeedback === null + ? null + : translate( + extras.actionFeedback === 'approving' + ? 'glanceable.approving' + : 'glanceable.couldNotApprove' + ), actions: { - // Only a permission wait can be answered from the widget, so the button - // gates on `needsApproval` (the count the Live Activity's own Approve - // control uses). A `question` needs an answer and a `retry` needs the - // provider back: neither is approvable, so neither may offer a button the - // action can only answer by opening the app. - approve: showCounts && (snapshot.needsApproval ?? 0) > 0, - // Nothing waiting to act on: the empty state, or an idle-only tray that - // keeps a card alive. A locked or expired surface offers neither. - newAgent: status === 'empty' || (showCounts && isIdleOnlyGlanceableWork(snapshot)), + approve: home.canApprove, + newAgent: home.canCreate, }, needsInputSince: showCounts && snapshot.needsInput > 0 ? snapshot.needsInputSince : null, // The shared helper decides the wake, so a scheduled count with no usable @@ -191,6 +211,11 @@ export function buildGlanceableViewProps( }; } +/** One record with its null fields dropped; the caller owns the resulting shape. */ +function withoutNulls(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== null)) as T; +} + /** * Drop the null fields before a widget write. * @@ -200,8 +225,19 @@ export function buildGlanceableViewProps( * defaults, so omitting the field is the lossless form. */ export function toWidgetProps(props: GlanceableViewProps): Partial { - const entries = Object.entries(props).filter(([, value]) => value !== null); - return Object.fromEntries(entries) as Partial; + const widgetProps = withoutNulls(props); + const { home } = props; + if (home === undefined) { + return widgetProps; + } + // UserDefaults rejects null even inside the nested Home-only payload: its + // own nullable fields and each scheduled row's wake. + return Object.assign(widgetProps, { + home: { + ...withoutNulls(home), + scheduledAgents: home.scheduledAgents.map(row => withoutNulls(row)), + }, + }); } /** @@ -235,10 +271,7 @@ export function staleTimelineFrame( : [{ date: new Date(staleAt), props: buildStaleWidgetProps(snapshot, translate) }]; } -/** - * The widget's expiry frame: the same snapshot with its counts zeroed and the - * expired copy, for the timeline entry that lands at `expiresAt`. - */ +/** Accessory expiry retracts its counts; Home retains the same confirmed work. */ export function buildExpiredWidgetProps( snapshot: GlanceableAgentsSnapshot, translate: (key: string) => string @@ -256,7 +289,9 @@ export function buildExpiredWidgetProps( scheduledAt: null, }, {}, - translate + translate, + { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }, + Date.parse(snapshot.expiresAt) ) ); } @@ -274,38 +309,66 @@ export type GlanceableLiveActivityProps = GlanceableLiveActivityContentState & { notice?: string; }; +/** The status a later timeline frame draws: expired at expiry, stale once the window lapses. */ +function frameStatus( + snapshot: GlanceableAgentsSnapshot, + time: number +): GlanceableAgentsSnapshot['status'] { + if (time >= Date.parse(snapshot.expiresAt)) { + return 'expired'; + } + if (time >= Date.parse(snapshot.updatedAt) + GLANCEABLE_STALE_MS) { + return 'stale'; + } + return snapshot.status; +} + /** - * The widget timeline for one snapshot running from `now`: the current frame, - * then the delayed frame that stops asserting the counts as current and the - * expiry frame that zeroes them. - * - * WidgetKit is the only clock the widget has while the app is not running, so - * every writer that replaces the timeline must hand it the whole set — the - * publisher's sink (`ios-sink.publish`) and the failure republish after a press - * (`glanceable-ios/widget-actions`). A single-frame write drops the two - * fallbacks and the widget keeps claiming the line it was last given. - * - * `null` for a terminal blank: `updateSnapshot` already wrote its single - * current frame, and the delayed copy must never replace signed-out or privacy - * copy. + * Every writer supplies the same current/stale/expiry/wake frames. Home counts + * remain last-known at activity expiry; accessories still expire privately. + * Terminal auth/privacy writes replace the entire timeline with one blank. */ +// eslint-disable-next-line max-params -- snapshot, the current props, the translator, and separately scoped Home-only data export function widgetTimelineFrames( snapshot: GlanceableAgentsSnapshot, props: Partial, - translate: (key: string) => string + translate: (key: string) => string, + homeData?: HomeWidgetData ): { date: Date; props: Partial }[] | null { if (snapshot.status === 'signed_out' || snapshot.status === 'privacy') { return null; } + const data = homeData ?? { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }; const now = Date.now(); // WidgetKit renders the newest entry at or before `now` and never rewinds, so // a frame whose date has already passed sits behind the current one and only // leaves the timeline unsorted. A press on a widget whose last snapshot // lapsed while the app was away therefore keeps the single current frame. - const later = [ - ...staleTimelineFrame(snapshot, translate), - { date: new Date(snapshot.expiresAt), props: buildExpiredWidgetProps(snapshot, translate) }, - ].filter(frame => frame.date.getTime() > now); + const boundaries = [ + ...staleTimelineFrame(snapshot, translate).map(entry => entry.date.getTime()), + ...buildHomeWidgetPresentationTimeline(data, now).map(entry => entry.at), + Date.parse(snapshot.expiresAt), + ]; + const later = [...new Set(boundaries)] + .filter(time => Number.isFinite(time) && time > now) + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; filter already copies so nothing shared is mutated + .sort((left, right) => left - right) + .map(time => ({ + date: new Date(time), + props: toWidgetProps({ + ...buildGlanceableViewProps( + { ...snapshot, status: frameStatus(snapshot, time) }, + {}, + translate, + data, + time + ), + ...(time >= Date.parse(snapshot.expiresAt) + ? { countLines: [], primaryCount: 0, primaryKind: null, primaryLabel: null } + : {}), + home: buildHomeWidgetPresentation(data, time), + }), + })); return [{ date: new Date(now), props }, ...later]; } diff --git a/apps/mobile/src/glanceable-ios/widget-actions.test.ts b/apps/mobile/src/glanceable-ios/widget-actions.test.ts index a784f2f481..5b6137e1c0 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.test.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.test.ts @@ -6,7 +6,13 @@ import { GLANCEABLE_SNAPSHOT_EXPIRY_MS, type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + buildHomeWidgetPresentation, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetDetails, +} from '@kilocode/app-shared/home-widget'; +import { setHomeWidgetDetails } from '@/lib/glanceable/home-widget-data'; import { _setLastGlanceableSnapshotForTests } from '@/lib/glanceable/persist'; import { getSurfaceExtras, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; @@ -19,11 +25,7 @@ import { registerWidgetActionHandling, runPendingWidgetActions, } from './widget-actions'; -import { - buildGlanceableViewProps, - type GlanceableViewProps, - type GlanceableWidgetProps, -} from './view-props'; +import { buildGlanceableViewProps, type GlanceableWidgetProps } from './view-props'; // The widget surfaces are unreachable under vitest: the swift-ui primitives are // recording stubs, react-native is stubbed, and expo-widgets' factories are @@ -156,69 +158,8 @@ function snapshotFor(sessions: { status: string }[], now = NOW): GlanceableAgent }); } -// ── widget-prop fixtures ──────────────────────────────────────────────────── - -const HAPPY_WAITING_PROPS: Partial = { - statusLine: null, - countLines: [ - { label: 'Needs input', kind: 'needsInput', count: 1 }, - { label: 'Working', kind: 'running', count: 1 }, - { label: 'Idle', kind: 'idle', count: 0 }, - ], - primaryLabel: 'Needs input', - primaryKind: 'needsInput', - primaryCount: 1, - newestTitle: 'Newest: Fix the flaky test', - actions: { approve: true, newAgent: false }, - needsInputSince: null, - accessibilityLabel: 'spoken label', -}; - -const EMPTY_WIDGET_PROPS: Partial = { - statusLine: 'No agents waiting', - countLines: [], - primaryLabel: null, - primaryKind: null, - primaryCount: 0, - newestTitle: null, - actions: { approve: false, newAgent: true }, - needsInputSince: null, - accessibilityLabel: 'spoken label', -}; - -const SIGNED_OUT_PROPS: Partial = { - statusLine: 'Sign in to see agents', - countLines: [], - primaryLabel: null, - primaryKind: null, - primaryCount: 0, - newestTitle: null, - actions: { approve: false, newAgent: false }, - needsInputSince: null, - accessibilityLabel: 'spoken label', -}; - -/** One scheduled session and nothing else: the wake rides beside its row. */ const SCHEDULED_WAKE = '2026-09-24T09:00:00.000Z'; -const SCHEDULED_PROPS: Partial = { - statusLine: null, - countLines: [ - { label: 'Needs input', kind: 'needsInput', count: 0 }, - { label: 'Working', kind: 'running', count: 0 }, - { label: 'Scheduled', kind: 'scheduled', count: 1 }, - { label: 'Idle', kind: 'idle', count: 0 }, - ], - primaryLabel: 'Scheduled', - primaryKind: 'scheduled', - primaryCount: 1, - newestTitle: null, - actions: { approve: false, newAgent: false }, - needsInputSince: null, - scheduledAt: SCHEDULED_WAKE, - accessibilityLabel: 'spoken label', -}; - // ── mock-element tree helpers, shared with the render suite ───────────────── type MockElement = { kind: string; props: Record }; @@ -245,31 +186,6 @@ function collectOfKind(node: unknown, kind: string): MockElement[] { return collect(node).filter(element => element.kind === kind); } -/** - * The scheduled wake: the one `Text` the layout draws as an absolute clock - * time (`dateStyle="time"`). The needs-input wait and the newest-result age are - * relative durations, so the style is what tells the wake apart. - */ -function wakeTexts(node: unknown): MockElement[] { - return collect(node).filter( - element => element.kind === 'Text' && element.props.dateStyle === 'time' - ); -} - -/** The count row whose own label is `label`, found through its direct children. */ -function countRowFor(node: unknown, label: string): MockElement | undefined { - return collect(node).find(element => { - const children = element.props.children; - return ( - element.kind === 'HStack' && - Array.isArray(children) && - children.some( - child => (child as { props?: { children?: unknown } } | null)?.props?.children === label - ) - ); - }); -} - /** Every `widgetURL` modifier argument anywhere in the tree. */ function widgetURLs(node: unknown): unknown[] { return collect(node).flatMap(element => { @@ -283,16 +199,26 @@ function widgetURLs(node: unknown): unknown[] { }); } -function pressButton(tree: unknown, patch: { pendingAction: string }): MockElement | undefined { - return collect(tree).find( - element => - element.kind === 'Button' && - typeof element.props.onPress === 'function' && - (element.props.onPress as () => { pendingAction: string })().pendingAction === - patch.pendingAction - ); +function pressButton( + tree: unknown, + patch: { pendingAction: string; pendingApprovalKey?: string } +): MockElement | undefined { + return collect(tree).find(element => { + if (element.kind !== 'Button' || typeof element.props.onPress !== 'function') { + return false; + } + const pressed = (element.props.onPress as () => Record)(); + return ( + pressed.pendingAction === patch.pendingAction && + (patch.pendingApprovalKey === undefined || + pressed.pendingApprovalKey === patch.pendingApprovalKey) + ); + }); } +const APPROVAL_KEY = 'a'.repeat(64); +const APPROVABLE: HomeWidgetDetails = { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: APPROVAL_KEY }; + // ── marker mapping ────────────────────────────────────────────────────────── describe('pendingActionOf', () => { @@ -558,18 +484,24 @@ describe('runPendingWidgetActions', () => { }); it("pushes the couldn't-approve feedback and fresh props when the approve fails", async () => { - widgetState.timeline = [{ date: new Date(1), props: { pendingAction: 'approve' } }]; + widgetState.timeline = [ + { date: new Date(1), props: { pendingAction: 'approve', pendingApprovalKey: APPROVAL_KEY } }, + ]; mocks.runWidgetApprove.mockResolvedValue({ kind: 'failed' }); _setLastGlanceableSnapshotForTests(snapshotFor([{ status: 'permission' }])); + setHomeWidgetDetails(APPROVABLE); await runPendingWidgetActions(); + setHomeWidgetDetails(EMPTY_HOME_WIDGET_DETAILS); + + expect(mocks.runWidgetApprove).toHaveBeenCalledExactlyOnceWith(APPROVAL_KEY); expect(getSurfaceExtras().actionFeedback).toBe('couldNotApprove'); expect(widgetState.snapshots).toHaveLength(1); expect(widgetState.snapshots[0]).toMatchObject({ newestTitle: 'glanceable.couldNotApprove', // Approve is still available: the call failed, not the work. - actions: { approve: true, newAgent: false }, + actions: { approve: true, newAgent: true }, }); }); @@ -776,289 +708,242 @@ function renderWidget(props: GlanceableWidgetProps, family: WidgetFamily): MockE }) as unknown as MockElement; } -/** A recorded swift-ui modifier, typed the way the mock stubs record it. */ -type MockModifier = { $type?: string; args?: Record }; - -/** The recorded modifiers on an element, never any other prop. */ -function mockModifiers(element: MockElement | undefined): MockModifier[] { - const modifiers = element?.props.modifiers; - return Array.isArray(modifiers) ? (modifiers as MockModifier[]) : []; -} - -/** - * The reserved newest-session slot: the one container whose `frame` pins a - * height and nothing else. The mark's own frame pins both a width and a - * height, so this finds the slot by shape rather than by position. - */ -function reservedSlot(tree: unknown): MockElement | undefined { - return collect(tree).find(element => - mockModifiers(element).some( - modifier => - modifier.$type === 'frame' && - modifier.args !== undefined && - 'height' in modifier.args && - !('width' in modifier.args) - ) - ); -} - -/** The height the slot's frame pins, or undefined when it pins none. */ -function slotHeight(element: MockElement | undefined): number | undefined { - const frameModifier = mockModifiers(element).find(modifier => modifier.$type === 'frame'); - return frameModifier?.args?.height as number | undefined; +const HOME_FAMILIES: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge']; +const ACCESSORY_FAMILIES: WidgetFamily[] = [ + 'accessoryCircular', + 'accessoryInline', + 'accessoryRectangular', +]; + +function homeProps( + sessions: Parameters[0]['sessions'], + details: HomeWidgetDetails = EMPTY_HOME_WIDGET_DETAILS, + now = NOW +): GlanceableWidgetProps { + const snapshot = buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + }); + return buildGlanceableViewProps(snapshot, {}, key => key, { snapshot, details }, now); } describe('activeAgentsWidgetLayout', () => { - it('keeps the Lock Screen families generic: no title and no buttons', () => { - const lockScreenFamilies: WidgetFamily[] = [ - 'accessoryCircular', - 'accessoryInline', - 'accessoryRectangular', - ]; - for (const family of lockScreenFamilies) { - const tree = renderWidget(HAPPY_WAITING_PROPS, family); + it('keeps accessory families privacy-minimal despite Home titles and actions', () => { + const props = homeProps([{ status: 'permission' }], { + approvalKey: APPROVAL_KEY, + primaryTitle: 'Private title', + waitingAgents: [{ title: 'Private title', kind: 'permission' }], + scheduledAgents: [], + }); + for (const family of ACCESSORY_FAMILIES) { + const tree = renderWidget(props, family); expect(collectOfKind(tree, 'Button')).toEqual([]); - expect(collectText(tree)).not.toContain('Newest: Fix the flaky test'); + expect(collectText(tree)).not.toContain('Private title'); expect(widgetURLs(tree)).toEqual(['kiloapp:///cloud/sessions']); } }); - it('renders a stored count row that carries no label in every family', () => { - // A timeline another app version wrote can hold a row without a string - // label; a throw here is the red error box in every family. - const props = { - ...HAPPY_WAITING_PROPS, - countLines: [{ count: 2, kind: 'needsInput' }], - } as unknown as GlanceableWidgetProps; - const families: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge']; - for (const family of families) { - expect(() => renderWidget(props, family)).not.toThrow(); - } - }); - - it('draws the newest-session line and the Approve button in the small family', () => { - const tree = renderWidget(HAPPY_WAITING_PROPS, 'systemSmall'); - - const button = pressButton(tree, { pendingAction: 'approve' }); - expect(button).toBeDefined(); - const pressApprove = button?.props.onPress as (() => unknown) | undefined; - expect(pressApprove?.()).toEqual({ - pendingAction: 'approve', - }); - // Approve answers in place: its intent must not foreground the app. - expect(button?.props.openAppWhenRun).toBeUndefined(); - expect(collectText(tree)).toContain('Newest: Fix the flaky test'); - // The body keeps its own deep link: a tap beside the buttons opens Kilo. - expect(widgetURLs(tree)).toEqual(['kiloapp:///cloud/sessions']); - }); - - it('reserves the newest-session slot with and without a line in both Home Screen families', () => { - for (const family of ['systemSmall', 'systemMedium'] as WidgetFamily[]) { - const filled = renderWidget(HAPPY_WAITING_PROPS, family); - // The same props with no title: the state a process restart starts from, - // before the first snapshot answers with a session. - const blank = renderWidget({ ...HAPPY_WAITING_PROPS, newestTitle: null }, family); - - const filledSlot = reservedSlot(filled); - const blankSlot = reservedSlot(blank); - // The slot is its own container with a fixed height in every state, so a - // title arriving late, an approve's progress line taking the slot, and the - // answer replacing it move neither the count rows above nor the action row below. - expect(filledSlot?.kind).toBe('VStack'); - expect(blankSlot?.kind).toBe('VStack'); - expect(slotHeight(filledSlot)).toBe(slotHeight(blankSlot)); - expect(slotHeight(filledSlot)).toBeGreaterThan(0); - - // Only the line differs, never the space reserved around it. - expect(collectText(filled)).toContain('Newest: Fix the flaky test'); - expect(collectText(blank)).not.toContain('Newest: Fix the flaky test'); + it.each(HOME_FAMILIES)( + 'keeps New agent navigation available alongside approval in %s', + family => { + const tree = renderWidget(homeProps([{ status: 'permission' }], APPROVABLE), family); + const newAgent = pressButton(tree, { pendingAction: 'new-agent' }); + expect(newAgent?.props.openAppWhenRun).toBe(true); + const approve = pressButton(tree, { + pendingAction: 'approve', + pendingApprovalKey: APPROVAL_KEY, + }); + expect(approve).toBeDefined(); + expect(approve?.props.openAppWhenRun).toBeUndefined(); + expect(widgetURLs(tree)).toEqual(['kiloapp:///cloud/sessions']); } - }); - - it('draws the New agent button for the empty state and no Approve button', () => { - const tree = renderWidget(EMPTY_WIDGET_PROPS, 'systemSmall'); - - const button = pressButton(tree, { pendingAction: 'new-agent' }); - expect(button).toBeDefined(); - const pressNewAgent = button?.props.onPress as (() => unknown) | undefined; - expect(pressNewAgent?.()).toEqual({ - pendingAction: 'new-agent', - }); - // New agent needs the composer: its intent foregrounds the app, or the tap - // only marks the timeline until the next launch. - expect(button?.props.openAppWhenRun).toBe(true); - expect(pressButton(tree, { pendingAction: 'approve' })).toBeUndefined(); - expect(collectText(tree)).toContain('No agents waiting'); - }); - - it('draws no button for a signed-out surface', () => { - const tree = renderWidget(SIGNED_OUT_PROPS, 'systemMedium'); - - expect(collectOfKind(tree, 'Button')).toEqual([]); - expect(collectText(tree)).toContain('Sign in to see agents'); - }); - - it('styles the widget buttons as small bordered actions', () => { - const tree = renderWidget(HAPPY_WAITING_PROPS, 'systemSmall'); - - const button = pressButton(tree, { pendingAction: 'approve' }); - const styles = (button?.props.modifiers as { $type: string }[] | undefined)?.map( - modifier => modifier.$type - ); - expect(styles).toContain('buttonStyle'); - expect(styles).toContain('controlSize'); - }); - - // The large card is a wide card with a footer, so it draws the scheduled - // wake beside its row exactly as the medium card does — the same information - // the Android widget of that size shows. The small square has no room. - it('draws the scheduled wake in the large card, the same as the medium one', () => { - for (const family of ['systemMedium', 'systemLarge'] as WidgetFamily[]) { - const wake = wakeTexts(renderWidget(SCHEDULED_PROPS, family)); + ); - expect(wake).toHaveLength(1); - expect(wake[0]?.props.date).toEqual(new Date(SCHEDULED_WAKE)); + it.each(HOME_FAMILIES)( + 'offers no blind Approve without a displayed request key in %s', + family => { + expect( + pressButton(renderWidget(homeProps([{ status: 'permission' }]), family), { + pendingAction: 'approve', + }) + ).toBeUndefined(); } - expect(wakeTexts(renderWidget(SCHEDULED_PROPS, 'systemSmall'))).toEqual([]); - }); - - it('reserves the wake slot beside the scheduled row in the medium and large cards', () => { - for (const family of ['systemMedium', 'systemLarge'] as WidgetFamily[]) { - const row = countRowFor(renderWidget(SCHEDULED_PROPS, family), 'Scheduled'); + ); - // The trailing slot is laid out whether or not a wake is known, so a - // wake the CLI reports later cannot move the row. - expect(collect(row?.props.children).some(element => element.kind === 'Spacer')).toBe(true); + it.each(HOME_FAMILIES)( + 'holds both action hit slots in the same header across states in %s', + family => { + const states = [ + homeProps([{ status: 'permission' }], APPROVABLE), + homeProps([]), + homeProps([{ status: 'busy' }]), + ]; + for (const props of states) { + const root = renderWidget(props, family); + const header = (root.props.children as { props: Record }[])[0]; + const slots = ( + header?.props.children as { props: Record }[] | undefined + )?.slice(-2); + expect(slots?.map(slot => slot.props.modifiers)).toEqual([ + [{ $type: 'frame', args: { width: 24, height: 24 } }], + [{ $type: 'frame', args: { width: 24, height: 24 } }], + ]); + } } - const smallRow = countRowFor(renderWidget(SCHEDULED_PROPS, 'systemSmall'), 'Scheduled'); - expect(collect(smallRow?.props.children).some(element => element.kind === 'Spacer')).toBe( - false - ); - }); + ); - it('never scales a count-row label in any family', () => { - // In the widget renderer a scaling label always drew at its minimum scale, - // far smaller than its count. - const families: WidgetFamily[] = [ - 'systemSmall', - 'systemMedium', - 'systemLarge', - 'accessoryRectangular', - ]; - for (const family of families) { - const row = countRowFor(renderWidget(HAPPY_WAITING_PROPS, family), 'Needs input'); - const label = collectOfKind(row?.props.children, 'Text').find( - text => text.props.children === 'Needs input' - ); - expect(label).toBeDefined(); - expect(mockModifiers(label).map(modifier => modifier.$type)).not.toContain( - 'minimumScaleFactor' + it.each(HOME_FAMILIES)( + 'shows only nonzero Home counts and an honest checked footer in %s', + family => { + const props = homeProps([{ status: 'busy' }, { status: 'idle' }]); + const texts = collectText(renderWidget(props, family)); + expect(texts).toContain('Working'); + expect(texts).not.toContain('Needs input'); + expect(texts).not.toContain('Scheduled'); + expect(texts).toContain('Checked'); + const footer = collectOfKind(renderWidget(props, family), 'Text').find( + text => text.props.dateStyle === 'ago' ); + expect(footer?.props.date).toEqual(new Date(NOW)); } - }); + ); - it('offers the action in the large card', () => { - const approve = pressButton(renderWidget(HAPPY_WAITING_PROPS, 'systemLarge'), { - pendingAction: 'approve', - }); - expect(approve).toBeDefined(); + it.each(HOME_FAMILIES)('retains old counts without asserting current work in %s', family => { + const texts = collectText( + renderWidget( + homeProps([{ status: 'busy' }], EMPTY_HOME_WIDGET_DETAILS, NOW + 24 * 60 * 60 * 1000), + family + ) + ); + expect(texts).toContain('1'); + expect(texts).toContain('Last known'); + expect(texts).toContain('Checked'); + expect(texts).not.toContain('Status expired'); + }); - const empty = renderWidget(EMPTY_WIDGET_PROPS, 'systemLarge'); - expect(pressButton(empty, { pendingAction: 'new-agent' })?.props.openAppWhenRun).toBe(true); - expect(collectText(empty)).toContain('No agents waiting'); + it.each(HOME_FAMILIES)('renders the scheduled wake even in the small family: %s', family => { + const props = homeProps([{ status: 'scheduled', scheduledAt: SCHEDULED_WAKE }]); + const tree = renderWidget(props, family); + expect( + collectOfKind(tree, 'Text').some( + text => text.props.dateStyle === 'time' && text.props.date instanceof Date + ) + ).toBe(true); + expect(collectText(tree)).not.toContain('Idle'); }); - it('names an Approve in flight on the large card, which has no reserved slot', () => { - const failed = renderWidget( - { ...HAPPY_WAITING_PROPS, actionLine: 'Could not approve' }, + it.each(HOME_FAMILIES)('does not invent running after a missed wake in %s', family => { + const props = homeProps([ + { status: 'scheduled', scheduledAt: new Date(NOW - 1).toISOString() }, + ]); + const texts = collectText(renderWidget(props, family)); + expect(texts).toContain('Awaiting update'); + expect(texts).toContain('Scheduled'); + expect(texts).not.toContain('Working'); + }); + + it('shows bounded waiting and scheduled details in the large family', () => { + const waiting = renderWidget( + homeProps([{ status: 'question' }], { + approvalKey: null, + primaryTitle: 'First wait', + waitingAgents: [ + { title: 'First wait', kind: 'question' }, + { title: 'Second wait', kind: 'retry' }, + ], + scheduledAgents: [], + }), 'systemLarge' ); - expect(collectText(failed)).toContain('Could not approve'); - expect(collectText(renderWidget(HAPPY_WAITING_PROPS, 'systemLarge'))).not.toContain( - 'Could not approve' + expect(collectText(waiting)).toContain('Second wait'); + const scheduled = renderWidget( + homeProps([{ status: 'scheduled', scheduledAt: SCHEDULED_WAKE }], { + approvalKey: null, + primaryTitle: 'Later job', + waitingAgents: [], + scheduledAgents: [ + { title: 'Later job', scheduledAt: SCHEDULED_WAKE }, + { title: '', scheduledAt: null }, + ], + }), + 'systemLarge' ); + expect(collectText(scheduled)).toContain('Agent'); + expect(collectText(scheduled)).toContain('Awaiting update'); }); - it('drops a row time whole when its label is too long for the large card', () => { - const props: GlanceableWidgetProps = { - ...SCHEDULED_PROPS, - countLines: [ - { label: 'Needs input', kind: 'needsInput', count: 0 }, - { label: 'Working', kind: 'running', count: 0 }, - { label: 'En espera de respuesta', kind: 'scheduled', count: 1 }, - { label: 'Idle', kind: 'idle', count: 0 }, - ], - }; - expect(wakeTexts(renderWidget(props, 'systemLarge'))).toEqual([]); - expect(wakeTexts(renderWidget(props, 'systemMedium'))).toHaveLength(1); - }); + it.each(HOME_FAMILIES)( + 'keeps failure feedback visible with retained Home content in %s', + family => { + const tree = renderWidget( + { ...homeProps([{ status: 'permission' }], APPROVABLE), actionLine: 'Could not approve' }, + family + ); + expect(collectText(tree)).toContain('Could not approve'); + expect( + pressButton(tree, { pendingAction: 'approve', pendingApprovalKey: APPROVAL_KEY }) + ).toBeDefined(); + } + ); - it('leads with the rows that have work and mutes the zero rows', () => { - const labels = (tree: unknown): unknown[] => - collectOfKind(tree, 'Text') - .map(text => text.props.children) - .filter(copy => ['Needs input', 'Working', 'Scheduled', 'Idle'].includes(copy as string)); - for (const family of [ - 'systemLarge', - 'systemMedium', - 'accessoryRectangular', - ] as WidgetFamily[]) { - const tree = renderWidget(SCHEDULED_PROPS, family); - - // Scheduled is the one row with work: it leads, the zeros keep their - // grid order after it. - expect(labels(tree)).toEqual(['Scheduled', 'Needs input', 'Working', 'Idle']); - - // A zero row draws fully muted: the label and the number in the - // secondary colour, the number without its emphasis. - const muted = countRowFor(tree, 'Working'); - const mutedLabel = collectOfKind(muted?.props.children, 'Text').find( - text => text.props.children === 'Working' + it.each(HOME_FAMILIES)('renders empty, waiting, auth and privacy deliberately in %s', family => { + for (const status of ['empty', 'waiting', 'signed_out', 'privacy'] as const) { + const snapshot = { ...snapshotFor([]), status }; + const home = buildHomeWidgetPresentation( + { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }, + NOW ); - expect(mockModifiers(mutedLabel).map(modifier => modifier.args)).toContain('secondaryLabel'); - const mutedNumber = collectOfKind(muted?.props.children, 'Text').find( - text => text.props.children === '0' + const tree = renderWidget({ home }, family); + expect(collectText(tree)).toContain('Kilo'); + expect(collectText(tree).includes('Checked')).toBe(status === 'empty'); + expect(pressButton(tree, { pendingAction: 'approve' })).toBeUndefined(); + expect(pressButton(tree, { pendingAction: 'new-agent' }) !== undefined).toBe( + status === 'empty' ); - expect(mockModifiers(mutedNumber).find(m => m.$type === 'font')?.args).toMatchObject({ - weight: 'regular', - }); } }); - it('sits the wait and the wake in one trailing column on the medium and large cards', () => { - const props: GlanceableWidgetProps = { - ...SCHEDULED_PROPS, - needsInputSince: '2026-09-24T08:00:00.000Z', - countLines: [ - { label: 'Needs input', kind: 'needsInput', count: 2 }, - { label: 'Scheduled', kind: 'scheduled', count: 1 }, - ], - }; - for (const family of ['systemMedium', 'systemLarge'] as WidgetFamily[]) { - const tree = renderWidget(props, family); - for (const label of ['Needs input', 'Scheduled']) { - const row = countRowFor(tree, label); - // The row fills the card's width, so its time lands in the same - // trailing column as every other row's. - expect(mockModifiers(row).map(modifier => modifier.$type)).toContain('frame'); - } + it('contains malformed persisted props without throwing in any family', () => { + const corrupt = { + countLines: [null, { kind: 'unknown', count: 'bad', label: {} }], + primaryKind: 'unknown', + primaryLabel: {}, + primaryCount: Number.NaN, + home: { + status: 'unknown', + secondaryCounts: [null, { kind: 'unknown' }], + waitingAgents: [null], + scheduledAgents: {}, + checkedAt: 'bad', + scheduledAt: 'bad', + }, + } as unknown as GlanceableWidgetProps; + for (const family of [...HOME_FAMILIES, ...ACCESSORY_FAMILIES]) { + expect(() => renderWidget(corrupt, family)).not.toThrow(); } }); - it('scales the count-less large card up', () => { - const tree = renderWidget(EMPTY_WIDGET_PROPS, 'systemLarge'); - - // The mark, the status line and the control all grow with the card, or the - // composition reads as a stamp in a big card. - const mark = collectOfKind(tree, 'Image')[0]; - expect(mockModifiers(mark).find(m => m.$type === 'frame')?.args).toMatchObject({ width: 56 }); - const status = collectOfKind(tree, 'Text').find( - text => text.props.children === 'No agents waiting' - ); - expect(mockModifiers(status).find(m => m.$type === 'font')?.args).toMatchObject({ - textStyle: 'title3', + it('does not use broken minimum scaling for long labels and large counts', () => { + const props = homeProps([{ status: 'busy' }], { + ...EMPTY_HOME_WIDGET_DETAILS, + primaryTitle: 'A very long title '.repeat(20), }); - const button = pressButton(tree, { pendingAction: 'new-agent' }); - expect(mockModifiers(button).find(m => m.$type === 'controlSize')?.args).toBe('regular'); + expect(props.home).toBeDefined(); + props.home = props.home && { ...props.home, primaryCount: 1_234_567_890 }; + for (const family of HOME_FAMILIES) { + const tree = renderWidget(props, family); + expect(collectText(tree)).toContain('1234567890'); + expect( + collect(tree) + .flatMap(element => + Array.isArray(element.props.modifiers) + ? (element.props.modifiers as { $type?: string }[]) + : [] + ) + .some(modifier => modifier.$type === 'minimumScaleFactor') + ).toBe(false); + } }); }); diff --git a/apps/mobile/src/glanceable-ios/widget-actions.ts b/apps/mobile/src/glanceable-ios/widget-actions.ts index b2450a0a8e..1fb74ea2d6 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.ts @@ -2,6 +2,7 @@ import { addUserInteractionListener, type UserInteractionEvent } from 'expo-widg import { Linking } from 'react-native'; import { i18n } from '@/i18n'; +import { getHomeWidgetDataForSnapshot } from '@/lib/glanceable/home-widget-data'; import { getLastGlanceableSnapshot } from '@/lib/glanceable/persist'; import { getSurfaceExtras, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; import { runWidgetApprove } from '@/lib/glanceable/widget-actions'; @@ -48,17 +49,20 @@ export function pendingActionOf( /** The pressed entry's props without the marker, in the form the timeline stores. */ function stripPendingAction(props: WidgetProps | null | undefined): WidgetProps { - const { pendingAction: _pendingAction, ...rest } = props ?? {}; + const { + pendingAction: _pendingAction, + pendingApprovalKey: _pendingApprovalKey, + ...rest + } = props ?? {}; return rest; } /** - * One press: the action its entry carries and the entry's date, which is what - * identifies the entry — the App Intent merges the press marker into the - * pressed entry's props and never touches its date, so a press read at one - * moment can be matched against the timeline read at another. + * One press: the action its entry carries, the approval it displayed, and the + * entry's date, which identifies the entry — the App Intent merges the press + * marker into the pressed entry's props and never touches its date. */ -type PressedEntry = { date: number; action: GlanceableWidgetAction }; +type PressedEntry = { date: number; action: GlanceableWidgetAction; approvalKey: string | null }; /** * The pressed entry a user-interaction event maps to, or null when the event @@ -79,7 +83,11 @@ function pressedEntryForEvent( // A timeline read always carries the entry's date; the fallback keeps a // hand-built entry from a caller out of the key, and no production read // reaches it. - return { date: entry.date?.getTime() ?? 0, action }; + return { + date: entry.date?.getTime() ?? 0, + action, + approvalKey: entry.props?.pendingApprovalKey ?? null, + }; } } return null; @@ -109,9 +117,10 @@ function republishWidgetProps(): void { return; } const translate = (key: string): string => i18n.t(key); - const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate)); + const homeData = getHomeWidgetDataForSnapshot(snapshot); + const props = toWidgetProps(buildGlanceableViewProps(snapshot, {}, translate, homeData)); ActiveAgentsWidget.updateSnapshot(props); - const frames = widgetTimelineFrames(snapshot, props, translate); + const frames = widgetTimelineFrames(snapshot, props, translate, homeData); if (frames !== null) { ActiveAgentsWidget.updateTimeline(frames); } @@ -144,7 +153,8 @@ async function openFromPress(uri: string): Promise { * question the widget must never invent an answer to) opens the agents list — * the same destination the Android twin opens (`glanceable-android/register.ts`). */ -async function performWidgetAction(action: GlanceableWidgetAction): Promise { +async function performWidgetAction(press: PressedEntry): Promise { + const { action } = press; if (action === 'new-agent') { await openFromPress(LAUNCHER_NEW_AGENT_URL); return; @@ -157,7 +167,7 @@ async function performWidgetAction(action: GlanceableWidgetAction): Promise carried.date === press.date && carried.action === press.action + carried => + carried.date === press.date && + carried.action === press.action && + carried.approvalKey === press.approvalKey ) ) { carriedPresses.push(press); @@ -267,13 +280,17 @@ function takeResweepRequest(): boolean { */ async function sweepPendingActions(): Promise { const timeline = await ActiveAgentsWidget.getTimeline(); - const pending = new Map(); + const pending = new Set(); const pressed: PressedEntry[] = []; for (const [index, entry] of timeline.entries()) { const action = pendingActionOf(entry.props); if (action !== null) { - pending.set(index, action); - pressed.push({ date: entry.date.getTime(), action }); + pending.add(index); + pressed.push({ + date: entry.date.getTime(), + action, + approvalKey: entry.props.pendingApprovalKey ?? null, + }); } } const carried = takeCarriedPresses().filter( @@ -286,11 +303,11 @@ async function sweepPendingActions(): Promise { ) ); } - for (const action of [...pending.values(), ...carried.map(press => press.action)]) { + for (const press of [...pressed, ...carried]) { // Sequential by design: each action can republish the surface, and two // overlapping republishes could push the props out of order. // eslint-disable-next-line no-await-in-loop -- one answer on screen at a time - await performWidgetAction(action); + await performWidgetAction(press); } } diff --git a/apps/mobile/src/glanceable-ios/widget-families.test.ts b/apps/mobile/src/glanceable-ios/widget-families.test.ts index 0617969873..56c5def7a2 100644 --- a/apps/mobile/src/glanceable-ios/widget-families.test.ts +++ b/apps/mobile/src/glanceable-ios/widget-families.test.ts @@ -4,8 +4,6 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { glanceableLayoutCopy } from './layout-copy'; - const MOBILE_DIR = join(__dirname, '..', '..'); const read = (...segments: string[]) => readFileSync(join(...segments), 'utf8'); @@ -23,40 +21,4 @@ describe('ActiveAgentsWidget families', () => { expect(entry?.[1]).toContain("'systemLarge'"); }); - - it('prefers the delayed copy over the newest result while the counts are stale', () => { - const layout = read(__dirname, 'active-agents-widget.tsx'); - const footer = layout.slice( - layout.indexOf('const newestResultBody'), - layout.indexOf('const footerBody') - ); - - // `statusLine` is tested first, so a stale frame draws "Can't update now" - // and never a relative time claiming freshness the snapshot has lost. - expect(footer).toContain('if (statusLine !== null)'); - expect(footer.indexOf('statusLine')).toBeLessThan(footer.indexOf('newestResultKind')); - }); - - it('bakes the newestResult copy slot into the layout map', () => { - expect(read(__dirname, 'layout-copy.ts')).toContain("i18n.t('glanceable.newestResult')"); - expect(glanceableLayoutCopy()).toHaveProperty('newestResult'); - }); - - it('decodes raw app-group count rows instead of mapping them blindly', () => { - const layout = read(__dirname, 'active-agents-widget.tsx'); - - // A stale timeline written by another app version can carry a non-array or - // a null row; mapping it threw and expo-widgets drew the red error box in - // every family. The rows are decoded, and an unknown kind falls back to the - // neutral idle mark instead of reading `.icon` off `undefined`. - expect(layout).toContain('Array.isArray(props.countLines) ? props.countLines : []'); - expect(layout).toContain('const glyphFor = (kind: string | null | undefined)'); - expect(layout).toContain('Object.hasOwn(GLYPH, kind)'); - expect(layout).toContain('return GLYPH.idle;'); - // No raw lookup may reach `.icon`/`.color`: an unknown kind there is a - // runtime `undefined` and the whole widget view falls into its red box. - expect(layout).not.toContain('GLYPH[primaryKind'); - expect(layout).not.toContain('GLYPH[newestResultKind'); - expect(layout).not.toContain('GLYPH[line.kind'); - }); }); diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index 9308aca821..aa68515cfc 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -135,7 +135,8 @@ "updated": "Bygewerk", "working": "Besig", "yourOwnRepositories": "Jou eie bewaarplekke", - "dismissed": "Verwerp" + "dismissed": "Verwerp", + "agent": "Agent" }, "language": { "restarting": "Herbegin om die taal toe te pas", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Skakel regstreekse aktiwiteite in die instellings aan om aktiewe agente op die sluitskerm te sien.", "openSession": "Oop", "approveFailed": "Kon nie goedkeur nie. Tik op $t(common.approve) om weer te probeer.", - "newestResult": "Nuutste resultaat", - "scheduledWakes": "word wakker {{time}}" + "scheduledWakes": "word wakker {{time}}", + "checked": "Gekontroleer", + "lastKnown": "Laas bekend", + "awaitingUpdate": "Wag op opdatering", + "nextRun": "Volgende uitvoering", + "waitingForYou": "Wag vir jou", + "nextScheduled": "Volgende geskeduleerde", + "answerNeeded": "Antwoord nodig", + "waitingToRetry": "Wag om weer te probeer" }, "transcriptionModel": { "title": "Transkripsiemodel", @@ -3357,7 +3365,6 @@ "template": "Sjabloon", "namePlaceholder": "explain", "templatePlaceholder": "Verduidelik $ARGUMENTS", - "agent": "Agent", "subtask": "Hardloop as subtaak", "edit": "Wysig skuinsstreep opdrag", "saveFailed": "Kon nie opdrag stoor nie", diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index ef8cf1f451..5024884ecd 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -135,7 +135,8 @@ "updated": "ተዘምኗል", "working": "በመሥራት ላይ", "yourOwnRepositories": "የራስህ ማከማቻዎች", - "dismissed": "ተወግዷል" + "dismissed": "ተወግዷል", + "agent": "ወኪል" }, "language": { "restarting": "ቋንቋውን ለመቀየር እንደገና በመጀመር ላይ", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ንቁ ወኪሎችን በተቆለፈው ማያ ገጽ ላይ ለማየት በቅንብሮች ውስጥ የቀጥታ እንቅስቃሴዎችን አንቃ።", "openSession": "ክፈት", "approveFailed": "ማጽደቅ አልተቻለም። እንደገና ለመሞከር $t(common.approve) ይንኩ።", - "newestResult": "አዲሱ ውጤት", - "scheduledWakes": "{{time}} ይነቃል" + "scheduledWakes": "{{time}} ይነቃል", + "checked": "የተረጋገጠ", + "lastKnown": "ለመጨረሻ ጊዜ የታወቀ", + "awaitingUpdate": "ዝማኔ በመጠበቅ ላይ", + "nextRun": "ቀጣይ አፈጻጸም", + "waitingForYou": "እርስዎን በመጠበቅ ላይ", + "nextScheduled": "ቀጣይ የተያዙ", + "answerNeeded": "ምላሽ ይፈልጋል", + "waitingToRetry": "ዳግም ለመሞከር በመጠበቅ ላይ" }, "transcriptionModel": { "title": "የድምጽ ወደ ጽሑፍ ሞዴል", @@ -3357,7 +3365,6 @@ "template": "አብነት", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ያብራሩ", - "agent": "ወኪል", "subtask": "እንደ ንዑስ ተግባር ያሂዱ", "edit": "የጭረት ትእዛዝን ያርትዑ", "saveFailed": "ትዕዛዝ ማስቀመጥ አልተቻለም", diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index c4d1184ce0..1dff0ae9e7 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -135,7 +135,8 @@ "updated": "تم التحديث", "working": "جارٍ العمل", "yourOwnRepositories": "مستودعاتك الخاصة", - "dismissed": "مستبعدة" + "dismissed": "مستبعدة", + "agent": "عامل" }, "language": { "restarting": "جارٍ إعادة التشغيل لتطبيق اللغة", @@ -3319,8 +3320,15 @@ "activityKitDisabledBody": "فعّل الأنشطة المباشرة في الإعدادات لعرض الوكلاء النشطين على شاشة القفل.", "openSession": "افتح", "approveFailed": "تعذّر الموافقة. اضغط على $t(common.approve) للمحاولة مرة أخرى.", - "newestResult": "أحدث نتيجة", - "scheduledWakes": "يستيقظ {{time}}" + "scheduledWakes": "يستيقظ {{time}}", + "checked": "آخر تحقق", + "lastKnown": "آخر حالة معروفة", + "awaitingUpdate": "بانتظار تحديث", + "nextRun": "التشغيل التالي", + "waitingForYou": "بانتظارك", + "nextScheduled": "المواعيد المجدولة القادمة", + "answerNeeded": "بانتظار إجابتك", + "waitingToRetry": "بانتظار إعادة المحاولة" }, "transcriptionModel": { "title": "نموذج التفريغ الصوتي", @@ -3521,7 +3529,6 @@ "template": "نموذج", "namePlaceholder": "explain", "templatePlaceholder": "شرح $ARGUMENTS", - "agent": "عامل", "subtask": "تشغيل كمهمة فرعية", "edit": "تحرير أمر الشرطة المائلة", "saveFailed": "تعذر حفظ الأمر", diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 56bc75bc05..7e5e0d4667 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -135,7 +135,8 @@ "updated": "Yenilənib", "working": "İşləyir", "yourOwnRepositories": "Öz repozitoriyaların", - "dismissed": "Rədd edilib" + "dismissed": "Rədd edilib", + "agent": "Agent" }, "language": { "restarting": "Dil dəyişikliyini tətbiq etmək üçün yenidən başladılır", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Kilid ekranında aktiv agentləri görmək üçün parametrlərdə canlı fəaliyyətləri aktivləşdir.", "openSession": "Aç", "approveFailed": "Təsdiqləmək mümkün olmadı. Yenidən cəhd etmək üçün $t(common.approve) üzərinə toxun.", - "newestResult": "Ən yeni nəticə", - "scheduledWakes": "{{time}} oyanır" + "scheduledWakes": "{{time}} oyanır", + "checked": "Yoxlanılıb", + "lastKnown": "Son məlum vəziyyət", + "awaitingUpdate": "Yenilənmə gözlənilir", + "nextRun": "Növbəti işə düşmə", + "waitingForYou": "Sizi gözləyir", + "nextScheduled": "Növbəti planlaşdırılanlar", + "answerNeeded": "Cavab gözləyir", + "waitingToRetry": "Təkrar cəhd gözlənilir" }, "transcriptionModel": { "title": "Səsin mətnə çevrilməsi modeli", @@ -3357,7 +3365,6 @@ "template": "Şablon", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS-i izah edin", - "agent": "Agent", "subtask": "Alt tapşırıq kimi işləyin", "edit": "Slash əmrini redaktə edin", "saveFailed": "Komandanı saxlamaq mümkün olmadı", diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index a6cfcdbce7..4dc123293a 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -135,7 +135,8 @@ "updated": "Абноўлена", "working": "Працуе", "yourOwnRepositories": "Твае рэпазіторыі", - "dismissed": "Адхілена" + "dismissed": "Адхілена", + "agent": "Агент" }, "language": { "restarting": "Перазапуск для змены мовы", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Уключы дзеянні ў рэальным часе ў наладах, каб бачыць актыўных агентаў на экране блакіроўкі.", "openSession": "Адкрыць", "approveFailed": "Не ўдалося ўхваліць. Націсніце $t(common.approve), каб паспрабаваць яшчэ раз.", - "newestResult": "Найноўшы вынік", - "scheduledWakes": "прачынаецца {{time}}" + "scheduledWakes": "прачынаецца {{time}}", + "checked": "Праверана", + "lastKnown": "Апошні вядомы стан", + "awaitingUpdate": "Чакае абнаўлення", + "nextRun": "Наступны запуск", + "waitingForYou": "Чакае вас", + "nextScheduled": "Наступныя запланаваныя", + "answerNeeded": "Патрэбны адказ", + "waitingToRetry": "Чакае паўторнай спробы" }, "transcriptionModel": { "title": "Мадэль распазнавання маўлення", @@ -3439,7 +3447,6 @@ "template": "Шаблон", "namePlaceholder": "explain", "templatePlaceholder": "Растлумачце $ARGUMENTS", - "agent": "Агент", "subtask": "Запуск як падзадача", "edit": "Рэдагаваць каманду касой рысы", "saveFailed": "Не ўдалося захаваць каманду", diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index d608fc523b..3164fd5d8d 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -135,7 +135,8 @@ "updated": "Актуализирано", "working": "В процес", "yourOwnRepositories": "Личните ти хранилища", - "dismissed": "Отхвърлено" + "dismissed": "Отхвърлено", + "agent": "агент" }, "language": { "restarting": "Рестартиране за смяна на езика", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Включи дейностите на живо в настройките, за да виждаш активните агенти на заключения екран.", "openSession": "Отвори", "approveFailed": "Не успяхме да одобрим. Докосни $t(common.approve), за да опиташ отново.", - "newestResult": "Най-нов резултат", - "scheduledWakes": "се събужда {{time}}" + "scheduledWakes": "се събужда {{time}}", + "checked": "Проверено", + "lastKnown": "Последно известно", + "awaitingUpdate": "Изчаква актуализация", + "nextRun": "Следващо изпълнение", + "waitingForYou": "Очаква ви", + "nextScheduled": "Следващи планирани", + "answerNeeded": "Нужен е отговор", + "waitingToRetry": "Изчаква повторен опит" }, "transcriptionModel": { "title": "Модел за транскрипция", @@ -3357,7 +3365,6 @@ "template": "Шаблон", "namePlaceholder": "explain", "templatePlaceholder": "Обяснете $ARGUMENTS", - "agent": "агент", "subtask": "Изпълнение като подзадача", "edit": "Команда за редактиране на наклонена черта", "saveFailed": "Командата не можа да бъде запазена", diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index 3ae76861c9..bd41b25ab6 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -135,7 +135,8 @@ "updated": "আপডেট হয়েছে", "working": "কাজ চলছে", "yourOwnRepositories": "আপনার নিজের রিপোজিটরি", - "dismissed": "খারিজ" + "dismissed": "খারিজ", + "agent": "এজেন্ট" }, "language": { "restarting": "নির্বাচিত ভাষা চালু করতে অ্যাপ আবার চালু হচ্ছে", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "লক স্ক্রিনে সক্রিয় এজেন্ট দেখতে সেটিংসে লাইভ অ্যাক্টিভিটি চালু করুন।", "openSession": "খুলুন", "approveFailed": "অনুমোদন করা যায়নি। আবার চেষ্টা করতে $t(common.approve) স্পর্শ করুন।", - "newestResult": "সর্বশেষ ফলাফল", - "scheduledWakes": "{{time}} জাগবে" + "scheduledWakes": "{{time}} জাগবে", + "checked": "যাচাই করা হয়েছে", + "lastKnown": "সর্বশেষ জানা", + "awaitingUpdate": "আপডেটের অপেক্ষায়", + "nextRun": "পরবর্তী রান", + "waitingForYou": "আপনার অপেক্ষায়", + "nextScheduled": "পরবর্তী নির্ধারিত", + "answerNeeded": "উত্তর প্রয়োজন", + "waitingToRetry": "আবার চেষ্টার অপেক্ষায়" }, "transcriptionModel": { "title": "কথা থেকে লেখার মডেল", @@ -3357,7 +3365,6 @@ "template": "টেমপ্লেট", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ব্যাখ্যা করুন", - "agent": "এজেন্ট", "subtask": "সাবটাস্ক হিসাবে চালান", "edit": "স্ল্যাশ কমান্ড সম্পাদনা করুন", "saveFailed": "কমান্ড সংরক্ষণ করা যায়নি", diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 91e660b81e..24684a75c6 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -135,7 +135,8 @@ "updated": "Ažurirano", "working": "U toku", "yourOwnRepositories": "Tvoji repozitoriji", - "dismissed": "Odbačeno" + "dismissed": "Odbačeno", + "agent": "Agent" }, "language": { "restarting": "Ponovno pokretanje radi primjene jezika", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama da vidiš aktivne agente na zaključanom ekranu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspjelo. Dodirni $t(common.approve) da pokušaš ponovo.", - "newestResult": "Najnoviji rezultat", - "scheduledWakes": "budi se {{time}}" + "scheduledWakes": "budi se {{time}}", + "checked": "Provjereno", + "lastKnown": "Posljednje poznato", + "awaitingUpdate": "Čeka ažuriranje", + "nextRun": "Sljedeće pokretanje", + "waitingForYou": "Čeka vas", + "nextScheduled": "Sljedeće zakazano", + "answerNeeded": "Potreban odgovor", + "waitingToRetry": "Čeka ponovni pokušaj" }, "transcriptionModel": { "title": "Model za transkripciju", @@ -3398,7 +3406,6 @@ "template": "Predložak", "namePlaceholder": "explain", "templatePlaceholder": "Objasnite $ARGUMENTS", - "agent": "Agent", "subtask": "Pokreni kao podzadatak", "edit": "Uredi naredbu kose crte", "saveFailed": "Nije moguće sačuvati naredbu", diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index e3a4988a74..b7438b1dae 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -135,7 +135,8 @@ "updated": "Actualitzat", "working": "En curs", "yourOwnRepositories": "Els teus repositoris", - "dismissed": "Descartat" + "dismissed": "Descartat", + "agent": "Agent" }, "language": { "restarting": "S'està reiniciant per aplicar l'idioma", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Activa les activitats en directe a la configuració del dispositiu per veure els agents actius a la pantalla de bloqueig.", "openSession": "Obre", "approveFailed": "No s'ha pogut aprovar. Toca $t(common.approve) per tornar-ho a provar.", - "newestResult": "Resultat més recent", - "scheduledWakes": "es desperta {{time}}" + "scheduledWakes": "es desperta {{time}}", + "checked": "Comprovat", + "lastKnown": "Últim estat conegut", + "awaitingUpdate": "Pendent d’actualització", + "nextRun": "Propera execució", + "waitingForYou": "Esperant-te", + "nextScheduled": "Properes programades", + "answerNeeded": "Pendent de resposta", + "waitingToRetry": "Pendent de reintent" }, "transcriptionModel": { "title": "Model de transcripció", @@ -3398,7 +3406,6 @@ "template": "Plantilla", "namePlaceholder": "explain", "templatePlaceholder": "Explica $ARGUMENTS", - "agent": "Agent", "subtask": "Executar com a subtasca", "edit": "Edita l'ordre de barra inclinada", "saveFailed": "No s'ha pogut desar l'ordre", diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 816d2661a2..c0b67bc392 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -135,7 +135,8 @@ "updated": "نوێکراوەتەوە", "working": "لە کاردا", "yourOwnRepositories": "کۆگاکانی خۆت", - "dismissed": "پشتگوێخراو" + "dismissed": "پشتگوێخراو", + "agent": "بریکار" }, "language": { "restarting": "بۆ جێبەجێکردنی گۆڕینی زمان، دووبارە دەست پێ دەکاتەوە", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "بۆ بینینی ئەجێنتە چالاکەکان لە شاشەی قوفڵدا، چالاکییە ڕاستەوخۆکان لە ڕێکخستنەکاندا چالاک بکە.", "openSession": "بیکەرەوە", "approveFailed": "نەتوانرا پەسند بکرێت. بۆ دووبارە هەوڵدانەوە، لە $t(common.approve) بدە.", - "newestResult": "نوێترین ئەنجام", - "scheduledWakes": "{{time}} خەبەر دەبێت" + "scheduledWakes": "{{time}} خەبەر دەبێت", + "checked": "پشکنراو", + "lastKnown": "دوایین دۆخی زانراو", + "awaitingUpdate": "چاوەڕێی نوێکردنەوە", + "nextRun": "جێبەجێکردنی داهاتوو", + "waitingForYou": "چاوەڕێی تۆ", + "nextScheduled": "خشتەکراوەکانی داهاتوو", + "answerNeeded": "چاوەڕێی وەڵام", + "waitingToRetry": "چاوەڕێی هەوڵدانەوە" }, "transcriptionModel": { "title": "مۆدێلی گۆڕینی دەنگ بۆ دەق", @@ -3357,7 +3365,6 @@ "template": "قاڵب", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ڕوون بکەرەوە", - "agent": "بریکار", "subtask": "وەکو ئەرکی لاوەکی کارپێبکە", "edit": "فەرمانی دەستکاریکردنی slash", "saveFailed": "نەتوانرا فەرمانەکە پاشەکەوت بکرێت", diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 7475e2c9c7..5dd423d5be 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -135,7 +135,8 @@ "updated": "Aktualizováno", "working": "Pracuje", "yourOwnRepositories": "Tvoje vlastní repozitáře", - "dismissed": "Zamítnuto" + "dismissed": "Zamítnuto", + "agent": "Činidlo" }, "language": { "restarting": "Aplikace se restartuje kvůli změně jazyka", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "V nastavení zapni živé aktivity, aby se aktivní agenti zobrazovali na zamknuté obrazovce.", "openSession": "Otevřít", "approveFailed": "Schválení se nepodařilo. Klepnutím na $t(common.approve) to zkuste znovu.", - "newestResult": "Nejnovější výsledek", - "scheduledWakes": "probudí se {{time}}" + "scheduledWakes": "probudí se {{time}}", + "checked": "Ověřeno", + "lastKnown": "Poslední známý stav", + "awaitingUpdate": "Čeká na aktualizaci", + "nextRun": "Příští spuštění", + "waitingForYou": "Čeká na vás", + "nextScheduled": "Další naplánované", + "answerNeeded": "Čeká na odpověď", + "waitingToRetry": "Čeká na další pokus" }, "transcriptionModel": { "title": "Model přepisu", @@ -3439,7 +3447,6 @@ "template": "Šablona", "namePlaceholder": "explain", "templatePlaceholder": "Vysvětlete $ARGUMENTS", - "agent": "Činidlo", "subtask": "Spustit jako dílčí úkol", "edit": "Příkaz upravit lomítko", "saveFailed": "Příkaz se nepodařilo uložit", diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index e0d85e71a4..d69943fa36 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -135,7 +135,8 @@ "updated": "Diweddarwyd", "working": "Yn gweithio", "yourOwnRepositories": "Dy ystorfeydd dy hun", - "dismissed": "Wedi'i ddiystyru" + "dismissed": "Wedi'i ddiystyru", + "agent": "Asiant" }, "language": { "restarting": "Yn ailgychwyn i newid yr iaith", @@ -3319,8 +3320,15 @@ "activityKitDisabledBody": "Trowch weithgareddau byw ymlaen yn y gosodiadau i weld asiantau gweithredol ar y sgrin glo.", "openSession": "Agor", "approveFailed": "Methu cymeradwyo. Tapiwch $t(common.approve) i geisio eto.", - "newestResult": "Canlyniad diweddaraf", - "scheduledWakes": "yn deffro {{time}}" + "scheduledWakes": "yn deffro {{time}}", + "checked": "Gwiriwyd", + "lastKnown": "Hysbys diwethaf", + "awaitingUpdate": "Yn aros am ddiweddariad", + "nextRun": "Rhediad nesaf", + "waitingForYou": "Yn aros amdanoch", + "nextScheduled": "Nesaf ar yr amserlen", + "answerNeeded": "Angen ateb", + "waitingToRetry": "Yn aros i ailgeisio" }, "transcriptionModel": { "title": "Model trawsgrifio", @@ -3521,7 +3529,6 @@ "template": "Templed", "namePlaceholder": "explain", "templatePlaceholder": "Eglurwch $ARGUMENTS", - "agent": "Asiant", "subtask": "Rhedeg fel is-dasg", "edit": "Golygu gorchymyn slaes", "saveFailed": "Ni fu modd cadw'r gorchymyn", diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 801bbe8e51..d70691e32f 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -135,7 +135,8 @@ "updated": "Opdateret", "working": "I gang", "yourOwnRepositories": "Dine egne kodelagre", - "dismissed": "Afvist" + "dismissed": "Afvist", + "agent": "Agent" }, "language": { "restarting": "Genstarter for at skifte sprog", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Slå liveaktiviteter til i Indstillinger for at se aktive agenter på låseskærmen.", "openSession": "Åbn", "approveFailed": "Kunne ikke godkende. Tryk på $t(common.approve) for at prøve igen.", - "newestResult": "Nyeste resultat", - "scheduledWakes": "vågner {{time}}" + "scheduledWakes": "vågner {{time}}", + "checked": "Kontrolleret", + "lastKnown": "Sidst kendt", + "awaitingUpdate": "Afventer opdatering", + "nextRun": "Næste kørsel", + "waitingForYou": "Afventer dig", + "nextScheduled": "Næste planlagte", + "answerNeeded": "Afventer svar", + "waitingToRetry": "Afventer nyt forsøg" }, "transcriptionModel": { "title": "Transskriptionsmodel", @@ -3357,7 +3365,6 @@ "template": "Skabelon", "namePlaceholder": "explain", "templatePlaceholder": "Forklar $ARGUMENTS", - "agent": "Agent", "subtask": "Kør som underopgave", "edit": "Rediger skråstreg kommando", "saveFailed": "Kommandoen kunne ikke gemmes", diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index eed5f206b2..f076f18824 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -135,7 +135,8 @@ "updated": "Aktualisiert", "working": "In Bearbeitung", "yourOwnRepositories": "Deine eigenen Code-Repositories", - "dismissed": "Verworfen" + "dismissed": "Verworfen", + "agent": "Agent" }, "language": { "restarting": "Neustart zum Übernehmen der Sprache", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Aktiviere Live-Aktivitäten in den Einstellungen, um aktive Agenten auf dem Sperrbildschirm zu sehen.", "openSession": "Öffnen", "approveFailed": "Genehmigen fehlgeschlagen. Tippe auf $t(common.approve), um es erneut zu versuchen.", - "newestResult": "Neuestes Ergebnis", - "scheduledWakes": "wacht {{time}} auf" + "scheduledWakes": "wacht {{time}} auf", + "checked": "Geprüft", + "lastKnown": "Zuletzt bekannt", + "awaitingUpdate": "Aktualisierung ausstehend", + "nextRun": "Nächste Ausführung", + "waitingForYou": "Wartet auf dich", + "nextScheduled": "Als Nächstes geplant", + "answerNeeded": "Antwort erforderlich", + "waitingToRetry": "Wartet auf erneuten Versuch" }, "transcriptionModel": { "title": "Transkriptionsmodell", @@ -3357,7 +3365,6 @@ "template": "Vorlage", "namePlaceholder": "explain", "templatePlaceholder": "Erklären Sie $ARGUMENTS", - "agent": "Agent", "subtask": "Als Unteraufgabe ausführen", "edit": "Slash-Befehl bearbeiten", "saveFailed": "Der Befehl konnte nicht gespeichert werden", diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 788f71f50a..63ec672107 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -135,7 +135,8 @@ "updated": "Ενημερώθηκε", "working": "Σε εξέλιξη", "yourOwnRepositories": "Τα δικά σου αποθετήρια", - "dismissed": "Απορρίφθηκε" + "dismissed": "Απορρίφθηκε", + "agent": "Μέσο" }, "language": { "restarting": "Επανεκκίνηση για εφαρμογή της νέας γλώσσας", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Ενεργοποίησε τις ζωντανές δραστηριότητες στις ρυθμίσεις για να βλέπεις τους ενεργούς πράκτορες στην οθόνη κλειδώματος.", "openSession": "Άνοιξε", "approveFailed": "Δεν ήταν δυνατή η έγκριση. Πάτησε $t(common.approve) για να δοκιμάσεις ξανά.", - "newestResult": "Νεότερο αποτέλεσμα", - "scheduledWakes": "ξυπνάει {{time}}" + "scheduledWakes": "ξυπνάει {{time}}", + "checked": "Ελέγχθηκε", + "lastKnown": "Τελευταία γνωστή κατάσταση", + "awaitingUpdate": "Αναμονή ενημέρωσης", + "nextRun": "Επόμενη εκτέλεση", + "waitingForYou": "Αναμονή για εσάς", + "nextScheduled": "Επόμενα προγραμματισμένα", + "answerNeeded": "Αναμονή απάντησης", + "waitingToRetry": "Αναμονή νέας προσπάθειας" }, "transcriptionModel": { "title": "Μοντέλο απομαγνητοφώνησης", @@ -3357,7 +3365,6 @@ "template": "Περίγραμμα", "namePlaceholder": "explain", "templatePlaceholder": "Εξηγήστε τα $ARGUMENTS", - "agent": "Μέσο", "subtask": "Εκτέλεση ως δευτερεύουσα εργασία", "edit": "Επεξεργασία εντολής κάθετο", "saveFailed": "Δεν ήταν δυνατή η αποθήκευση της εντολής", diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index e2664a596d..ceabab811e 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -91,6 +91,7 @@ "couldnTLoadMore": "Couldn't load more", "deviceSessions": "Device sessions", "disabled": "Disabled", + "agent": "Agent", "email": "Email", "ends": "Ends {{date}}", "failed": "Failed", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Turn on Live Activities in Settings to see Active Agents on the Lock Screen.", "openSession": "Open", "approveFailed": "Couldn't approve. Tap $t(common.approve) to try again.", - "newestResult": "Newest result", - "scheduledWakes": "wakes {{time}}" + "scheduledWakes": "wakes {{time}}", + "checked": "Checked", + "lastKnown": "Last known", + "awaitingUpdate": "Awaiting update", + "nextRun": "Next run", + "waitingForYou": "Waiting for you", + "nextScheduled": "Next scheduled", + "answerNeeded": "Answer needed", + "waitingToRetry": "Waiting to retry" }, "transcriptionModel": { "title": "Transcription model", @@ -3357,7 +3365,6 @@ "template": "Template", "namePlaceholder": "explain", "templatePlaceholder": "Explain $ARGUMENTS", - "agent": "Agent", "subtask": "Run as subtask", "edit": "Edit slash command", "saveFailed": "Couldn't save command", diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 21625114ba..93d307f428 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -135,7 +135,8 @@ "updated": "Actualizado", "working": "Trabajando", "yourOwnRepositories": "Tus propios repositorios", - "dismissed": "Descartado" + "dismissed": "Descartado", + "agent": "Agente" }, "language": { "restarting": "Reiniciando para aplicar el idioma", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Activa las actividades en directo en Ajustes para ver los agentes activos en la pantalla de bloqueo.", "openSession": "Abrir", "approveFailed": "No se pudo aprobar. Toca $t(common.approve) para intentarlo de nuevo.", - "newestResult": "Resultado más reciente", - "scheduledWakes": "se activa {{time}}" + "scheduledWakes": "se activa {{time}}", + "checked": "Comprobado", + "lastKnown": "Último estado conocido", + "awaitingUpdate": "En espera de actualización", + "nextRun": "Próxima ejecución", + "waitingForYou": "En espera de tu respuesta", + "nextScheduled": "Próximas programadas", + "answerNeeded": "En espera de respuesta", + "waitingToRetry": "En espera de reintento" }, "transcriptionModel": { "title": "Modelo de transcripción", @@ -3398,7 +3406,6 @@ "template": "Plantilla", "namePlaceholder": "explain", "templatePlaceholder": "Explique $ARGUMENTS", - "agent": "Agente", "subtask": "Ejecutar como subtarea", "edit": "Editar comando de barra diagonal", "saveFailed": "No se pudo guardar el comando", diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index b18870e49e..259bea39f7 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -135,7 +135,8 @@ "updated": "Uuendatud", "working": "Töös", "yourOwnRepositories": "Sinu enda hoidlad", - "dismissed": "Kõrvale jäetud" + "dismissed": "Kõrvale jäetud", + "agent": "Agent" }, "language": { "restarting": "Keele vahetamiseks taaskäivitatakse rakendus", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Lülita seadetes reaalajas tegevused sisse, et näha lukustuskuval aktiivseid agente.", "openSession": "Ava", "approveFailed": "Kinnitamine ebaõnnestus. Uuesti proovimiseks puuduta $t(common.approve).", - "newestResult": "Uusim tulemus", - "scheduledWakes": "ärkab {{time}}" + "scheduledWakes": "ärkab {{time}}", + "checked": "Kontrollitud", + "lastKnown": "Viimane teadaolev", + "awaitingUpdate": "Ootab uuendust", + "nextRun": "Järgmine käivitus", + "waitingForYou": "Ootab sind", + "nextScheduled": "Järgmisena ajastatud", + "answerNeeded": "Vajab vastust", + "waitingToRetry": "Ootab uut katset" }, "transcriptionModel": { "title": "Kõnetuvastuse mudel", @@ -3357,7 +3365,6 @@ "template": "Mall", "namePlaceholder": "explain", "templatePlaceholder": "Selgitage $ARGUMENTS", - "agent": "Agent", "subtask": "Käivita alamülesandena", "edit": "Muuda kaldkriipsu käsku", "saveFailed": "Käsku ei saanud salvestada", diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index ab80742abb..2f137b8920 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -135,7 +135,8 @@ "updated": "Eguneratuta", "working": "Lanean", "yourOwnRepositories": "Zure biltegiak", - "dismissed": "Baztertuta" + "dismissed": "Baztertuta", + "agent": "Agentea" }, "language": { "restarting": "Hizkuntza aplikatzeko berrabiarazten", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Aktibatu zuzeneko jarduerak ezarpenetan, agente aktiboak blokeo-pantailan ikusteko.", "openSession": "Ireki", "approveFailed": "Ezin izan da onartu. Sakatu $t(common.approve) berriro saiatzeko.", - "newestResult": "Emaitza berriena", - "scheduledWakes": "{{time}} esnatuko da" + "scheduledWakes": "{{time}} esnatuko da", + "checked": "Egiaztatuta", + "lastKnown": "Azken egoera ezaguna", + "awaitingUpdate": "Eguneratzearen zain", + "nextRun": "Hurrengo exekuzioa", + "waitingForYou": "Zure zain", + "nextScheduled": "Hurrengo programatuak", + "answerNeeded": "Zure erantzunaren zain", + "waitingToRetry": "Berriro saiatzeko zain" }, "transcriptionModel": { "title": "Transkripzio-eredua", @@ -3357,7 +3365,6 @@ "template": "Txantiloia", "namePlaceholder": "explain", "templatePlaceholder": "Azaldu $ARGUMENTS", - "agent": "Agentea", "subtask": "Exekutatu azpizeregin gisa", "edit": "Editatu barra komandoa", "saveFailed": "Ezin izan da gorde komandoa", diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index 06f5a8191f..f9c452fec2 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -135,7 +135,8 @@ "updated": "به‌روزشده", "working": "در حال کار", "yourOwnRepositories": "مخازن خودتان", - "dismissed": "نادیده‌گرفته‌شده" + "dismissed": "نادیده‌گرفته‌شده", + "agent": "عامل" }, "language": { "restarting": "در حال راه‌اندازی مجدد برای تغییر زبان", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "برای دیدن عامل‌های فعال در صفحهٔ قفل، فعالیت‌های زنده را در تنظیمات فعال کنید.", "openSession": "باز کن", "approveFailed": "تأیید ممکن نشد. برای تلاش دوباره روی $t(common.approve) ضربه بزنید.", - "newestResult": "جدیدترین نتیجه", - "scheduledWakes": "{{time}} بیدار می‌شود" + "scheduledWakes": "{{time}} بیدار می‌شود", + "checked": "آخرین بررسی", + "lastKnown": "آخرین وضعیت شناخته‌شده", + "awaitingUpdate": "در انتظار به‌روزرسانی", + "nextRun": "اجرای بعدی", + "waitingForYou": "در انتظار شما", + "nextScheduled": "زمانبندی‌های بعدی", + "answerNeeded": "نیازمند پاسخ", + "waitingToRetry": "در انتظار تلاش مجدد" }, "transcriptionModel": { "title": "مدل تبدیل گفتار به متن", @@ -3357,7 +3365,6 @@ "template": "الگو", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS را توضیح دهید", - "agent": "عامل", "subtask": "به عنوان وظیفه فرعی اجرا شود", "edit": "ویرایش دستور اسلش", "saveFailed": "فرمان ذخیره نشد", diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 3cd9bd439b..a00203a2f4 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -135,7 +135,8 @@ "updated": "Päivitetty", "working": "Työn alla", "yourOwnRepositories": "Omat koodivarastosi", - "dismissed": "Sivuutettu" + "dismissed": "Sivuutettu", + "agent": "Agentti" }, "language": { "restarting": "Käynnistetään uudelleen kielen vaihtamiseksi", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Ota live-aktiviteetit käyttöön asetuksissa, niin näet aktiiviset agentit lukitusnäytöllä.", "openSession": "Avaa", "approveFailed": "Hyväksyminen ei onnistunut. Yritä uudelleen napauttamalla $t(common.approve).", - "newestResult": "Uusin tulos", - "scheduledWakes": "herää {{time}}" + "scheduledWakes": "herää {{time}}", + "checked": "Tarkistettu", + "lastKnown": "Viimeisin tiedossa oleva", + "awaitingUpdate": "Odottaa päivitystä", + "nextRun": "Seuraava ajo", + "waitingForYou": "Odottaa sinua", + "nextScheduled": "Seuraavaksi ajastetut", + "answerNeeded": "Odottaa vastausta", + "waitingToRetry": "Odottaa uutta yritystä" }, "transcriptionModel": { "title": "Puheentunnistusmalli", @@ -3357,7 +3365,6 @@ "template": "Malli", "namePlaceholder": "explain", "templatePlaceholder": "Selitä $ARGUMENTS", - "agent": "Agentti", "subtask": "Suorita alitehtävänä", "edit": "Muokkaa vinoviivakomentoa", "saveFailed": "Komentoa ei voitu tallentaa", diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index d55b789001..17e5b3fe39 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -135,7 +135,8 @@ "updated": "Na-update", "working": "Nagtatrabaho", "yourOwnRepositories": "Mga sarili mong repositoryo", - "dismissed": "Binalewala" + "dismissed": "Binalewala", + "agent": "Ahente" }, "language": { "restarting": "Ini-restart para ilapat ang wika", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Paganahin ang mga live na aktibidad sa mga setting para makita ang mga aktibong agent sa naka-lock na screen.", "openSession": "Buksan", "approveFailed": "Hindi maaprubahan. I-tap ang $t(common.approve) para subukang muli.", - "newestResult": "Pinakabagong resulta", - "scheduledWakes": "gigising {{time}}" + "scheduledWakes": "gigising {{time}}", + "checked": "Nakumpirma", + "lastKnown": "Huling nalaman", + "awaitingUpdate": "Naghihintay ng update", + "nextRun": "Susunod na pagpapatakbo", + "waitingForYou": "Hinihintay ka", + "nextScheduled": "Susunod na naka-iskedyul", + "answerNeeded": "Kailangan ng sagot", + "waitingToRetry": "Naghihintay ng muling pagsubok" }, "transcriptionModel": { "title": "Modelo para sa pagsasalin ng boses sa teksto", @@ -3357,7 +3365,6 @@ "template": "Padron", "namePlaceholder": "explain", "templatePlaceholder": "Ipaliwanag ang $ARGUMENTS", - "agent": "Ahente", "subtask": "Patakbuhin bilang subtask", "edit": "I-edit ang slash command", "saveFailed": "Hindi ma-save ang command", diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 73cf768e08..c7e921966b 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -135,7 +135,8 @@ "updated": "Mis à jour", "working": "En cours", "yourOwnRepositories": "Vos propres dépôts", - "dismissed": "Ignoré" + "dismissed": "Ignoré", + "agent": "Agent" }, "language": { "restarting": "Redémarrage pour changer de langue", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Activez les activités en direct dans les paramètres pour voir les agents actifs sur l'écran verrouillé.", "openSession": "Ouvrir", "approveFailed": "Impossible d'approuver. Touchez $t(common.approve) pour réessayer.", - "newestResult": "Dernier résultat", - "scheduledWakes": "se réveille {{time}}" + "scheduledWakes": "se réveille {{time}}", + "checked": "Vérifié", + "lastKnown": "Dernier état connu", + "awaitingUpdate": "En attente de mise à jour", + "nextRun": "Prochaine exécution", + "waitingForYou": "En attente de votre intervention", + "nextScheduled": "Prochaines exécutions", + "answerNeeded": "Réponse requise", + "waitingToRetry": "Nouvelle tentative en attente" }, "transcriptionModel": { "title": "Modèle de transcription", @@ -3398,7 +3406,6 @@ "template": "Modèle", "namePlaceholder": "explain", "templatePlaceholder": "Expliquez $ARGUMENTS", - "agent": "Agent", "subtask": "Exécuter en tant que sous-tâche", "edit": "Modifier la commande barre oblique", "saveFailed": "Impossible d'enregistrer la commande", diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index d0a3b93099..eed441af53 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -135,7 +135,8 @@ "updated": "Nuashonraithe", "working": "Ag obair", "yourOwnRepositories": "Do stóir féin", - "dismissed": "Curtha as an áireamh" + "dismissed": "Curtha as an áireamh", + "agent": "Gníomhaire" }, "language": { "restarting": "Ag atosú chun an teanga a chur i bhfeidhm", @@ -3278,8 +3279,15 @@ "activityKitDisabledBody": "Cumasaigh gníomhaíochtaí beo sna socruithe chun gníomhairí gníomhacha a fheiceáil ar an scáileán glasála.", "openSession": "Oscail", "approveFailed": "Níorbh fhéidir ceadú. Tapáil $t(common.approve) chun triail eile a bhaint as.", - "newestResult": "An toradh is nuaí", - "scheduledWakes": "dúisíonn {{time}}" + "scheduledWakes": "dúisíonn {{time}}", + "checked": "Seiceáilte", + "lastKnown": "Stádas deireanach ar eolas", + "awaitingUpdate": "Ag fanacht le nuashonrú", + "nextRun": "An chéad rith eile", + "waitingForYou": "Ag fanacht leat", + "nextScheduled": "Sceidealaithe amach romhainn", + "answerNeeded": "Freagra de dhíth", + "waitingToRetry": "Ag fanacht le hatriail" }, "transcriptionModel": { "title": "Samhail tras-scríobh", @@ -3480,7 +3488,6 @@ "template": "Teimpléad", "namePlaceholder": "explain", "templatePlaceholder": "Mínigh $ARGUMENTS", - "agent": "Gníomhaire", "subtask": "Rith mar fhothasc", "edit": "Cuir ordú slais in eagar", "saveFailed": "Níorbh fhéidir an t-ordú a shábháil", diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index 85866005c3..d53e53fe11 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -135,7 +135,8 @@ "updated": "Actualizado", "working": "Traballando", "yourOwnRepositories": "Os teus propios repositorios", - "dismissed": "Descartado" + "dismissed": "Descartado", + "agent": "Axente" }, "language": { "restarting": "Reiniciando para aplicar o idioma", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Activa as actividades en directo nos axustes para ver os axentes activos na pantalla de bloqueo.", "openSession": "Abrir", "approveFailed": "Non se puido aprobar. Toca $t(common.approve) para tentalo de novo.", - "newestResult": "Resultado máis recente", - "scheduledWakes": "esperta {{time}}" + "scheduledWakes": "esperta {{time}}", + "checked": "Comprobado", + "lastKnown": "Último estado coñecido", + "awaitingUpdate": "Agardando actualización", + "nextRun": "Próxima execución", + "waitingForYou": "Agardando por ti", + "nextScheduled": "Próximas tarefas programadas", + "answerNeeded": "Agardando resposta", + "waitingToRetry": "Agardando para reintentar" }, "transcriptionModel": { "title": "Modelo de transcrición", @@ -3357,7 +3365,6 @@ "template": "Modelo", "namePlaceholder": "explain", "templatePlaceholder": "Explica $ARGUMENTS", - "agent": "Axente", "subtask": "Executar como subtarefa", "edit": "Comando de edición de barra", "saveFailed": "Non se puido gardar o comando", diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 7e88b8650e..0ecec39903 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -135,7 +135,8 @@ "updated": "અપડેટ થયું", "working": "કામ ચાલુ છે", "yourOwnRepositories": "તમારી પોતાની રિપોઝિટરીઓ", - "dismissed": "ખારિજ કરેલું" + "dismissed": "ખારિજ કરેલું", + "agent": "એજન્ટ" }, "language": { "restarting": "ભાષા લાગુ કરવા ઍપ ફરી શરૂ થઈ રહી છે", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "લૉક સ્ક્રીન પર સક્રિય એજન્ટો જોવા માટે સેટિંગ્સમાં લાઇવ પ્રવૃત્તિઓ ચાલુ કરો.", "openSession": "ખોલો", "approveFailed": "મંજૂર કરી શકાયું નહીં. ફરી પ્રયાસ કરવા $t(common.approve) પર ટૅપ કરો.", - "newestResult": "સૌથી નવું પરિણામ", - "scheduledWakes": "{{time}} જાગશે" + "scheduledWakes": "{{time}} જાગશે", + "checked": "ચકાસ્યું", + "lastKnown": "છેલ્લે જાણીતું", + "awaitingUpdate": "અપડેટની રાહમાં", + "nextRun": "આગામી રન", + "waitingForYou": "તમારી રાહમાં", + "nextScheduled": "આગામી નિર્ધારિત", + "answerNeeded": "જવાબ જરૂરી", + "waitingToRetry": "ફરી પ્રયાસની રાહમાં" }, "transcriptionModel": { "title": "અવાજને લખાણમાં ફેરવવાનું મોડેલ", @@ -3357,7 +3365,6 @@ "template": "ઢાંચો", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS સમજાવો", - "agent": "એજન્ટ", "subtask": "સબટાસ્ક તરીકે ચલાવો", "edit": "સ્લેશ આદેશ સંપાદિત કરો", "saveFailed": "આદેશ સાચવી શકાયો નથી", diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index fad87e5be2..b710619c52 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -135,7 +135,8 @@ "updated": "An sabunta", "working": "Ana aiki", "yourOwnRepositories": "Ma'ajiyoyinka", - "dismissed": "An yi watsi da shi" + "dismissed": "An yi watsi da shi", + "agent": "Wakili" }, "language": { "restarting": "Ana sake kunnawa don amfani da harshen da aka zaɓa", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Kunna ayyukan kai tsaye a saituna don ganin wakilan da ke aiki a allon kulle.", "openSession": "Buɗe", "approveFailed": "An kasa amincewa. Danna $t(common.approve) don sake gwadawa.", - "newestResult": "Sabon sakamako", - "scheduledWakes": "zai farka {{time}}" + "scheduledWakes": "zai farka {{time}}", + "checked": "An duba", + "lastKnown": "Na ƙarshe da aka sani", + "awaitingUpdate": "Ana jiran sabuntawa", + "nextRun": "Aiki na gaba", + "waitingForYou": "Ana jiran ku", + "nextScheduled": "Na gaba da aka tsara", + "answerNeeded": "Ana buƙatar amsa", + "waitingToRetry": "Ana jiran sake gwadawa" }, "transcriptionModel": { "title": "Samfurin mayar da magana zuwa rubutu", @@ -3357,7 +3365,6 @@ "template": "Samfura", "namePlaceholder": "explain", "templatePlaceholder": "Yi bayani $ARGUMENTS", - "agent": "Wakili", "subtask": "Gudu azaman ƙaramin aiki", "edit": "Gyara umarnin slash", "saveFailed": "An kasa ajiye umarni", diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 6587b511d7..b34ecdd8d4 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -135,7 +135,8 @@ "updated": "עודכן", "working": "בפעולה", "yourOwnRepositories": "המאגרים שלך", - "dismissed": "סומן להתעלמות" + "dismissed": "סומן להתעלמות", + "agent": "סוֹכֵן" }, "language": { "restarting": "האפליקציה מופעלת מחדש לשינוי השפה", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "הפעל פעילויות בזמן אמת בהגדרות כדי לראות סוכנים פעילים במסך הנעילה.", "openSession": "פתח", "approveFailed": "לא ניתן היה לאשר. הקש על $t(common.approve) כדי לנסות שוב.", - "newestResult": "התוצאה החדשה ביותר", - "scheduledWakes": "מתעורר {{time}}" + "scheduledWakes": "מתעורר {{time}}", + "checked": "נבדק", + "lastKnown": "מצב אחרון ידוע", + "awaitingUpdate": "ממתין לעדכון", + "nextRun": "ההפעלה הבאה", + "waitingForYou": "ממתין לך", + "nextScheduled": "התזמונים הבאים", + "answerNeeded": "נדרשת תשובה", + "waitingToRetry": "ממתין לניסיון חוזר" }, "transcriptionModel": { "title": "מודל תמלול", @@ -3398,7 +3406,6 @@ "template": "תבנית", "namePlaceholder": "explain", "templatePlaceholder": "הסבר $ARGUMENTS", - "agent": "סוֹכֵן", "subtask": "הפעל כתת-מטלת", "edit": "ערוך פקודת נטוי", "saveFailed": "לא ניתן לשמור את הפקודה", diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 30ec02f5a6..fd7f9cb7d0 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -135,7 +135,8 @@ "updated": "अपडेट किया गया", "working": "काम जारी", "yourOwnRepositories": "आपकी अपनी रिपॉज़िटरी", - "dismissed": "खारिज" + "dismissed": "खारिज", + "agent": "प्रतिनिधि" }, "language": { "restarting": "भाषा बदलने के लिए ऐप फिर से शुरू हो रहा है", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "लॉक स्क्रीन पर सक्रिय एजेंट देखने के लिए सेटिंग में लाइव ऐक्टिविटी चालू करें।", "openSession": "खोलें", "approveFailed": "स्वीकार नहीं किया जा सका। फिर से कोशिश करने के लिए $t(common.approve) पर टैप करें।", - "newestResult": "नवीनतम परिणाम", - "scheduledWakes": "{{time}} जागेगा" + "scheduledWakes": "{{time}} जागेगा", + "checked": "जाँच की गई", + "lastKnown": "अंतिम ज्ञात", + "awaitingUpdate": "अपडेट का इंतज़ार", + "nextRun": "अगला रन", + "waitingForYou": "आपका इंतज़ार", + "nextScheduled": "अगले निर्धारित", + "answerNeeded": "जवाब चाहिए", + "waitingToRetry": "फिर कोशिश का इंतज़ार" }, "transcriptionModel": { "title": "आवाज़ को टेक्स्ट में बदलने का मॉडल", @@ -3357,7 +3365,6 @@ "template": "खाका", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS स्पष्ट करें", - "agent": "प्रतिनिधि", "subtask": "उपकार्य के रूप में चलाएँ", "edit": "स्लैश कमांड संपादित करें", "saveFailed": "आदेश सहेजा नहीं जा सका", diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 53951447fe..6b1343a189 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -135,7 +135,8 @@ "updated": "Ažurirano", "working": "U radu", "yourOwnRepositories": "Tvoji repozitoriji", - "dismissed": "Odbačeno" + "dismissed": "Odbačeno", + "agent": "Agent" }, "language": { "restarting": "Ponovno pokretanje radi promjene jezika", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama za prikaz aktivnih agenata na zaključanom zaslonu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspjelo. Dodirni $t(common.approve) da pokušaš ponovno.", - "newestResult": "Najnoviji rezultat", - "scheduledWakes": "budi se {{time}}" + "scheduledWakes": "budi se {{time}}", + "checked": "Provjereno", + "lastKnown": "Posljednje poznato", + "awaitingUpdate": "Čeka ažuriranje", + "nextRun": "Sljedeće pokretanje", + "waitingForYou": "Čeka vas", + "nextScheduled": "Sljedeće zakazano", + "answerNeeded": "Potreban odgovor", + "waitingToRetry": "Čeka ponovni pokušaj" }, "transcriptionModel": { "title": "Model za transkripciju", @@ -3398,7 +3406,6 @@ "template": "Predložak", "namePlaceholder": "explain", "templatePlaceholder": "Objasnite $ARGUMENTS", - "agent": "Agent", "subtask": "Pokreni kao podzadatak", "edit": "Naredba za uređivanje kose crte", "saveFailed": "Nije moguće spremiti naredbu", diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index a1db0a07be..a676bb9a8a 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -135,7 +135,8 @@ "updated": "Mete ajou", "working": "Ap travay", "yourOwnRepositories": "Pwòp depo ou yo", - "dismissed": "Rejte" + "dismissed": "Rejte", + "agent": "Ajan" }, "language": { "restarting": "Ap rekòmanse pou itilize lang ou chwazi a", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Nan paramèt yo, aktive aktivite an dirèk yo pou wè ajan aktif yo sou ekran fèmen an.", "openSession": "Louvri", "approveFailed": "Nou pa t ka apwouve. Peze $t(common.approve) pou eseye ankò.", - "newestResult": "Dènye rezilta", - "scheduledWakes": "leve {{time}}" + "scheduledWakes": "leve {{time}}", + "checked": "Verifye", + "lastKnown": "Dènye eta koni", + "awaitingUpdate": "Ap tann mizajou", + "nextRun": "Pwochen ekzekisyon", + "waitingForYou": "Ap tann ou", + "nextScheduled": "Pwochen ki pwograme", + "answerNeeded": "Bezwen repons", + "waitingToRetry": "Ap tann pou reeseye" }, "transcriptionModel": { "title": "Modèl transkripsyon", @@ -3357,7 +3365,6 @@ "template": "Modèl", "namePlaceholder": "explain", "templatePlaceholder": "Eksplike $ARGUMENTS", - "agent": "Ajan", "subtask": "Kouri kòm subtach", "edit": "Edit lòd koupe", "saveFailed": "Pa t 'kapab sove lòd", diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index f968322864..b7a94183ce 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -135,7 +135,8 @@ "updated": "Frissítve", "working": "Dolgozik", "yourOwnRepositories": "Saját adattáraid", - "dismissed": "Elvetve" + "dismissed": "Elvetve", + "agent": "Ügynök" }, "language": { "restarting": "Újraindítás a nyelv beállításához", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Kapcsold be az Élő tevékenységek funkciót a Beállításokban, hogy az aktív ügynökök megjelenjenek a zárolási képernyőn.", "openSession": "Megnyitás", "approveFailed": "Nem sikerült jóváhagyni. Az újrapróbáláshoz koppints a $t(common.approve) elemre.", - "newestResult": "Legújabb eredmény", - "scheduledWakes": "{{time}} felébred" + "scheduledWakes": "{{time}} felébred", + "checked": "Ellenőrizve", + "lastKnown": "Utolsó ismert", + "awaitingUpdate": "Frissítésre vár", + "nextRun": "Következő futás", + "waitingForYou": "Rád vár", + "nextScheduled": "Következő ütemezések", + "answerNeeded": "Válaszra vár", + "waitingToRetry": "Újrapróbálkozásra vár" }, "transcriptionModel": { "title": "Átírási modell", @@ -3357,7 +3365,6 @@ "template": "Sablon", "namePlaceholder": "explain", "templatePlaceholder": "Magyarázza el $ARGUMENTS", - "agent": "Ügynök", "subtask": "Futtassa részfeladatként", "edit": "Szerkessze a perjel parancsot", "saveFailed": "Nem sikerült menteni a parancsot", diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index 81d64c7379..bb2730d35b 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -135,7 +135,8 @@ "updated": "Թարմացված", "working": "Աշխատում է", "yourOwnRepositories": "Քո սեփական պահոցները", - "dismissed": "Անտեսված" + "dismissed": "Անտեսված", + "agent": "Գործակալ" }, "language": { "restarting": "Վերագործարկվում է՝ լեզվի փոփոխությունը կիրառելու համար", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Կարգավորումներում միացրու ընթացիկ գործողությունների ցուցադրումը՝ ակտիվ գործակալներին կողպման էկրանին տեսնելու համար։", "openSession": "Բացիր", "approveFailed": "Չհաջողվեց հաստատել։ Կրկին փորձելու համար հպեք $t(common.approve)-ին։", - "newestResult": "Նորագույն արդյունքը", - "scheduledWakes": "արթնանում է {{time}}" + "scheduledWakes": "արթնանում է {{time}}", + "checked": "Ստուգված", + "lastKnown": "Վերջին հայտնի", + "awaitingUpdate": "Սպասում է թարմացման", + "nextRun": "Հաջորդ գործարկումը", + "waitingForYou": "Սպասում է ձեզ", + "nextScheduled": "Հաջորդ պլանավորվածները", + "answerNeeded": "Սպասում է պատասխանի", + "waitingToRetry": "Սպասում է կրկնափորձի" }, "transcriptionModel": { "title": "Տեքստի վերածման մոդել", @@ -3357,7 +3365,6 @@ "template": "Կաղապար", "namePlaceholder": "explain", "templatePlaceholder": "Բացատրեք $ARGUMENTS", - "agent": "Գործակալ", "subtask": "Գործարկել որպես ենթաառաջադրանք", "edit": "Խմբագրել շեղ հրամանը", "saveFailed": "Չհաջողվեց պահել հրամանը", diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index a41ab6bf9a..428662fe34 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -135,7 +135,8 @@ "updated": "Diperbarui", "working": "Bekerja", "yourOwnRepositories": "Repositori milikmu", - "dismissed": "Diabaikan" + "dismissed": "Diabaikan", + "agent": "Agen" }, "language": { "restarting": "Memulai ulang untuk menerapkan bahasa", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Aktifkan Aktivitas Langsung di pengaturan untuk melihat agen aktif di layar terkunci.", "openSession": "Buka", "approveFailed": "Tidak dapat menyetujui. Ketuk $t(common.approve) untuk mencoba lagi.", - "newestResult": "Hasil terbaru", - "scheduledWakes": "bangun {{time}}" + "scheduledWakes": "bangun {{time}}", + "checked": "Diperiksa", + "lastKnown": "Terakhir diketahui", + "awaitingUpdate": "Menunggu pembaruan", + "nextRun": "Eksekusi berikutnya", + "waitingForYou": "Menunggu Anda", + "nextScheduled": "Jadwal berikutnya", + "answerNeeded": "Perlu jawaban", + "waitingToRetry": "Menunggu percobaan ulang" }, "transcriptionModel": { "title": "Model transkripsi", @@ -3357,7 +3365,6 @@ "template": "Templat", "namePlaceholder": "explain", "templatePlaceholder": "Jelaskan $ARGUMENTS", - "agent": "Agen", "subtask": "Jalankan sebagai subtugas", "edit": "Edit perintah garis miring", "saveFailed": "Tidak dapat menyimpan perintah", diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index 11e0a14438..a46128a8dd 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -135,7 +135,8 @@ "updated": "Emelitere", "working": "Na-arụ ọrụ", "yourOwnRepositories": "Ebe nchekwa koodu nke gị", - "dismissed": "Elegharala anya" + "dismissed": "Elegharala anya", + "agent": "Onye nnọchi anya" }, "language": { "restarting": "Na-amalitegharị iji asụsụ ahọpụtara", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Gbanye ihe omume na-emelite ozugbo n'ime ntọala ka ị hụ ndị nnọchi anya na-arụ ọrụ n'ihuenyo mkpọchi.", "openSession": "Meghe", "approveFailed": "Enweghị ike ịkwado. Pịa $t(common.approve) ka ị nwaa ọzọ.", - "newestResult": "Nsonaazụ kacha ọhụrụ", - "scheduledWakes": "ga-eteta {{time}}" + "scheduledWakes": "ga-eteta {{time}}", + "checked": "Enyochara", + "lastKnown": "Nke ikpeazụ a maara", + "awaitingUpdate": "Na-eche mmelite", + "nextRun": "Oge ọrụ ọzọ", + "waitingForYou": "Na-eche gị", + "nextScheduled": "Ihe ahaziri na-esote", + "answerNeeded": "Chọrọ azịza", + "waitingToRetry": "Na-eche ịnwale ọzọ" }, "transcriptionModel": { "title": "Ihe nlereanya maka ịtụgharị okwu ka ọ bụrụ ederede", @@ -3357,7 +3365,6 @@ "template": "Ụdị", "namePlaceholder": "explain", "templatePlaceholder": "Kọwaa $ARGUMENTS", - "agent": "Onye nnọchi anya", "subtask": "Gbaa ọsọ dị ka obere ọrụ", "edit": "Dezie slash iwu", "saveFailed": "Enweghị ike ịchekwa iwu", diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index df8b802187..a912714fd4 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -135,7 +135,8 @@ "updated": "Uppfært", "working": "Í vinnslu", "yourOwnRepositories": "Þín eigin kóðasöfn", - "dismissed": "Vísað frá" + "dismissed": "Vísað frá", + "agent": "Umboðsmaður" }, "language": { "restarting": "Endurræsi til að virkja valið tungumál", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Kveiktu á rauntímavirkni í stillingum til að sjá virka fulltrúa á lásskjánum.", "openSession": "Opna", "approveFailed": "Ekki tókst að samþykkja. Ýttu á $t(common.approve) til að reyna aftur.", - "newestResult": "Nýjasta niðurstaðan", - "scheduledWakes": "vaknar {{time}}" + "scheduledWakes": "vaknar {{time}}", + "checked": "Staðfest", + "lastKnown": "Síðast þekkta staða", + "awaitingUpdate": "Bíður eftir uppfærslu", + "nextRun": "Næsta keyrsla", + "waitingForYou": "Bíður eftir þér", + "nextScheduled": "Næst á dagskrá", + "answerNeeded": "Bíður eftir svari", + "waitingToRetry": "Bíður eftir endurtilraun" }, "transcriptionModel": { "title": "Umritunarlíkan", @@ -3357,7 +3365,6 @@ "template": "Sniðmát", "namePlaceholder": "explain", "templatePlaceholder": "Útskýrðu $ARGUMENTS", - "agent": "Umboðsmaður", "subtask": "Keyra sem undirverkefni", "edit": "Breyta skástrik skipun", "saveFailed": "Ekki tókst að vista skipunina", diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index da031548d9..3302fc07c3 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -135,7 +135,8 @@ "updated": "Aggiornato", "working": "In corso", "yourOwnRepositories": "I tuoi repository", - "dismissed": "Ignorato" + "dismissed": "Ignorato", + "agent": "Agente" }, "language": { "restarting": "Riavvio per applicare la lingua selezionata", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Attiva le attività in tempo reale nelle impostazioni per vedere gli agenti attivi nella schermata di blocco.", "openSession": "Apri", "approveFailed": "Impossibile approvare. Tocca $t(common.approve) per riprovare.", - "newestResult": "Risultato più recente", - "scheduledWakes": "si risveglia {{time}}" + "scheduledWakes": "si risveglia {{time}}", + "checked": "Verificato", + "lastKnown": "Ultimo stato noto", + "awaitingUpdate": "In attesa di aggiornamento", + "nextRun": "Prossima esecuzione", + "waitingForYou": "In attesa di te", + "nextScheduled": "Prossime pianificate", + "answerNeeded": "In attesa di risposta", + "waitingToRetry": "In attesa di riprovare" }, "transcriptionModel": { "title": "Modello di trascrizione", @@ -3398,7 +3406,6 @@ "template": "Modello", "namePlaceholder": "explain", "templatePlaceholder": "Spiega $ARGUMENTS", - "agent": "Agente", "subtask": "Esegui come attività secondaria", "edit": "Comando Modifica barra", "saveFailed": "Impossibile salvare il comando", diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index f7239210c9..1dcbaf642f 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -135,7 +135,8 @@ "updated": "更新", "working": "作業中", "yourOwnRepositories": "自分のリポジトリ", - "dismissed": "却下済み" + "dismissed": "却下済み", + "agent": "エージェント" }, "language": { "restarting": "言語設定を適用するために再起動中", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ロック画面にアクティブなエージェントを表示するには、設定でライブアクティビティをオンにしてください。", "openSession": "開く", "approveFailed": "承認できませんでした。もう一度試すには$t(common.approve)をタップしてください。", - "newestResult": "最新の結果", - "scheduledWakes": "{{time}}に起動" + "scheduledWakes": "{{time}}に起動", + "checked": "最終確認", + "lastKnown": "最終記録", + "awaitingUpdate": "更新待ち", + "nextRun": "次回実行", + "waitingForYou": "操作待ち", + "nextScheduled": "次の予定", + "answerNeeded": "回答が必要", + "waitingToRetry": "再試行待ち" }, "transcriptionModel": { "title": "文字起こしモデル", @@ -3357,7 +3365,6 @@ "template": "テンプレート", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS の説明", - "agent": "エージェント", "subtask": "サブタスクとして実行", "edit": "スラッシュコマンドの編集", "saveFailed": "コマンドを保存できませんでした", diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 77494f257b..625e69e45a 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -135,7 +135,8 @@ "updated": "განახლებულია", "working": "მუშაობს", "yourOwnRepositories": "შენი რეპოზიტორიები", - "dismissed": "უგულებელყოფილია" + "dismissed": "უგულებელყოფილია", + "agent": "აგენტი" }, "language": { "restarting": "ენის შესაცვლელად აპი ხელახლა იტვირთება", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "დაბლოკილ ეკრანზე აქტიური აგენტების სანახავად პარამეტრებში ჩართე მიმდინარე აქტივობები.", "openSession": "გახსენი", "approveFailed": "დამტკიცება ვერ მოხერხდა. ხელახლა საცდელად შეეხეთ $t(common.approve)-ს.", - "newestResult": "უახლესი შედეგი", - "scheduledWakes": "{{time}} იღვიძებს" + "scheduledWakes": "{{time}} იღვიძებს", + "checked": "შემოწმდა", + "lastKnown": "ბოლო ცნობილი", + "awaitingUpdate": "განახლებას ელის", + "nextRun": "შემდეგი გაშვება", + "waitingForYou": "გელოდებათ", + "nextScheduled": "შემდეგი დაგეგმილი", + "answerNeeded": "პასუხს ელის", + "waitingToRetry": "ხელახალ ცდას ელის" }, "transcriptionModel": { "title": "ტრანსკრიფციის მოდელი", @@ -3357,7 +3365,6 @@ "template": "შაბლონი", "namePlaceholder": "explain", "templatePlaceholder": "ახსენით $ARGUMENTS", - "agent": "აგენტი", "subtask": "გაშვება როგორც ქვეამოცანა", "edit": "დახრილი ბრძანების რედაქტირება", "saveFailed": "ბრძანების შენახვა ვერ მოხერხდა", diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index ea1a975ada..4d305f43a1 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -135,7 +135,8 @@ "updated": "Жаңартылды", "working": "Орындалуда", "yourOwnRepositories": "Өзіңіздің репозиторийлеріңіз", - "dismissed": "Еленбейді" + "dismissed": "Еленбейді", + "agent": "Агент" }, "language": { "restarting": "Тілді қолдану үшін қайта іске қосылуда", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Құлыптау экранында белсенді агенттерді көру үшін баптауларда тікелей эфирдегі әрекеттерді қосыңыз.", "openSession": "Ашыңыз", "approveFailed": "Мақұлдау мүмкін болмады. Қайталап көру үшін $t(common.approve) түртіңіз.", - "newestResult": "Ең жаңа нәтиже", - "scheduledWakes": "{{time}} оянады" + "scheduledWakes": "{{time}} оянады", + "checked": "Тексерілді", + "lastKnown": "Соңғы белгілі күй", + "awaitingUpdate": "Жаңарту күтілуде", + "nextRun": "Келесі іске қосылу", + "waitingForYou": "Сізді күтуде", + "nextScheduled": "Келесі жоспарланғандар", + "answerNeeded": "Жауап қажет", + "waitingToRetry": "Қайта әрекет күтілуде" }, "transcriptionModel": { "title": "Дауысты мәтінге айналдыру моделі", @@ -3357,7 +3365,6 @@ "template": "Үлгі", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS түсіндіріңіз", - "agent": "Агент", "subtask": "Қосымша тапсырма ретінде іске қосыңыз", "edit": "Қиғаш сызықты өңдеу пәрмені", "saveFailed": "Пәрменді сақтау мүмкін болмады", diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index ae7c42d79d..71c5c31529 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -135,7 +135,8 @@ "updated": "បានធ្វើបច្ចុប្បន្នភាព", "working": "កំពុងដំណើរការ", "yourOwnRepositories": "ឃ្លាំងកូដផ្ទាល់ខ្លួនរបស់អ្នក", - "dismissed": "បានសម្គាល់ថាមិនអើពើ" + "dismissed": "បានសម្គាល់ថាមិនអើពើ", + "agent": "ភ្នាក់ងារ" }, "language": { "restarting": "កំពុងចាប់ផ្តើមឡើងវិញដើម្បីប្រើភាសាដែលបានជ្រើសរើស", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "បើកសកម្មភាពបន្តផ្ទាល់នៅក្នុងការកំណត់ ដើម្បីមើលភ្នាក់ងារសកម្មនៅលើអេក្រង់ចាក់សោ។", "openSession": "បើក", "approveFailed": "មិនអាចយល់ព្រមបានទេ។ ប៉ះ $t(common.approve) ដើម្បីព្យាយាមម្តងទៀត។", - "newestResult": "លទ្ធផលថ្មីបំផុត", - "scheduledWakes": "ភ្ញាក់ {{time}}" + "scheduledWakes": "ភ្ញាក់ {{time}}", + "checked": "បានពិនិត្យ", + "lastKnown": "ស្ថានភាពដែលដឹងចុងក្រោយ", + "awaitingUpdate": "រង់ចាំការធ្វើបច្ចុប្បន្នភាព", + "nextRun": "ការដំណើរការបន្ទាប់", + "waitingForYou": "កំពុងរង់ចាំអ្នក", + "nextScheduled": "កាលវិភាគបន្ទាប់", + "answerNeeded": "ត្រូវការការឆ្លើយតប", + "waitingToRetry": "រង់ចាំព្យាយាមម្ដងទៀត" }, "transcriptionModel": { "title": "ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទ", @@ -3357,7 +3365,6 @@ "template": "គំរូ", "namePlaceholder": "explain", "templatePlaceholder": "ពន្យល់ $ARGUMENTS", - "agent": "ភ្នាក់ងារ", "subtask": "ដំណើរការជាកិច្ចការរង", "edit": "កែសម្រួលពាក្យបញ្ជា slash", "saveFailed": "មិនអាចរក្សាទុកពាក្យបញ្ជាបានទេ។", diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 886d99ffde..7ec6b0521b 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -135,7 +135,8 @@ "updated": "ನವೀಕರಿಸಲಾಗಿದೆ", "working": "ಕೆಲಸ ಮಾಡುತ್ತಿದೆ", "yourOwnRepositories": "ನಿಮ್ಮ ಸ್ವಂತ ರೆಪೊಸಿಟರಿಗಳು", - "dismissed": "ಕಡೆಗಣಿಸಲಾಗಿದೆ" + "dismissed": "ಕಡೆಗಣಿಸಲಾಗಿದೆ", + "agent": "ಏಜೆಂಟ್" }, "language": { "restarting": "ಆಯ್ಕೆ ಮಾಡಿದ ಭಾಷೆಯನ್ನು ಅನ್ವಯಿಸಲು ಮರುಪ್ರಾರಂಭಿಸಲಾಗುತ್ತಿದೆ", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ಲಾಕ್ ಪರದೆಯಲ್ಲಿ ಸಕ್ರಿಯ ಏಜೆಂಟ್‌ಗಳನ್ನು ನೋಡಲು ಸೆಟ್ಟಿಂಗ್‌ಗಳಲ್ಲಿ ಲೈವ್ ಚಟುವಟಿಕೆಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ.", "openSession": "ತೆರೆಯಿರಿ", "approveFailed": "ಅನುಮೋದಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಲು $t(common.approve) ಟ್ಯಾಪ್ ಮಾಡಿ.", - "newestResult": "ಇತ್ತೀಚಿನ ಫಲಿತಾಂಶ", - "scheduledWakes": "{{time}} ಎಚ್ಚರಗೊಳ್ಳುತ್ತದೆ" + "scheduledWakes": "{{time}} ಎಚ್ಚರಗೊಳ್ಳುತ್ತದೆ", + "checked": "ಪರಿಶೀಲಿಸಲಾಗಿದೆ", + "lastKnown": "ಕೊನೆಯದಾಗಿ ತಿಳಿದ ಸ್ಥಿತಿ", + "awaitingUpdate": "ನವೀಕರಣಕ್ಕಾಗಿ ಕಾಯುತ್ತಿದೆ", + "nextRun": "ಮುಂದಿನ ಚಾಲನೆ", + "waitingForYou": "ನಿಮಗಾಗಿ ಕಾಯುತ್ತಿದೆ", + "nextScheduled": "ಮುಂದಿನ ನಿಗದಿತ ಕಾರ್ಯಗಳು", + "answerNeeded": "ಉತ್ತರ ಅಗತ್ಯ", + "waitingToRetry": "ಮರುಪ್ರಯತ್ನಕ್ಕಾಗಿ ಕಾಯುತ್ತಿದೆ" }, "transcriptionModel": { "title": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಮಾದರಿ", @@ -3357,7 +3365,6 @@ "template": "ಟೆಂಪ್ಲೇಟ್", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ಅನ್ನು ವಿವರಿಸಿ", - "agent": "ಏಜೆಂಟ್", "subtask": "ಉಪಕಾರ್ಯದಂತೆ ರನ್ ಮಾಡಿ", "edit": "ಸ್ಲಾಶ್ ಆಜ್ಞೆಯನ್ನು ಸಂಪಾದಿಸಿ", "saveFailed": "ಆಜ್ಞೆಯನ್ನು ಉಳಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index 6092b58a39..d94841b3cb 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -135,7 +135,8 @@ "updated": "업데이트됨", "working": "작업 중", "yourOwnRepositories": "내 저장소", - "dismissed": "무시됨" + "dismissed": "무시됨", + "agent": "대리인" }, "language": { "restarting": "언어 적용을 위해 다시 시작 중", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "잠금 화면에서 활성 에이전트를 보려면 설정에서 실시간 현황을 켜세요.", "openSession": "열기", "approveFailed": "승인하지 못했습니다. 다시 시도하려면 $t(common.approve)을 탭하세요.", - "newestResult": "최신 결과", - "scheduledWakes": "{{time}}에 깨어남" + "scheduledWakes": "{{time}}에 깨어남", + "checked": "마지막 확인", + "lastKnown": "마지막 알려진 상태", + "awaitingUpdate": "업데이트 대기", + "nextRun": "다음 실행", + "waitingForYou": "사용자 응답 대기", + "nextScheduled": "다음 예약", + "answerNeeded": "답변 필요", + "waitingToRetry": "재시도 대기" }, "transcriptionModel": { "title": "음성 텍스트 변환 모델", @@ -3357,7 +3365,6 @@ "template": "주형", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS 설명", - "agent": "대리인", "subtask": "하위 작업으로 실행", "edit": "슬래시 명령 편집", "saveFailed": "명령을 저장할 수 없습니다", diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 0f44a374dd..3ecd522bd6 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -135,7 +135,8 @@ "updated": "ອັບເດດແລ້ວ", "working": "ກຳລັງເຮັດວຽກ", "yourOwnRepositories": "ຄັງໂຄ້ດຂອງທ່ານເອງ", - "dismissed": "ລະເວັ້ນແລ້ວ" + "dismissed": "ລະເວັ້ນແລ້ວ", + "agent": "ຕົວແທນ" }, "language": { "restarting": "ກຳລັງເປີດແອັບໃໝ່ເພື່ອໃຊ້ພາສາທີ່ເລືອກ", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ເປີດກິດຈະກຳສົດໃນການຕັ້ງຄ່າ ເພື່ອເບິ່ງເອເຈນທີ່ກຳລັງເຮັດວຽກໃນໜ້າຈໍລັອກ.", "openSession": "ເປີດ", "approveFailed": "ບໍ່ສາມາດອະນຸມັດໄດ້. ແຕະ $t(common.approve) ເພື່ອລອງອີກຄັ້ງ.", - "newestResult": "ຜົນຫຼ້າສຸດ", - "scheduledWakes": "ຕື່ນ {{time}}" + "scheduledWakes": "ຕື່ນ {{time}}", + "checked": "ກວດສອບແລ້ວ", + "lastKnown": "ສະຖານະຫຼ້າສຸດທີ່ຮູ້", + "awaitingUpdate": "ລໍການອັບເດດ", + "nextRun": "ເຮັດວຽກຄັ້ງຕໍ່ໄປ", + "waitingForYou": "ລໍທ່ານ", + "nextScheduled": "ກຳນົດເວລາຖັດໄປ", + "answerNeeded": "ລໍຄຳຕອບ", + "waitingToRetry": "ລໍລອງໃໝ່" }, "transcriptionModel": { "title": "ໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ", @@ -3357,7 +3365,6 @@ "template": "ແມ່ແບບ", "namePlaceholder": "explain", "templatePlaceholder": "ອະທິບາຍ $ARGUMENTS", - "agent": "ຕົວແທນ", "subtask": "ດໍາເນີນການເປັນວຽກຍ່ອຍ", "edit": "ແກ້ໄຂຄໍາສັ່ງ slash", "saveFailed": "ບໍ່ສາມາດບັນທຶກຄຳສັ່ງໄດ້", diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index ecc45875ab..62fb16cf14 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -135,7 +135,8 @@ "updated": "Atnaujinta", "working": "Vykdoma", "yourOwnRepositories": "Tavo saugyklos", - "dismissed": "Atmesta" + "dismissed": "Atmesta", + "agent": "Agentas" }, "language": { "restarting": "Paleidžiama iš naujo, kad būtų pakeista kalba", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Nustatymuose įjunk tiesiogines veiklas, kad užrakinimo ekrane matytum aktyvius agentus.", "openSession": "Atidaryti", "approveFailed": "Nepavyko patvirtinti. Bakstelėkite $t(common.approve), kad bandytumėte dar kartą.", - "newestResult": "Naujausias rezultatas", - "scheduledWakes": "prabunda {{time}}" + "scheduledWakes": "prabunda {{time}}", + "checked": "Patikrinta", + "lastKnown": "Paskutinė žinoma", + "awaitingUpdate": "Laukiama atnaujinimo", + "nextRun": "Kitas vykdymas", + "waitingForYou": "Laukia jūsų", + "nextScheduled": "Artimiausi suplanuoti", + "answerNeeded": "Laukia atsakymo", + "waitingToRetry": "Laukiama pakartojimo" }, "transcriptionModel": { "title": "Transkribavimo modelis", @@ -3439,7 +3447,6 @@ "template": "Šablonas", "namePlaceholder": "explain", "templatePlaceholder": "Paaiškinkite $ARGUMENTS", - "agent": "Agentas", "subtask": "Vykdyti kaip papildomą užduotį", "edit": "Redaguoti pasvirojo brūkšnio komandą", "saveFailed": "Nepavyko išsaugoti komandos", diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 72c5b5f938..b736faff9e 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -135,7 +135,8 @@ "updated": "Atjaunināts", "working": "Strādā", "yourOwnRepositories": "Tavi repozitoriji", - "dismissed": "Noraidīts" + "dismissed": "Noraidīts", + "agent": "Aģents" }, "language": { "restarting": "Notiek restartēšana, lai lietotu izvēlēto valodu", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Iestatījumos ieslēdz tiešraides aktivitātes, lai bloķēšanas ekrānā redzētu aktīvos aģentus.", "openSession": "Atvērt", "approveFailed": "Neizdevās apstiprināt. Pieskaries $t(common.approve), lai mēģinātu vēlreiz.", - "newestResult": "Jaunākais rezultāts", - "scheduledWakes": "pamostas {{time}}" + "scheduledWakes": "pamostas {{time}}", + "checked": "Pārbaudīts", + "lastKnown": "Pēdējais zināmais", + "awaitingUpdate": "Gaida atjauninājumu", + "nextRun": "Nākamā palaišana", + "waitingForYou": "Gaida jūs", + "nextScheduled": "Nākamie ieplānotie", + "answerNeeded": "Nepieciešama atbilde", + "waitingToRetry": "Gaida atkārtotu mēģinājumu" }, "transcriptionModel": { "title": "Transkripcijas modelis", @@ -3398,7 +3406,6 @@ "template": "Veidne", "namePlaceholder": "explain", "templatePlaceholder": "Izskaidrojiet $ARGUMENTS", - "agent": "Aģents", "subtask": "Palaist kā apakšuzdevumu", "edit": "Rediģēt slīpsvītras komandu", "saveFailed": "Nevarēja saglabāt komandu", diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index 3dab2ea7f3..b16db4fc28 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -135,7 +135,8 @@ "updated": "Nohavaozina", "working": "Miasa", "yourOwnRepositories": "Ny tahirin-kaodinao manokana", - "dismissed": "Nesorina" + "dismissed": "Nesorina", + "agent": "mpandraharaha" }, "language": { "restarting": "Manomboka indray mba hampiharana ny fiteny", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Alefaso ao amin'ny fikirana ny hetsika mivantana mba hahitana ny mpanatanteraka mandeha eo amin'ny efijery mihidy.", "openSession": "Sokafy", "approveFailed": "Tsy nahomby ny fanekena. Tsindrio $t(common.approve) hanandramana indray.", - "newestResult": "Vokatra vaovao indrindra", - "scheduledWakes": "mifoha {{time}}" + "scheduledWakes": "mifoha {{time}}", + "checked": "Voamarina", + "lastKnown": "Fantatra farany", + "awaitingUpdate": "Miandry fanavaozana", + "nextRun": "Fampandehanana manaraka", + "waitingForYou": "Miandry anao", + "nextScheduled": "Voalahatra manaraka", + "answerNeeded": "Mila valiny", + "waitingToRetry": "Miandry hanandrana indray" }, "transcriptionModel": { "title": "Modely fanovana feo ho soratra", @@ -3357,7 +3365,6 @@ "template": "Endrika", "namePlaceholder": "explain", "templatePlaceholder": "Hazavao ny $ARGUMENTS", - "agent": "mpandraharaha", "subtask": "Mihazakazaka toy ny subtask", "edit": "Ahitsio slash command", "saveFailed": "Tsy afaka mitahiry baiko", diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 82c214294c..c1ec546d47 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -135,7 +135,8 @@ "updated": "Kua whakahoutia", "working": "Kei te mahi", "yourOwnRepositories": "Ō ake pūranga waehere", - "dismissed": "Kua whakakorea" + "dismissed": "Kua whakakorea", + "agent": "Kaihoko" }, "language": { "restarting": "Kei te tīmata anō hei whakamahi i te reo", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Whakakāngia ngā mahi wā-tūturu i ngā tautuhinga kia kite ai i ngā māngai e mahi ana i te mata maukati.", "openSession": "Tuwhera", "approveFailed": "Kāore i taea te whakaae. Pāwhiria $t(common.approve) hei whakamātau anō.", - "newestResult": "Te hua hou rawa", - "scheduledWakes": "ka oho {{time}}" + "scheduledWakes": "ka oho {{time}}", + "checked": "Kua manatoko", + "lastKnown": "Mōhiotanga whakamutunga", + "awaitingUpdate": "E tatari ana ki te whakahou", + "nextRun": "Te whakahaerenga e whai ake nei", + "waitingForYou": "E tatari ana ki a koe", + "nextScheduled": "Te whakaritenga e whai ake nei", + "answerNeeded": "Me whai whakautu", + "waitingToRetry": "E tatari ana ki te ngana anō" }, "transcriptionModel": { "title": "Tauira tuhi ā-reo", @@ -3357,7 +3365,6 @@ "template": "tauira", "namePlaceholder": "explain", "templatePlaceholder": "Whakamaramahia $ARGUMENTS", - "agent": "Kaihoko", "subtask": "Whakahaerehia hei mahi iti", "edit": "Whakatika whakahau rītaha", "saveFailed": "Kāore i taea te tiaki tono", diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index be3e03ab42..aea74fe923 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -135,7 +135,8 @@ "updated": "Ажурирано", "working": "Во тек", "yourOwnRepositories": "Твоите репозиториуми", - "dismissed": "Отфрлено" + "dismissed": "Отфрлено", + "agent": "Агент" }, "language": { "restarting": "Се рестартира за да се примени јазикот", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Вклучи ги активностите во живо во поставките за да ги видиш активните агенти на заклучениот екран.", "openSession": "Отвори", "approveFailed": "Одобрувањето не успеа. Допри $t(common.approve) за да се обидеш повторно.", - "newestResult": "Најнов резултат", - "scheduledWakes": "се буди {{time}}" + "scheduledWakes": "се буди {{time}}", + "checked": "Проверено", + "lastKnown": "Последно познато", + "awaitingUpdate": "Чека ажурирање", + "nextRun": "Следно извршување", + "waitingForYou": "Чека на вас", + "nextScheduled": "Следни закажани", + "answerNeeded": "Потребен е одговор", + "waitingToRetry": "Чека повторен обид" }, "transcriptionModel": { "title": "Модел за транскрипција", @@ -3357,7 +3365,6 @@ "template": "Шаблон", "namePlaceholder": "explain", "templatePlaceholder": "Објасни $ARGUMENTS", - "agent": "Агент", "subtask": "Стартувај како подзадача", "edit": "Уреди команда коса црта", "saveFailed": "Не може да се зачува командата", diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 5ccb5c1256..d84c82fef4 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -135,7 +135,8 @@ "updated": "പുതുക്കി", "working": "പ്രവർത്തിക്കുന്നു", "yourOwnRepositories": "നിങ്ങളുടെ സ്വന്തം റിപ്പോസിറ്ററികൾ", - "dismissed": "ഒഴിവാക്കി" + "dismissed": "ഒഴിവാക്കി", + "agent": "ഏജൻ്റ്" }, "language": { "restarting": "ഭാഷ മാറ്റാൻ പുനരാരംഭിക്കുന്നു", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ലോക്ക് സ്ക്രീനിൽ സജീവ ഏജന്റുകളെ കാണാൻ ക്രമീകരണങ്ങളിൽ തത്സമയ പ്രവർത്തനങ്ങൾ പ്രവർത്തനക്ഷമമാക്കുക.", "openSession": "തുറക്കുക", "approveFailed": "അംഗീകരിക്കാനായില്ല. വീണ്ടും ശ്രമിക്കാൻ $t(common.approve) ടാപ്പ് ചെയ്യുക.", - "newestResult": "ഏറ്റവും പുതിയ ഫലം", - "scheduledWakes": "{{time}} ഉണരും" + "scheduledWakes": "{{time}} ഉണരും", + "checked": "പരിശോധിച്ചത്", + "lastKnown": "അവസാനം അറിയപ്പെട്ടത്", + "awaitingUpdate": "അപ്‌ഡേറ്റിനായി കാത്തിരിക്കുന്നു", + "nextRun": "അടുത്ത പ്രവർത്തനം", + "waitingForYou": "നിങ്ങൾക്കായി കാത്തിരിക്കുന്നു", + "nextScheduled": "അടുത്ത ഷെഡ്യൂൾ", + "answerNeeded": "മറുപടി വേണം", + "waitingToRetry": "വീണ്ടും ശ്രമിക്കാൻ കാത്തിരിക്കുന്നു" }, "transcriptionModel": { "title": "ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ", @@ -3357,7 +3365,6 @@ "template": "ടെംപ്ലേറ്റ്", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS വിശദീകരിക്കുക", - "agent": "ഏജൻ്റ്", "subtask": "ഉപടാസ്‌കായി പ്രവർത്തിപ്പിക്കുക", "edit": "സ്ലാഷ് കമാൻഡ് എഡിറ്റ് ചെയ്യുക", "saveFailed": "കമാൻഡ് സംരക്ഷിക്കാനായില്ല", diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index dc74c8c069..d304b56873 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -135,7 +135,8 @@ "updated": "Шинэчлэгдсэн", "working": "Ажиллаж байна", "yourOwnRepositories": "Таны кодын сангууд", - "dismissed": "Хасагдсан" + "dismissed": "Хасагдсан", + "agent": "Агент" }, "language": { "restarting": "Сонгосон хэлийг хэрэглэхийн тулд дахин ачаалж байна", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Түгжээтэй дэлгэц дээр идэвхтэй агентуудыг харахын тулд тохиргооноос шууд үйл ажиллагааг идэвхжүүлнэ үү.", "openSession": "Нээх", "approveFailed": "Зөвшөөрч чадсангүй. Дахин оролдохын тулд $t(common.approve)-г товшино уу.", - "newestResult": "Хамгийн сүүлийн үр дүн", - "scheduledWakes": "{{time}} сэрнэ" + "scheduledWakes": "{{time}} сэрнэ", + "checked": "Шалгасан", + "lastKnown": "Сүүлд мэдэгдсэн", + "awaitingUpdate": "Шинэчлэлт хүлээж байна", + "nextRun": "Дараагийн ажиллах цаг", + "waitingForYou": "Таныг хүлээж байна", + "nextScheduled": "Дараагийн товлол", + "answerNeeded": "Хариу хүлээж байна", + "waitingToRetry": "Дахин оролдохыг хүлээж байна" }, "transcriptionModel": { "title": "Яриаг бичвэр болгох загвар", @@ -3357,7 +3365,6 @@ "template": "Загвар", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS-г тайлбарла", - "agent": "Агент", "subtask": "Дэд даалгавар болгон ажиллуул", "edit": "Талуу зураасыг засах команд", "saveFailed": "Командыг хадгалж чадсангүй", diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index bbf99b9396..3d0baa1475 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -135,7 +135,8 @@ "updated": "अद्ययावत केले", "working": "काम सुरू आहे", "yourOwnRepositories": "तुमच्या स्वतःच्या रिपॉझिटरी", - "dismissed": "दुर्लक्षित" + "dismissed": "दुर्लक्षित", + "agent": "एजंट" }, "language": { "restarting": "निवडलेली भाषा लागू करण्यासाठी पुन्हा सुरू होत आहे", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "लॉक स्क्रीनवर सक्रिय एजंट्स पाहण्यासाठी सेटिंग्जमध्ये लाइव्ह अॅक्टिव्हिटी चालू करा.", "openSession": "उघडा", "approveFailed": "मंजूर करता आले नाही. पुन्हा प्रयत्न करण्यासाठी $t(common.approve) वर टॅप करा.", - "newestResult": "नवीनतम निकाल", - "scheduledWakes": "{{time}} जागेल" + "scheduledWakes": "{{time}} जागेल", + "checked": "तपासले", + "lastKnown": "शेवटची ज्ञात स्थिती", + "awaitingUpdate": "अपडेटची प्रतीक्षा", + "nextRun": "पुढील कार्यारंभ", + "waitingForYou": "तुमची प्रतीक्षा", + "nextScheduled": "पुढील नियोजित", + "answerNeeded": "उत्तर आवश्यक", + "waitingToRetry": "पुन्हा प्रयत्नाची प्रतीक्षा" }, "transcriptionModel": { "title": "लिप्यंतरण मॉडेल", @@ -3357,7 +3365,6 @@ "template": "साचा", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS स्पष्ट करा", - "agent": "एजंट", "subtask": "सबटास्क म्हणून चालवा", "edit": "स्लॅश कमांड संपादित करा", "saveFailed": "कमांड सेव्ह करू शकलो नाही", diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index 72a94746b4..b802a7032f 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -135,7 +135,8 @@ "updated": "Dikemas kini", "working": "Memproses…", "yourOwnRepositories": "Repositori anda sendiri", - "dismissed": "Diketepikan" + "dismissed": "Diketepikan", + "agent": "ejen" }, "language": { "restarting": "Memulakan semula apl untuk menukar bahasa", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Dayakan Aktiviti Langsung dalam Tetapan untuk melihat ejen aktif pada Skrin Kunci.", "openSession": "Buka", "approveFailed": "Tidak dapat meluluskan. Ketik $t(common.approve) untuk mencuba semula.", - "newestResult": "Hasil terbaharu", - "scheduledWakes": "bangun {{time}}" + "scheduledWakes": "bangun {{time}}", + "checked": "Disemak", + "lastKnown": "Terakhir diketahui", + "awaitingUpdate": "Menunggu kemas kini", + "nextRun": "Pelaksanaan seterusnya", + "waitingForYou": "Menunggu anda", + "nextScheduled": "Jadual seterusnya", + "answerNeeded": "Perlu jawapan", + "waitingToRetry": "Menunggu cubaan semula" }, "transcriptionModel": { "title": "Model transkripsi", @@ -3357,7 +3365,6 @@ "template": "templat", "namePlaceholder": "explain", "templatePlaceholder": "Terangkan $ARGUMENTS", - "agent": "ejen", "subtask": "Jalankan sebagai subtugas", "edit": "Edit arahan slash", "saveFailed": "Tidak dapat menyimpan arahan", diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index cb3023e23a..cdf2cc7575 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -135,7 +135,8 @@ "updated": "Aġġornat", "working": "Fix-xogħol", "yourOwnRepositories": "Ir-repożitorji tiegħek stess", - "dismissed": "Imwarrba" + "dismissed": "Imwarrba", + "agent": "Aġent" }, "language": { "restarting": "L-app qed terġa' tibda biex tapplika l-lingwa", @@ -3278,8 +3279,15 @@ "activityKitDisabledBody": "Attiva l-attivitajiet diretti fl-issettjar biex tara l-aġenti attivi fuq l-iskrin imsakkar.", "openSession": "Iftaħ", "approveFailed": "Ma setax jiġi approvat. Tektek $t(common.approve) biex terġa' tipprova.", - "newestResult": "L-aħħar riżultat", - "scheduledWakes": "iqum {{time}}" + "scheduledWakes": "iqum {{time}}", + "checked": "Iċċekkjat", + "lastKnown": "L-aħħar magħruf", + "awaitingUpdate": "Jistenna aġġornament", + "nextRun": "Tħaddim li jmiss", + "waitingForYou": "Jistenna lilek", + "nextScheduled": "Ipprogrammati li jmiss", + "answerNeeded": "Tweġiba meħtieġa", + "waitingToRetry": "Jistenna biex jerġa’ jipprova" }, "transcriptionModel": { "title": "Mudell ta' traskrizzjoni", @@ -3480,7 +3488,6 @@ "template": "Mudell", "namePlaceholder": "explain", "templatePlaceholder": "Spjega $ARGUMENTS", - "agent": "Aġent", "subtask": "Mexxi bħala subtask", "edit": "Editja kmand slash", "saveFailed": "Ma setax isalva l-kmand", diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index 933d3f7e9f..79600ba082 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -135,7 +135,8 @@ "updated": "မွမ်းမံပြီး", "working": "လုပ်ဆောင်နေ", "yourOwnRepositories": "သင့်ကိုယ်ပိုင် ကုဒ်သိုလှောင်ရာများ", - "dismissed": "ပယ်ချပြီး" + "dismissed": "ပယ်ချပြီး", + "agent": "အေးဂျင့်" }, "language": { "restarting": "ရွေးထားသော ဘာသာစကားကို သုံးရန် ပြန်စတင်နေသည်", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "သော့ခတ်မျက်နှာပြင်တွင် လုပ်ဆောင်နေသော အေးဂျင့်များကို ကြည့်ရန် ဆက်တင်များ၌ တိုက်ရိုက်လုပ်ဆောင်မှုများကို ဖွင့်ပါ။", "openSession": "ဖွင့်ပါ", "approveFailed": "အတည်ပြု၍မရပါ။ ထပ်ကြိုးစားရန် $t(common.approve) ကို နှိပ်ပါ။", - "newestResult": "အသစ်ဆုံး ရလဒ်", - "scheduledWakes": "{{time}} နိုးမည်" + "scheduledWakes": "{{time}} နိုးမည်", + "checked": "စစ်ဆေးထားသည်", + "lastKnown": "နောက်ဆုံး သိရှိထားသည်", + "awaitingUpdate": "အပ်ဒိတ် စောင့်နေ", + "nextRun": "နောက်တစ်ကြိမ် လုပ်ဆောင်ချိန်", + "waitingForYou": "သင့်ကို စောင့်နေ", + "nextScheduled": "နောက်ထပ် စီစဉ်ထားမှုများ", + "answerNeeded": "အဖြေ လိုအပ်", + "waitingToRetry": "ပြန်လည်ကြိုးစားရန် စောင့်နေ" }, "transcriptionModel": { "title": "အသံမှစာသားပြောင်းမော်ဒယ်", @@ -3357,7 +3365,6 @@ "template": "ပုံစံခွက်", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ကိုရှင်းပြပါ။", - "agent": "အေးဂျင့်", "subtask": "လုပ်ငန်းခွဲအဖြစ် လုပ်ဆောင်ပါ။", "edit": "မျဥ်းစောင်းအမိန့်ကို တည်းဖြတ်ပါ။", "saveFailed": "အမိန့်ကို သိမ်းဆည်း၍မရပါ။", diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index b065785553..7d5a15aa0a 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -135,7 +135,8 @@ "updated": "Oppdatert", "working": "Jobber", "yourOwnRepositories": "Dine egne kodelagre", - "dismissed": "Avvist" + "dismissed": "Avvist", + "agent": "Agent" }, "language": { "restarting": "Starter på nytt for å bruke språket", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Slå på oppdateringer i sanntid i innstillingene for å se aktive agenter på låseskjermen.", "openSession": "Åpne", "approveFailed": "Kunne ikke godkjenne. Trykk på $t(common.approve) for å prøve igjen.", - "newestResult": "Nyeste resultat", - "scheduledWakes": "våkner {{time}}" + "scheduledWakes": "våkner {{time}}", + "checked": "Sjekket", + "lastKnown": "Sist kjent", + "awaitingUpdate": "Venter på oppdatering", + "nextRun": "Neste kjøring", + "waitingForYou": "Venter på deg", + "nextScheduled": "Neste planlagte", + "answerNeeded": "Venter på svar", + "waitingToRetry": "Venter på nytt forsøk" }, "transcriptionModel": { "title": "Transkripsjonsmodell", @@ -3357,7 +3365,6 @@ "template": "Mal", "namePlaceholder": "explain", "templatePlaceholder": "Forklar $ARGUMENTS", - "agent": "Agent", "subtask": "Kjør som deloppgave", "edit": "Rediger skråstrek-kommando", "saveFailed": "Kunne ikke lagre kommandoen", diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index 37743f9a0e..2fffa72f70 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -135,7 +135,8 @@ "updated": "अद्यावधिक गरियो", "working": "काम गर्दै", "yourOwnRepositories": "तपाईंका आफ्नै रिपोजिटरीहरू", - "dismissed": "खारेज गरियो" + "dismissed": "खारेज गरियो", + "agent": "एजेन्ट" }, "language": { "restarting": "भाषा लागू गर्न पुनः सुरु गर्दै", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "लक स्क्रिनमा सक्रिय एजेन्टहरू हेर्न सेटिङ्समा प्रत्यक्ष गतिविधिहरू चालू गर्नुहोस्।", "openSession": "खोल्नुहोस्", "approveFailed": "स्वीकृति गर्न सकिएन। फेरि प्रयास गर्न $t(common.approve) मा ट्याप गर्नुहोस्।", - "newestResult": "नवीनतम नतिजा", - "scheduledWakes": "{{time}} उठ्नेछ" + "scheduledWakes": "{{time}} उठ्नेछ", + "checked": "जाँच गरिएको", + "lastKnown": "पछिल्लो ज्ञात", + "awaitingUpdate": "अपडेटको प्रतीक्षामा", + "nextRun": "अर्को सञ्चालन", + "waitingForYou": "तपाईंको प्रतीक्षामा", + "nextScheduled": "अब तय भएका", + "answerNeeded": "जवाफ चाहिन्छ", + "waitingToRetry": "फेरि प्रयासको प्रतीक्षामा" }, "transcriptionModel": { "title": "बोलीलाई पाठमा रूपान्तरण गर्ने मोडेल", @@ -3357,7 +3365,6 @@ "template": "टेम्प्लेट", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS व्याख्या गर्नुहोस्", - "agent": "एजेन्ट", "subtask": "सबटास्कको रूपमा चलाउनुहोस्", "edit": "स्ल्याश आदेश सम्पादन गर्नुहोस्", "saveFailed": "आदेश सुरक्षित गर्न सकिएन", diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index 4f199c7683..0307a71066 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -135,7 +135,8 @@ "updated": "Bijgewerkt", "working": "Bezig", "yourOwnRepositories": "Je eigen coderepository's", - "dismissed": "Genegeerd" + "dismissed": "Genegeerd", + "agent": "Tussenpersoon" }, "language": { "restarting": "Opnieuw starten om de taal in te stellen", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Schakel liveactiviteiten in via de instellingen om actieve agents op het toegangsscherm te zien.", "openSession": "Openen", "approveFailed": "Goedkeuren is niet gelukt. Tik op $t(common.approve) om het opnieuw te proberen.", - "newestResult": "Nieuwste resultaat", - "scheduledWakes": "wordt wakker {{time}}" + "scheduledWakes": "wordt wakker {{time}}", + "checked": "Gecontroleerd", + "lastKnown": "Laatst bekend", + "awaitingUpdate": "Wacht op update", + "nextRun": "Volgende uitvoering", + "waitingForYou": "Wacht op jou", + "nextScheduled": "Hierna gepland", + "answerNeeded": "Antwoord nodig", + "waitingToRetry": "Wacht op nieuwe poging" }, "transcriptionModel": { "title": "Transcriptiemodel", @@ -3357,7 +3365,6 @@ "template": "Sjabloon", "namePlaceholder": "explain", "templatePlaceholder": "Leg $ARGUMENTS uit", - "agent": "Tussenpersoon", "subtask": "Uitvoeren als subtaak", "edit": "Bewerk de slash-opdracht", "saveFailed": "Kan opdracht niet opslaan", diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index 431999dfe0..e79c201ea9 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -135,7 +135,8 @@ "updated": "Haaromfame", "working": "Hojjechaa jira", "yourOwnRepositories": "Kuusaalee koodii mataa keetii", - "dismissed": "Dhiifame" + "dismissed": "Dhiifame", + "agent": "Bakka bu'aa" }, "language": { "restarting": "Afaan filatame fayyadamuuf irra deebi'ee jalqabaa jira", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Eejentoota hojii irra jiran iskiriinii qulfii irratti ilaaluuf, qindaa'ina keessatti sochiiwwan yeroo ammaa hojii irra oolchi.", "openSession": "Bani", "approveFailed": "Raggaasisuun hin danda'amne. Deebi'anii yaaluuf $t(common.approve) tuqi.", - "newestResult": "Bu'aa haaraa", - "scheduledWakes": "{{time}} dammaqaa" + "scheduledWakes": "{{time}} dammaqaa", + "checked": "Mirkanaa'e", + "lastKnown": "Kan dhumarratti beekame", + "awaitingUpdate": "Haaromsa eeggachaa jira", + "nextRun": "Raawwii itti aanu", + "waitingForYou": "Si eeggachaa jira", + "nextScheduled": "Kan itti aansee karoorfame", + "answerNeeded": "Deebii barbaada", + "waitingToRetry": "Irra deebi'uuf eeggachaa jira" }, "transcriptionModel": { "title": "Moodeela sagalee gara barreeffamaatti jijjiiru", @@ -3357,7 +3365,6 @@ "template": "Unkaa", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ibsi", - "agent": "Bakka bu'aa", "subtask": "Akka hojii xiqqaatti fiigi", "edit": "Ajaja slash gulaali", "saveFailed": "Ajaja qusachuu hin dandeenye", diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index 365e0219a2..f77fad7f0b 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -135,7 +135,8 @@ "updated": "ଅଦ୍ୟତନ", "working": "କାମ କରୁଛି", "yourOwnRepositories": "ଆପଣଙ୍କ ନିଜ ରିପୋଜିଟୋରୀ", - "dismissed": "ଖାରଜ ହୋଇଛି" + "dismissed": "ଖାରଜ ହୋଇଛି", + "agent": "ଏଜେଣ୍ଟ।" }, "language": { "restarting": "ଭାଷା ବଦଳାଇବାକୁ ଆପ୍ ପୁଣି ଆରମ୍ଭ ହେଉଛି", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ଲକ୍ ସ୍କ୍ରିନ୍‌ରେ ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ ଦେଖିବାକୁ ସେଟିଂସ୍‌ରେ ଲାଇଭ୍ କାର୍ଯ୍ୟକଳାପ ଚାଲୁ କରନ୍ତୁ।", "openSession": "ଖୋଲନ୍ତୁ", "approveFailed": "ଅନୁମୋଦନ କରିହେଲା ନାହିଁ। ପୁଣି ଚେଷ୍ଟା କରିବାକୁ $t(common.approve) ଟାପ୍ କରନ୍ତୁ।", - "newestResult": "ନୂତନତମ ଫଳାଫଳ", - "scheduledWakes": "{{time}} ଉଠିବ" + "scheduledWakes": "{{time}} ଉଠିବ", + "checked": "ଯାଞ୍ଚ ହୋଇଛି", + "lastKnown": "ଶେଷ ଜଣା ସ୍ଥିତି", + "awaitingUpdate": "ଅପଡେଟ୍ ଅପେକ୍ଷାରେ", + "nextRun": "ପରବର୍ତ୍ତୀ ଚାଳନା", + "waitingForYou": "ଆପଣଙ୍କ ଅପେକ୍ଷାରେ", + "nextScheduled": "ପରବର୍ତ୍ତୀ ନିର୍ଧାରିତ", + "answerNeeded": "ଆପଣଙ୍କ ଉତ୍ତର ଆବଶ୍ୟକ", + "waitingToRetry": "ପୁନଃଚେଷ୍ଟା ଅପେକ୍ଷାରେ" }, "transcriptionModel": { "title": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର ମଡେଲ୍", @@ -3357,7 +3365,6 @@ "template": "ଟେମ୍ପଲେଟ୍।", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ବ୍ୟାଖ୍ୟା କର।", - "agent": "ଏଜେଣ୍ଟ।", "subtask": "ସବଟାସ୍କ ଭାବରେ ଚଲାନ୍ତୁ।", "edit": "ସ୍ଲାସ୍ କମାଣ୍ଡ୍ ଏଡିଟ୍ କରନ୍ତୁ।", "saveFailed": "କମାଣ୍ଡ୍ ସେଭ୍ କରିପାରିଲା ନାହିଁ।", diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index e1fcaa52ef..c8e0e1fbab 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -135,7 +135,8 @@ "updated": "ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ", "working": "ਕੰਮ ਜਾਰੀ", "yourOwnRepositories": "ਤੁਹਾਡੀਆਂ ਆਪਣੀਆਂ ਰਿਪੋਜ਼ਿਟਰੀਆਂ", - "dismissed": "ਖਾਰਜ" + "dismissed": "ਖਾਰਜ", + "agent": "ਏਜੰਟ" }, "language": { "restarting": "ਭਾਸ਼ਾ ਲਾਗੂ ਕਰਨ ਲਈ ਮੁੜ ਚਾਲੂ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "ਲਾਕ ਸਕ੍ਰੀਨ 'ਤੇ ਸਰਗਰਮ ਏਜੰਟ ਵੇਖਣ ਲਈ ਸੈਟਿੰਗਾਂ ਵਿੱਚ ਲਾਈਵ ਗਤੀਵਿਧੀਆਂ ਚਾਲੂ ਕਰੋ।", "openSession": "ਖੋਲ੍ਹੋ", "approveFailed": "ਮਨਜ਼ੂਰ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰਨ ਲਈ $t(common.approve) 'ਤੇ ਟੈਪ ਕਰੋ।", - "newestResult": "ਸਭ ਤੋਂ ਨਵਾਂ ਨਤੀਜਾ", - "scheduledWakes": "{{time}} ਜਾਗੇਗਾ" + "scheduledWakes": "{{time}} ਜਾਗੇਗਾ", + "checked": "ਜਾਂਚ ਕੀਤੀ", + "lastKnown": "ਆਖਰੀ ਜਾਣੀ ਸਥਿਤੀ", + "awaitingUpdate": "ਅੱਪਡੇਟ ਦੀ ਉਡੀਕ", + "nextRun": "ਅਗਲੀ ਵਾਰ ਚੱਲੇਗਾ", + "waitingForYou": "ਤੁਹਾਡੀ ਉਡੀਕ", + "nextScheduled": "ਅਗਲਾ ਨਿਰਧਾਰਿਤ", + "answerNeeded": "ਜਵਾਬ ਚਾਹੀਦਾ ਹੈ", + "waitingToRetry": "ਮੁੜ ਕੋਸ਼ਿਸ਼ ਦੀ ਉਡੀਕ" }, "transcriptionModel": { "title": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਦਾ ਮਾਡਲ", @@ -3357,7 +3365,6 @@ "template": "ਟੈਂਪਲੇਟ", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ਦੀ ਵਿਆਖਿਆ ਕਰੋ", - "agent": "ਏਜੰਟ", "subtask": "ਸਬਟਾਸਕ ਵਜੋਂ ਚਲਾਓ", "edit": "ਸਲੈਸ਼ ਕਮਾਂਡ ਨੂੰ ਸੋਧੋ", "saveFailed": "ਕਮਾਂਡ ਨੂੰ ਸੁਰੱਖਿਅਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ", diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index 4ce5cd7860..b58567a6fd 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -135,7 +135,8 @@ "updated": "Zaktualizowano", "working": "W toku", "yourOwnRepositories": "Twoje repozytoria", - "dismissed": "Odrzucone" + "dismissed": "Odrzucone", + "agent": "Agent" }, "language": { "restarting": "Ponowne uruchamianie po zmianie języka", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Włącz aktywności na żywo w ustawieniach, aby widzieć aktywnych agentów na ekranie blokady.", "openSession": "Otwórz", "approveFailed": "Nie udało się zatwierdzić. Dotknij $t(common.approve), aby spróbować ponownie.", - "newestResult": "Najnowszy wynik", - "scheduledWakes": "budzi się {{time}}" + "scheduledWakes": "budzi się {{time}}", + "checked": "Sprawdzono", + "lastKnown": "Ostatni znany stan", + "awaitingUpdate": "Czeka na aktualizację", + "nextRun": "Następne uruchomienie", + "waitingForYou": "Czeka na ciebie", + "nextScheduled": "Najbliższe zaplanowane", + "answerNeeded": "Czeka na odpowiedź", + "waitingToRetry": "Czeka na ponowną próbę" }, "transcriptionModel": { "title": "Model transkrypcji", @@ -3439,7 +3447,6 @@ "template": "Szablon", "namePlaceholder": "explain", "templatePlaceholder": "Wyjaśnij $ARGUMENTS", - "agent": "Agent", "subtask": "Uruchom jako podzadanie", "edit": "Edytuj polecenie ukośnika", "saveFailed": "Nie udało się zapisać polecenia", diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index 60da8466aa..7f0c11cfec 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -135,7 +135,8 @@ "updated": "تازه شوی", "working": "په کار بوخت", "yourOwnRepositories": "ستاسو خپل زېرمتونونه", - "dismissed": "له پامه غورځول شوی" + "dismissed": "له پامه غورځول شوی", + "agent": "اجنټ" }, "language": { "restarting": "د ژبې د پلي کولو لپاره بیا پیلېږي", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "په قلف شوې پرده کې د فعالو اجنټانو د لیدلو لپاره په ترتیباتو کې روان فعالیتونه فعال کړئ.", "openSession": "پرانیستل", "approveFailed": "منظور نه شو. د بیا هڅې لپاره $t(common.approve) ته ټک ووهئ.", - "newestResult": "تر ټولو نوې پایله", - "scheduledWakes": "{{time}} بیداریږي" + "scheduledWakes": "{{time}} بیداریږي", + "checked": "تایید شوی", + "lastKnown": "وروستی معلوم حالت", + "awaitingUpdate": "د تازه کېدو په تمه", + "nextRun": "راتلونکی چلول", + "waitingForYou": "ستاسو په تمه", + "nextScheduled": "راتلونکی مهال ویش", + "answerNeeded": "ځواب ته اړتیا لري", + "waitingToRetry": "د بیا هڅې په تمه" }, "transcriptionModel": { "title": "د غږ د متن ته بدلولو ماډل", @@ -3357,7 +3365,6 @@ "template": "کينډۍ", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS تشریح کړئ", - "agent": "اجنټ", "subtask": "د فرعي دندې په توګه چلول", "edit": "د سلیش کمانډ ایډیټ کړئ", "saveFailed": "کمانډ خوندي نشو کړای", diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index 03cf600ec2..6e3bba27eb 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -135,7 +135,8 @@ "updated": "Atualizado", "working": "Em execução", "yourOwnRepositories": "Seus próprios repositórios", - "dismissed": "Ignorado" + "dismissed": "Ignorado", + "agent": "Agente" }, "language": { "restarting": "Reiniciando para aplicar o idioma", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Ative as Atividades ao Vivo nos Ajustes para ver os agentes ativos na tela bloqueada.", "openSession": "Abrir", "approveFailed": "Não foi possível aprovar. Toque em $t(common.approve) para tentar novamente.", - "newestResult": "Resultado mais recente", - "scheduledWakes": "acorda {{time}}" + "scheduledWakes": "acorda {{time}}", + "checked": "Verificado", + "lastKnown": "Último status conhecido", + "awaitingUpdate": "Aguardando atualização", + "nextRun": "Próxima execução", + "waitingForYou": "Aguardando você", + "nextScheduled": "Próximas agendadas", + "answerNeeded": "Aguardando resposta", + "waitingToRetry": "Aguardando nova tentativa" }, "transcriptionModel": { "title": "Modelo de transcrição", @@ -3398,7 +3406,6 @@ "template": "Modelo", "namePlaceholder": "explain", "templatePlaceholder": "Explique $ARGUMENTS", - "agent": "Agente", "subtask": "Executar como subtarefa", "edit": "Editar comando de barra", "saveFailed": "Não foi possível salvar o comando", diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index 2cfdc6de5f..4fb7845373 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -135,7 +135,8 @@ "updated": "Atualizado", "working": "A trabalhar", "yourOwnRepositories": "Os teus repositórios", - "dismissed": "Ignorada" + "dismissed": "Ignorada", + "agent": "Agente" }, "language": { "restarting": "A reiniciar para aplicar o idioma", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Ativa as atividades em direto nas definições para ver os agentes ativos no ecrã bloqueado.", "openSession": "Abrir", "approveFailed": "Não foi possível aprovar. Toque em $t(common.approve) para tentar novamente.", - "newestResult": "Resultado mais recente", - "scheduledWakes": "acorda {{time}}" + "scheduledWakes": "acorda {{time}}", + "checked": "Verificado", + "lastKnown": "Último estado conhecido", + "awaitingUpdate": "À espera de atualização", + "nextRun": "Próxima execução", + "waitingForYou": "À sua espera", + "nextScheduled": "Próximos agendamentos", + "answerNeeded": "Resposta necessária", + "waitingToRetry": "À espera de nova tentativa" }, "transcriptionModel": { "title": "Modelo de transcrição", @@ -3398,7 +3406,6 @@ "template": "Modelo", "namePlaceholder": "explain", "templatePlaceholder": "Explica $ARGUMENTS", - "agent": "Agente", "subtask": "Executar como subtarefa", "edit": "Editar comando de barra", "saveFailed": "Não foi possível guardar o comando", diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index ab0e5a2d00..3dcdd7614e 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -135,7 +135,8 @@ "updated": "Actualizat", "working": "În lucru", "yourOwnRepositories": "Depozitele tale de cod", - "dismissed": "Ignorată" + "dismissed": "Ignorată", + "agent": "Agent" }, "language": { "restarting": "Se repornește aplicația pentru a schimba limba", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Activează activitățile live din setări pentru a vedea agenții activi pe ecranul de blocare.", "openSession": "Deschide", "approveFailed": "Aprobarea nu a reușit. Atinge $t(common.approve) pentru a încerca din nou.", - "newestResult": "Cel mai recent rezultat", - "scheduledWakes": "se trezește {{time}}" + "scheduledWakes": "se trezește {{time}}", + "checked": "Verificat", + "lastKnown": "Ultima stare cunoscută", + "awaitingUpdate": "Așteaptă actualizarea", + "nextRun": "Următoarea rulare", + "waitingForYou": "Te așteaptă", + "nextScheduled": "Următoarele programate", + "answerNeeded": "Așteaptă un răspuns", + "waitingToRetry": "Așteaptă reîncercarea" }, "transcriptionModel": { "title": "Model de transcriere", @@ -3398,7 +3406,6 @@ "template": "Șablon", "namePlaceholder": "explain", "templatePlaceholder": "Explicați $ARGUMENTS", - "agent": "Agent", "subtask": "Rulați ca subsarcină", "edit": "Comanda Editați slash", "saveFailed": "Comanda nu a putut fi salvată", diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index 2e4f090e80..814cec3528 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -135,7 +135,8 @@ "updated": "Обновлено", "working": "В работе", "yourOwnRepositories": "Личные репозитории", - "dismissed": "Отклонено" + "dismissed": "Отклонено", + "agent": "Агент" }, "language": { "restarting": "Перезапуск для смены языка", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Включите «Эфир активности» в настройках, чтобы видеть активных агентов на экране блокировки.", "openSession": "Открыть", "approveFailed": "Не удалось одобрить. Нажмите $t(common.approve), чтобы повторить.", - "newestResult": "Последний результат", - "scheduledWakes": "просыпается {{time}}" + "scheduledWakes": "просыпается {{time}}", + "checked": "Проверено", + "lastKnown": "Последний известный статус", + "awaitingUpdate": "Ожидание обновления", + "nextRun": "Следующий запуск", + "waitingForYou": "Ожидание ваших действий", + "nextScheduled": "Далее по расписанию", + "answerNeeded": "Требуется ответ", + "waitingToRetry": "Ожидание повтора" }, "transcriptionModel": { "title": "Модель распознавания речи", @@ -3439,7 +3447,6 @@ "template": "Шаблон", "namePlaceholder": "explain", "templatePlaceholder": "Объясните $ARGUMENTS", - "agent": "Агент", "subtask": "Запустить как подзадачу", "edit": "Команда редактирования косой черты", "saveFailed": "Не удалось сохранить команду", diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index 6865bed5ab..e9041ce1e4 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -135,7 +135,8 @@ "updated": "යාවත්කාලීන කර ඇත", "working": "ක්‍රියාත්මකයි", "yourOwnRepositories": "ඔබේම කේත ගබඩා", - "dismissed": "ඉවතලා ඇත" + "dismissed": "ඉවතලා ඇත", + "agent": "නියෝජිතයා" }, "language": { "restarting": "භාෂාව යෙදීමට නැවත ආරම්භ කරමින්", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "අගුළු තිරයේ සක්‍රීය නියෝජිතයන් බැලීමට සැකසුම් තුළ සජීවී ක්‍රියාකාරකම් සක්‍රීය කරන්න.", "openSession": "විවෘත කරන්න", "approveFailed": "අනුමත කිරීමට නොහැකි විය. නැවත උත්සාහ කිරීමට $t(common.approve) තට්ටු කරන්න.", - "newestResult": "නවතම ප්‍රතිඵලය", - "scheduledWakes": "{{time}} අවදි වේ" + "scheduledWakes": "{{time}} අවදි වේ", + "checked": "තහවුරු කළේ", + "lastKnown": "අවසන් වරට දන්නා තත්ත්වය", + "awaitingUpdate": "යාවත්කාලීනයක් එනතුරු", + "nextRun": "ඊළඟ ක්‍රියාත්මක වීම", + "waitingForYou": "ඔබේ ප්‍රතිචාරය එනතුරු", + "nextScheduled": "ඊළඟ නියමිත කාර්යයන්", + "answerNeeded": "පිළිතුරක් අවශ්‍යයි", + "waitingToRetry": "නැවත උත්සාහ කිරීමට රැඳී සිටී" }, "transcriptionModel": { "title": "හඬ පෙළට හරවන ආකෘතිය", @@ -3357,7 +3365,6 @@ "template": "සැකිල්ල", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS පැහැදිලි කරන්න", - "agent": "නියෝජිතයා", "subtask": "උප කාර්යයක් ලෙස ධාවනය කරන්න", "edit": "slash විධානය සංස්කරණය කරන්න", "saveFailed": "විධානය සුරැකීමට නොහැකි විය", diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 2acbec3541..2642852a81 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -135,7 +135,8 @@ "updated": "Aktualizované", "working": "Pracuje sa", "yourOwnRepositories": "Tvoje vlastné repozitáre", - "dismissed": "Ignorované" + "dismissed": "Ignorované", + "agent": "agent" }, "language": { "restarting": "Aplikácia sa reštartuje na zmenu jazyka", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Zapni v nastaveniach živé aktivity, aby sa aktívne agenty zobrazovali na zamknutej obrazovke.", "openSession": "Otvoriť", "approveFailed": "Schválenie sa nepodarilo. Klepnutím na $t(common.approve) to skús znova.", - "newestResult": "Najnovší výsledok", - "scheduledWakes": "prebudí sa {{time}}" + "scheduledWakes": "prebudí sa {{time}}", + "checked": "Overené", + "lastKnown": "Posledný známy stav", + "awaitingUpdate": "Čaká na aktualizáciu", + "nextRun": "Ďalšie spustenie", + "waitingForYou": "Čaká na vás", + "nextScheduled": "Ďalšie naplánované", + "answerNeeded": "Čaká na odpoveď", + "waitingToRetry": "Čaká na opakovanie" }, "transcriptionModel": { "title": "Model prepisu", @@ -3439,7 +3447,6 @@ "template": "Šablóna", "namePlaceholder": "explain", "templatePlaceholder": "Vysvetlite $ARGUMENTS", - "agent": "agent", "subtask": "Spustiť ako podúlohu", "edit": "Príkaz na úpravu lomky", "saveFailed": "Príkaz sa nepodarilo uložiť", diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index 5e2ccd0ae7..de81bfbead 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -135,7 +135,8 @@ "updated": "Posodobljeno", "working": "V teku", "yourOwnRepositories": "Tvoji repozitoriji", - "dismissed": "Zavrženo" + "dismissed": "Zavrženo", + "agent": "Agent" }, "language": { "restarting": "Ponovni zagon za spremembo jezika", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "V nastavitvah vklopi dejavnosti v živo, da se aktivni agenti prikažejo na zaklenjenem zaslonu.", "openSession": "Odpri", "approveFailed": "Odobritev ni uspela. Tapni $t(common.approve) za ponovni poskus.", - "newestResult": "Najnovejši rezultat", - "scheduledWakes": "zbudi se {{time}}" + "scheduledWakes": "zbudi se {{time}}", + "checked": "Preverjeno", + "lastKnown": "Nazadnje znano", + "awaitingUpdate": "Čaka na posodobitev", + "nextRun": "Naslednji zagon", + "waitingForYou": "Čaka na vas", + "nextScheduled": "Naslednje načrtovano", + "answerNeeded": "Potreben odgovor", + "waitingToRetry": "Čaka na ponovni poskus" }, "transcriptionModel": { "title": "Model za prepisovanje", @@ -3439,7 +3447,6 @@ "template": "Predloga", "namePlaceholder": "explain", "templatePlaceholder": "Pojasnite $ARGUMENTS", - "agent": "Agent", "subtask": "Zaženi kot podopravilo", "edit": "Uredi ukaz za poševnico", "saveFailed": "Ukaza ni bilo mogoče shraniti", diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 3a9d8066c2..593174cdc2 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -135,7 +135,8 @@ "updated": "La cusboonaysiiyay", "working": "Shaqaynaya", "yourOwnRepositories": "Kaydadkaaga shakhsiga ah", - "dismissed": "La iska dhaafay" + "dismissed": "La iska dhaafay", + "agent": "Wakiil" }, "language": { "restarting": "Dib ayaa loo bilaabayaa si luqadda loo adeegsado", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Ka daar hawlaha tooska ah dejinta si aad wakiillada firfircoon ugu aragto shaashadda qufulka.", "openSession": "Fur", "approveFailed": "Lama oggolaan karin. Taabo $t(common.approve) si aad mar kale isku daydo.", - "newestResult": "Natiijada ugu cusub", - "scheduledWakes": "wuxuu soo toosaa {{time}}" + "scheduledWakes": "wuxuu soo toosaa {{time}}", + "checked": "La hubiyay", + "lastKnown": "Ugu dambayn la ogaa", + "awaitingUpdate": "Cusboonaysiin sugaya", + "nextRun": "Hawlgalka xiga", + "waitingForYou": "Adiga ku sugaya", + "nextScheduled": "Qorshaha xiga", + "answerNeeded": "Jawaab loo baahan yahay", + "waitingToRetry": "Dib-u-tijaabin sugaya" }, "transcriptionModel": { "title": "Moodeelka qoraal u beddelidda", @@ -3357,7 +3365,6 @@ "template": "Tusaale", "namePlaceholder": "explain", "templatePlaceholder": "Sharax $ARGUMENTS", - "agent": "Wakiil", "subtask": "U orda sidii shaqo-hoosaad", "edit": "Tafatir amarka slash", "saveFailed": "Way kaydsan kari wayday amarka", diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 86d9ae1080..68e43f9f97 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -135,7 +135,8 @@ "updated": "Përditësuar", "working": "Duke punuar", "yourOwnRepositories": "Depot e tua", - "dismissed": "E hedhur poshtë" + "dismissed": "E hedhur poshtë", + "agent": "Agjenti" }, "language": { "restarting": "Po riniset për të zbatuar ndryshimin e gjuhës", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Aktivizo aktivitetet në kohë reale në cilësimet e pajisjes për të parë agjentët aktivë në ekranin e kyçjes.", "openSession": "Hap", "approveFailed": "Nuk u arrit të miratohej. Trokit $t(common.approve) për të provuar përsëri.", - "newestResult": "Rezultati më i ri", - "scheduledWakes": "zgjohet {{time}}" + "scheduledWakes": "zgjohet {{time}}", + "checked": "Kontrolluar", + "lastKnown": "Gjendja e fundit e njohur", + "awaitingUpdate": "Në pritje të përditësimit", + "nextRun": "Ekzekutimi i radhës", + "waitingForYou": "Pret ty", + "nextScheduled": "Të planifikuara në vijim", + "answerNeeded": "Pret përgjigje", + "waitingToRetry": "Në pritje për të riprovuar" }, "transcriptionModel": { "title": "Modeli i transkriptimit", @@ -3357,7 +3365,6 @@ "template": "shabllon", "namePlaceholder": "explain", "templatePlaceholder": "Shpjegoni $ARGUMENTS", - "agent": "Agjenti", "subtask": "Ekzekuto si nëndetyrë", "edit": "Redakto komandën e pjerrët", "saveFailed": "Komanda nuk mund të ruhej", diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 0fde589639..ced3fb7270 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -135,7 +135,8 @@ "updated": "Ažurirano", "working": "U radu", "yourOwnRepositories": "Tvoji repozitorijumi", - "dismissed": "Odbačeno" + "dismissed": "Odbačeno", + "agent": "Agent" }, "language": { "restarting": "Ponovno pokretanje radi promene jezika", @@ -3196,8 +3197,15 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u podešavanjima da vidiš aktivne agente na zaključanom ekranu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspelo. Dodirni $t(common.approve) da pokušaš ponovo.", - "newestResult": "Najnoviji rezultat", - "scheduledWakes": "budi se {{time}}" + "scheduledWakes": "budi se {{time}}", + "checked": "Provereno", + "lastKnown": "Poslednje poznato", + "awaitingUpdate": "Čeka ažuriranje", + "nextRun": "Sledeće pokretanje", + "waitingForYou": "Čeka vas", + "nextScheduled": "Sledeće zakazano", + "answerNeeded": "Potreban odgovor", + "waitingToRetry": "Čeka ponovni pokušaj" }, "transcriptionModel": { "title": "Model za transkripciju", @@ -3398,7 +3406,6 @@ "template": "Šablon", "namePlaceholder": "explain", "templatePlaceholder": "Objasnite $ARGUMENTS", - "agent": "Agent", "subtask": "Pokreni kao podzadatak", "edit": "Naredba za uređivanje kose crte", "saveFailed": "Nije moguće sačuvati komandu", diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 21985dc0cb..6c7c9b0582 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -135,7 +135,8 @@ "updated": "Uppdaterad", "working": "Arbetar", "yourOwnRepositories": "Dina egna kodarkiv", - "dismissed": "Avskriven" + "dismissed": "Avskriven", + "agent": "Ombud" }, "language": { "restarting": "Startar om för att byta språk", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Aktivera liveaktiviteter i Inställningar för att se aktiva agenter på låsskärmen.", "openSession": "Öppna", "approveFailed": "Det gick inte att godkänna. Tryck på $t(common.approve) för att försöka igen.", - "newestResult": "Nyaste resultatet", - "scheduledWakes": "vaknar {{time}}" + "scheduledWakes": "vaknar {{time}}", + "checked": "Kontrollerad", + "lastKnown": "Senast kända", + "awaitingUpdate": "Väntar på uppdatering", + "nextRun": "Nästa körning", + "waitingForYou": "Väntar på dig", + "nextScheduled": "Nästa schemalagda", + "answerNeeded": "Väntar på svar", + "waitingToRetry": "Väntar på nytt försök" }, "transcriptionModel": { "title": "Transkriberingsmodell", @@ -3357,7 +3365,6 @@ "template": "Mall", "namePlaceholder": "explain", "templatePlaceholder": "Förklara $ARGUMENTS", - "agent": "Ombud", "subtask": "Kör som deluppgift", "edit": "Redigera snedstreck kommando", "saveFailed": "Det gick inte att spara kommandot", diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index 88aaefa7c4..ce3e241ddf 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -135,7 +135,8 @@ "updated": "Imesasishwa", "working": "Inafanya kazi", "yourOwnRepositories": "Hazina zako mwenyewe", - "dismissed": "Imepuuzwa" + "dismissed": "Imepuuzwa", + "agent": "Wakala" }, "language": { "restarting": "Programu inaanza upya ili kutumia lugha", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Washa shughuli za wakati halisi kwenye mipangilio ili uone mawakala wanaofanya kazi kwenye skrini iliyofungwa.", "openSession": "Fungua", "approveFailed": "Imeshindikana kuidhinisha. Gusa $t(common.approve) ili ujaribu tena.", - "newestResult": "Matokeo mapya zaidi", - "scheduledWakes": "itaamka {{time}}" + "scheduledWakes": "itaamka {{time}}", + "checked": "Imethibitishwa", + "lastKnown": "Hali ya mwisho iliyojulikana", + "awaitingUpdate": "Inasubiri sasisho", + "nextRun": "Utekelezaji unaofuata", + "waitingForYou": "Inakusubiri", + "nextScheduled": "Zilizopangwa zinazofuata", + "answerNeeded": "Inasubiri jibu", + "waitingToRetry": "Inasubiri kujaribu tena" }, "transcriptionModel": { "title": "Modeli ya unukuzi", @@ -3357,7 +3365,6 @@ "template": "Kiolezo", "namePlaceholder": "explain", "templatePlaceholder": "Eleza $ARGUMENTS", - "agent": "Wakala", "subtask": "Endesha kama kazi ndogo", "edit": "Hariri amri ya kufyeka", "saveFailed": "Haikuweza kuhifadhi amri", diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index 1c62d738fa..d9b8b557a8 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -135,7 +135,8 @@ "updated": "புதுப்பிக்கப்பட்டது", "working": "பணியில்", "yourOwnRepositories": "உங்கள் சொந்தக் களஞ்சியங்கள்", - "dismissed": "புறக்கணிக்கப்பட்டது" + "dismissed": "புறக்கணிக்கப்பட்டது", + "agent": "முகவர்" }, "language": { "restarting": "மொழி மாற்றத்தைப் பயன்படுத்த மறுதொடக்கம் செய்கிறது", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "பூட்டுத் திரையில் செயலில் உள்ள ஏஜெண்டுகளைப் பார்க்க, அமைப்புகளில் நேரலைச் செயல்பாடுகளை இயக்கவும்.", "openSession": "திற", "approveFailed": "ஒப்புதல் அளிக்க முடியவில்லை. மீண்டும் முயற்சிக்க $t(common.approve) தட்டவும்.", - "newestResult": "சமீபத்திய முடிவு", - "scheduledWakes": "{{time}} விழிக்கும்" + "scheduledWakes": "{{time}} விழிக்கும்", + "checked": "சரிபார்க்கப்பட்டது", + "lastKnown": "கடைசியாக அறியப்பட்ட நிலை", + "awaitingUpdate": "புதுப்பிப்புக்குக் காத்திருக்கிறது", + "nextRun": "அடுத்த இயக்கம்", + "waitingForYou": "உங்களுக்காகக் காத்திருக்கிறது", + "nextScheduled": "அடுத்து திட்டமிடப்பட்டவை", + "answerNeeded": "பதில் தேவை", + "waitingToRetry": "மீண்டும் முயலக் காத்திருக்கிறது" }, "transcriptionModel": { "title": "உரையாக்க மாதிரி", @@ -3357,7 +3365,6 @@ "template": "டெம்ப்ளேட்", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTSஐ விளக்கவும்", - "agent": "முகவர்", "subtask": "துணைப் பணியாக இயக்கவும்", "edit": "ஸ்லாஷ் கட்டளையைத் திருத்தவும்", "saveFailed": "கட்டளையைச் சேமிக்க முடியவில்லை", diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index 29c79ff779..af5912da04 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -135,7 +135,8 @@ "updated": "నవీకరించారు", "working": "పని జరుగుతోంది", "yourOwnRepositories": "మీ స్వంత రిపోజిటరీలు", - "dismissed": "విస్మరించారు" + "dismissed": "విస్మరించారు", + "agent": "ఏజెంట్" }, "language": { "restarting": "ఎంచుకున్న భాషను వర్తింపజేయడానికి పునఃప్రారంభిస్తోంది", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "లాక్ స్క్రీన్‌పై పనిచేస్తున్న ఏజెంట్లను చూడటానికి సెట్టింగ్‌లలో లైవ్ యాక్టివిటీస్‌ను ఆన్ చేయండి.", "openSession": "తెరవండి", "approveFailed": "ఆమోదించలేకపోయాము. మళ్లీ ప్రయత్నించడానికి $t(common.approve) ని నొక్కండి.", - "newestResult": "సరికొత్త ఫలితం", - "scheduledWakes": "{{time}} మేల్కొంటుంది" + "scheduledWakes": "{{time}} మేల్కొంటుంది", + "checked": "తనిఖీ చేయబడింది", + "lastKnown": "చివరిగా తెలిసిన స్థితి", + "awaitingUpdate": "అప్‌డేట్ కోసం వేచి ఉంది", + "nextRun": "తదుపరి అమలు", + "waitingForYou": "మీ కోసం వేచి ఉంది", + "nextScheduled": "తదుపరి షెడ్యూల్", + "answerNeeded": "మీ సమాధానం అవసరం", + "waitingToRetry": "మళ్లీ ప్రయత్నించడానికి వేచి ఉంది" }, "transcriptionModel": { "title": "వచనంగా మార్చే మోడల్", @@ -3357,7 +3365,6 @@ "template": "మూస", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTSని వివరించండి", - "agent": "ఏజెంట్", "subtask": "సబ్‌టాస్క్‌గా అమలు చేయండి", "edit": "స్లాష్ ఆదేశాన్ని సవరించండి", "saveFailed": "ఆదేశాన్ని సేవ్ చేయడం సాధ్యపడలేదు", diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index e37e4caa8a..52e5b66339 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -135,7 +135,8 @@ "updated": "อัปเดตแล้ว", "working": "กำลังทำงาน", "yourOwnRepositories": "คลังโค้ดของคุณเอง", - "dismissed": "ละเว้นแล้ว" + "dismissed": "ละเว้นแล้ว", + "agent": "ตัวแทน" }, "language": { "restarting": "กำลังเริ่มแอปใหม่เพื่อเปลี่ยนภาษา", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "เปิดกิจกรรมสดในการตั้งค่าเพื่อดูเอเจนต์ที่กำลังทำงานบนหน้าจอล็อก", "openSession": "เปิด", "approveFailed": "อนุมัติไม่สำเร็จ แตะ $t(common.approve) เพื่อลองอีกครั้ง", - "newestResult": "ผลลัพธ์ล่าสุด", - "scheduledWakes": "ตื่น {{time}}" + "scheduledWakes": "ตื่น {{time}}", + "checked": "ตรวจสอบล่าสุด", + "lastKnown": "สถานะล่าสุดที่ทราบ", + "awaitingUpdate": "รออัปเดต", + "nextRun": "ทำงานครั้งถัดไป", + "waitingForYou": "รอคุณ", + "nextScheduled": "กำหนดการถัดไป", + "answerNeeded": "รอคำตอบ", + "waitingToRetry": "รอลองใหม่" }, "transcriptionModel": { "title": "โมเดลถอดเสียง", @@ -3357,7 +3365,6 @@ "template": "แม่แบบ", "namePlaceholder": "explain", "templatePlaceholder": "อธิบาย $ARGUMENTS", - "agent": "ตัวแทน", "subtask": "ทำงานเป็นงานย่อย", "edit": "แก้ไขคำสั่งสแลช", "saveFailed": "ไม่สามารถบันทึกคำสั่งได้", diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index cadb00a8cc..2c14ae3d9c 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -135,7 +135,8 @@ "updated": "Güncellendi", "working": "Çalışıyor", "yourOwnRepositories": "Kendi depoların", - "dismissed": "Yok sayıldı" + "dismissed": "Yok sayıldı", + "agent": "Ajan" }, "language": { "restarting": "Dil değişikliğini uygulamak için yeniden başlatılıyor", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Etkin ajanları kilit ekranında görmek için ayarlardan Canlı Etkinlikler özelliğini aç.", "openSession": "Aç", "approveFailed": "Onaylanamadı. Tekrar denemek için $t(common.approve) öğesine dokun.", - "newestResult": "En yeni sonuç", - "scheduledWakes": "{{time}} uyanır" + "scheduledWakes": "{{time}} uyanır", + "checked": "Kontrol edildi", + "lastKnown": "Son bilinen", + "awaitingUpdate": "Güncelleme bekleniyor", + "nextRun": "Sonraki çalışma", + "waitingForYou": "Sizi bekliyor", + "nextScheduled": "Sıradaki zamanlananlar", + "answerNeeded": "Yanıt bekliyor", + "waitingToRetry": "Yeniden deneme bekleniyor" }, "transcriptionModel": { "title": "Metne dönüştürme modeli", @@ -3357,7 +3365,6 @@ "template": "Şablon", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS'ı açıklayın", - "agent": "Ajan", "subtask": "Alt görev olarak çalıştır", "edit": "Eğik çizgi komutunu düzenle", "saveFailed": "Komut kaydedilemedi", diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 2f47a07954..349a4866f6 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -135,7 +135,8 @@ "updated": "Оновлено", "working": "У роботі", "yourOwnRepositories": "Власні репозиторії", - "dismissed": "Відхилено" + "dismissed": "Відхилено", + "agent": "Агент" }, "language": { "restarting": "Перезапуск для зміни мови", @@ -3237,8 +3238,15 @@ "activityKitDisabledBody": "Увімкніть «Дії наживо» в «Параметрах», щоб бачити активних агентів на замкненому екрані.", "openSession": "Відкрити", "approveFailed": "Не вдалося схвалити. Торкніться $t(common.approve), щоб спробувати ще раз.", - "newestResult": "Найновіший результат", - "scheduledWakes": "прокидається {{time}}" + "scheduledWakes": "прокидається {{time}}", + "checked": "Перевірено", + "lastKnown": "Останній відомий стан", + "awaitingUpdate": "Очікує оновлення", + "nextRun": "Наступний запуск", + "waitingForYou": "Очікує на вас", + "nextScheduled": "Наступні заплановані", + "answerNeeded": "Очікує відповіді", + "waitingToRetry": "Очікує повторної спроби" }, "transcriptionModel": { "title": "Модель розпізнавання мовлення", @@ -3439,7 +3447,6 @@ "template": "Шаблон", "namePlaceholder": "explain", "templatePlaceholder": "Поясніть $ARGUMENTS", - "agent": "Агент", "subtask": "Запуск як підзавдання", "edit": "Редагувати команду косої риски", "saveFailed": "Не вдалося зберегти команду", diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index a49f67066e..6faeb0d220 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -135,7 +135,8 @@ "updated": "اپ ڈیٹ کیا گیا", "working": "کام جاری ہے", "yourOwnRepositories": "آپ کی اپنی ریپوزٹریز", - "dismissed": "نظر انداز کردہ" + "dismissed": "نظر انداز کردہ", + "agent": "ایجنٹ" }, "language": { "restarting": "منتخب زبان لاگو کرنے کے لیے ایپ دوبارہ شروع ہو رہی ہے", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "لاک اسکرین پر فعال ایجنٹس دیکھنے کے لیے ترتیبات میں لائیو سرگرمیاں فعال کریں۔", "openSession": "کھولیں", "approveFailed": "منظور نہیں کیا جا سکا۔ دوبارہ کوشش کرنے کے لیے $t(common.approve) پر ٹیپ کریں۔", - "newestResult": "تازہ ترین نتیجہ", - "scheduledWakes": "{{time}} بیدار ہوگا" + "scheduledWakes": "{{time}} بیدار ہوگا", + "checked": "آخری تصدیق", + "lastKnown": "آخری معلوم حالت", + "awaitingUpdate": "اپ ڈیٹ کا انتظار", + "nextRun": "اگلا اجرا", + "waitingForYou": "آپ کا انتظار", + "nextScheduled": "آئندہ شیڈول", + "answerNeeded": "جواب درکار", + "waitingToRetry": "دوبارہ کوشش کا انتظار" }, "transcriptionModel": { "title": "متن میں بدلنے کا ماڈل", @@ -3357,7 +3365,6 @@ "template": "سانچہ", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS کی وضاحت کریں۔", - "agent": "ایجنٹ", "subtask": "ذیلی کام کے طور پر چلائیں۔", "edit": "سلیش کمانڈ میں ترمیم کریں۔", "saveFailed": "کمانڈ کو محفوظ نہیں کیا جا سکا", diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index ee8474c7be..50109a4775 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -135,7 +135,8 @@ "updated": "Yangilangan", "working": "Ishlamoqda", "yourOwnRepositories": "O‘zingizga tegishli repozitoriylar", - "dismissed": "E’tiborsiz qoldirilgan" + "dismissed": "E’tiborsiz qoldirilgan", + "agent": "Agent" }, "language": { "restarting": "Tilni qo'llash uchun qayta ishga tushirilmoqda", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Qulflangan ekranda faol agentlarni ko'rish uchun sozlamalarda jonli faoliyatlarni yoqing.", "openSession": "Ochish", "approveFailed": "Tasdiqlab bo'lmadi. Qayta urinish uchun $t(common.approve) ga teging.", - "newestResult": "Eng yangi natija", - "scheduledWakes": "{{time}} uyg'onadi" + "scheduledWakes": "{{time}} uyg'onadi", + "checked": "Tekshirilgan", + "lastKnown": "So‘nggi ma’lum holat", + "awaitingUpdate": "Yangilanish kutilmoqda", + "nextRun": "Keyingi ishga tushish", + "waitingForYou": "Sizni kutmoqda", + "nextScheduled": "Keyingi rejalashtirilganlar", + "answerNeeded": "Javob kutilmoqda", + "waitingToRetry": "Qayta urinish kutilmoqda" }, "transcriptionModel": { "title": "Matnga aylantirish modeli", @@ -3357,7 +3365,6 @@ "template": "Shablon", "namePlaceholder": "explain", "templatePlaceholder": "$ARGUMENTS ni tushuntiring", - "agent": "Agent", "subtask": "Quyi vazifa sifatida ishga tushirish", "edit": "Slash buyrug'ini tahrirlash", "saveFailed": "Buyruq saqlanmadi", diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 95c22014b9..7207bf1d89 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -135,7 +135,8 @@ "updated": "Đã cập nhật", "working": "Đang xử lý", "yourOwnRepositories": "Kho mã nguồn của riêng bạn", - "dismissed": "Đã bỏ qua" + "dismissed": "Đã bỏ qua", + "agent": "Đại lý" }, "language": { "restarting": "Đang khởi động lại để áp dụng ngôn ngữ", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Bật Hoạt động trực tiếp trong Cài đặt để xem các tác nhân đang hoạt động trên màn hình khóa.", "openSession": "Mở", "approveFailed": "Không thể phê duyệt. Nhấn $t(common.approve) để thử lại.", - "newestResult": "Kết quả mới nhất", - "scheduledWakes": "thức dậy {{time}}" + "scheduledWakes": "thức dậy {{time}}", + "checked": "Đã kiểm tra", + "lastKnown": "Ghi nhận gần nhất", + "awaitingUpdate": "Đang chờ cập nhật", + "nextRun": "Lần chạy tiếp theo", + "waitingForYou": "Đang chờ bạn", + "nextScheduled": "Lịch tiếp theo", + "answerNeeded": "Cần trả lời", + "waitingToRetry": "Đang chờ thử lại" }, "transcriptionModel": { "title": "Mô hình chuyển giọng nói thành văn bản", @@ -3357,7 +3365,6 @@ "template": "Bản mẫu", "namePlaceholder": "explain", "templatePlaceholder": "Giải thích $ARGUMENTS", - "agent": "Đại lý", "subtask": "Chạy dưới dạng nhiệm vụ phụ", "edit": "Chỉnh sửa lệnh gạch chéo", "saveFailed": "Không thể lưu lệnh", diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index 75b4c168d6..8be8a9968c 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -135,7 +135,8 @@ "updated": "A ti ṣe ìmúdójúìwọ̀n", "working": "Ń ṣiṣẹ́", "yourOwnRepositories": "Àwọn ibi ìpamọ́ kóòdù tìrẹ", - "dismissed": "A ti kọ̀ sílẹ̀" + "dismissed": "A ti kọ̀ sílẹ̀", + "agent": "Aṣoju" }, "language": { "restarting": "A ń tún bẹ̀rẹ̀ láti lo èdè tí o yàn", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Tan àwọn ìgbòkègbodò ìsinsìnyí nínú àwọn ètò láti rí àwọn aṣojú tó ń ṣiṣẹ́ lórí ojú ìwé títìpa.", "openSession": "Ṣí", "approveFailed": "A kò lè fọwọ́ sí. Tẹ $t(common.approve) láti tún gbìyànjú.", - "newestResult": "Àbájáde tuntun jùlọ", - "scheduledWakes": "yoo ji {{time}}" + "scheduledWakes": "yoo ji {{time}}", + "checked": "Ti ṣàyẹ̀wò", + "lastKnown": "Tí a mọ̀ kẹ́yìn", + "awaitingUpdate": "Ń dúró de ìmúdójúìwọ̀n", + "nextRun": "Ìṣiṣẹ́ tó kàn", + "waitingForYou": "Ń dúró dè ọ́", + "nextScheduled": "Àwọn ètò tó kàn", + "answerNeeded": "Nílò èsì", + "waitingToRetry": "Ń dúró láti tún gbìyànjú" }, "transcriptionModel": { "title": "Àwòṣe ìyípadà ohùn sí ọ̀rọ̀", @@ -3357,7 +3365,6 @@ "template": "Àdàkọ", "namePlaceholder": "explain", "templatePlaceholder": "Ṣe alaye $ARGUMENTS", - "agent": "Aṣoju", "subtask": "Ṣiṣe bi iṣẹ-ṣiṣe", "edit": "Ṣatunkọ pipaṣẹ idinku", "saveFailed": "Ko le fi aṣẹ pamọ", diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 0ed511f532..4afe83d24a 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -135,7 +135,8 @@ "updated": "已更新", "working": "处理中", "yourOwnRepositories": "你自己的仓库", - "dismissed": "已忽略" + "dismissed": "已忽略", + "agent": "代理人" }, "language": { "restarting": "正在重启以应用语言设置", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "请在设置中开启实时活动,在锁定屏幕上查看运行中的智能体。", "openSession": "打开", "approveFailed": "无法批准。点按$t(common.approve)重试。", - "newestResult": "最新结果", - "scheduledWakes": "{{time}}唤醒" + "scheduledWakes": "{{time}}唤醒", + "checked": "上次确认", + "lastKnown": "上次已知状态", + "awaitingUpdate": "等待更新", + "nextRun": "下次运行", + "waitingForYou": "等待你处理", + "nextScheduled": "后续计划", + "answerNeeded": "等待回答", + "waitingToRetry": "等待重试" }, "transcriptionModel": { "title": "转写模型", @@ -3357,7 +3365,6 @@ "template": "模板", "namePlaceholder": "explain", "templatePlaceholder": "解释$ARGUMENTS", - "agent": "代理人", "subtask": "作为子任务运行", "edit": "编辑斜杠命令", "saveFailed": "无法保存命令", diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 4265732360..689e362c66 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -135,7 +135,8 @@ "updated": "已更新", "working": "處理中", "yourOwnRepositories": "你自己的存放庫", - "dismissed": "已忽略" + "dismissed": "已忽略", + "agent": "代理人" }, "language": { "restarting": "正在重新啟動以套用語言", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "請在「設定」中開啟「即時動態」,即可在鎖定畫面查看執行中的代理程式。", "openSession": "開啟", "approveFailed": "無法核准。點一下$t(common.approve)再試一次。", - "newestResult": "最新結果", - "scheduledWakes": "{{time}}喚醒" + "scheduledWakes": "{{time}}喚醒", + "checked": "上次確認", + "lastKnown": "最後已知", + "awaitingUpdate": "等待更新", + "nextRun": "下次執行", + "waitingForYou": "等待你回應", + "nextScheduled": "下次排程", + "answerNeeded": "等待回答", + "waitingToRetry": "等待重試" }, "transcriptionModel": { "title": "轉錄模型", @@ -3357,7 +3365,6 @@ "template": "範本", "namePlaceholder": "explain", "templatePlaceholder": "解釋$ARGUMENTS", - "agent": "代理人", "subtask": "作為子任務運行", "edit": "編輯斜槓命令", "saveFailed": "無法保存命令", diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index 794360b1ba..a79b021fd8 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -135,7 +135,8 @@ "updated": "Kubuyekezwe", "working": "Kuyasebenza", "yourOwnRepositories": "Izinqolobane zakho zekhodi", - "dismissed": "Kuchithiwe" + "dismissed": "Kuchithiwe", + "agent": "Umenzeli" }, "language": { "restarting": "Kuqaliswa kabusha ukuze kusetshenziswe ulimi olukhethiwe", @@ -3155,8 +3156,15 @@ "activityKitDisabledBody": "Vula imisebenzi ebukhoma kuzilungiselelo ukuze ubone ama-ejenti asebenzayo esikrinini esikhiyiwe.", "openSession": "Vula", "approveFailed": "Asikwazanga ukugunyaza. Thepha $t(common.approve) ukuze uzame futhi.", - "newestResult": "Umphumela wakamuva", - "scheduledWakes": "ivuka {{time}}" + "scheduledWakes": "ivuka {{time}}", + "checked": "Kuhloliwe", + "lastKnown": "Okwagcina kwaziwa", + "awaitingUpdate": "Kulinde isibuyekezo", + "nextRun": "Ukuqalisa okulandelayo", + "waitingForYou": "Kulinde wena", + "nextScheduled": "Okulandelayo okuhleliwe", + "answerNeeded": "Kudinga impendulo", + "waitingToRetry": "Kulinde ukuzama futhi" }, "transcriptionModel": { "title": "Imodeli yokuguqula inkulumo", @@ -3357,7 +3365,6 @@ "template": "Isifanekiso", "namePlaceholder": "explain", "templatePlaceholder": "Chaza $ARGUMENTS", - "agent": "Umenzeli", "subtask": "Qalisa njengomsebenzi ongaphansi", "edit": "Hlela umyalo we-slash", "saveFailed": "Ayikwazanga ukulondoloza umyalo", diff --git a/apps/mobile/src/lib/glanceable/cleanup.ts b/apps/mobile/src/lib/glanceable/cleanup.ts index ca36f60061..3019dfedc8 100644 --- a/apps/mobile/src/lib/glanceable/cleanup.ts +++ b/apps/mobile/src/lib/glanceable/cleanup.ts @@ -4,6 +4,7 @@ import { type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { clearHomeWidgetData } from './home-widget-data'; import { getLastGlanceableSnapshot } from './persist'; import { withStatus } from './publisher'; import { forEachSink, getGlanceableDelivery } from './sink-registry'; @@ -100,6 +101,7 @@ function writeTerminalAndEnd(status: 'signed_out' | 'privacy'): void { // Arm the publisher gate before any sink writes, so a cache success that // lands during this window can never emit for the torn-down session. terminalBlankEpoch += 1; + clearHomeWidgetData(); getGlanceableDelivery().cleanupTokens('scope'); const snapshot = buildTerminalSnapshot(status); // Write the snapshot first, then end: the surface shows the terminal copy diff --git a/apps/mobile/src/lib/glanceable/fixture-catalog.test.ts b/apps/mobile/src/lib/glanceable/fixture-catalog.test.ts deleted file mode 100644 index 5d6285f772..0000000000 --- a/apps/mobile/src/lib/glanceable/fixture-catalog.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- the fixture list lives outside src for shell loops, so it is read from disk */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { GLANCEABLE_FIXTURES } from './fixture-catalog'; - -const LIST = JSON.parse( - readFileSync(join(__dirname, '../../../scripts/glanceable-fixtures.json'), 'utf8') -) as { fixtures: { name: string; description: string }[] }; - -describe('glanceable fixture list', () => { - it('matches the catalog name for name, in order', () => { - expect(LIST.fixtures).toEqual( - Object.entries(GLANCEABLE_FIXTURES).map(([name, fixture]) => ({ - name, - description: fixture.description, - })) - ); - }); -}); diff --git a/apps/mobile/src/lib/glanceable/fixture-catalog.ts b/apps/mobile/src/lib/glanceable/fixture-catalog.ts index 43bd09dc7d..d35ba8eca6 100644 --- a/apps/mobile/src/lib/glanceable/fixture-catalog.ts +++ b/apps/mobile/src/lib/glanceable/fixture-catalog.ts @@ -10,7 +10,7 @@ import { type GlanceableActionFeedback } from './surface-extras'; * relative to the apply moment so relative copy ("28 min") looks real. * * `apps/mobile/scripts/glanceable-fixtures.json` lists the names and - * descriptions; `fixture-catalog.test.ts` keeps the two in sync. + * descriptions for capture scripts; regenerate it after a catalog change. */ export type GlanceableFixtureRow = { @@ -27,6 +27,8 @@ export type GlanceableFixture = { description: string; /** Overrides the happy/empty derivation, exactly as the publisher does. */ status?: Exclude; + /** Minutes since the last successful confirmation. */ + checkedAgo?: number; rows?: (now: number) => GlanceableFixtureRow[]; actionFeedback?: Exclude; }; @@ -84,6 +86,25 @@ function mixedRows( ]; } +const COMBINATION_STATUS = { + needsInput: 'permission', + running: 'busy', + scheduled: 'scheduled', + idle: 'idle', +} as const; + +function combinationRows( + kinds: readonly (keyof typeof COMBINATION_STATUS)[] +): (now: number) => GlanceableFixtureRow[] { + return now => + kinds.map(kind => ({ + status: COMBINATION_STATUS[kind], + ago: 12, + title: kind === 'scheduled' ? 'Nightly dependency audit' : 'Review authentication changes', + ...(kind === 'scheduled' ? { scheduledAt: roundWake(now) } : {}), + })); +} + export const GLANCEABLE_FIXTURES = { 'signed-out': { description: 'Signed out: the sign-in copy, no counts, no actions.', @@ -101,12 +122,15 @@ export const GLANCEABLE_FIXTURES = { status: 'privacy', }, expired: { - description: 'Expired: counts dropped, the expired copy.', + description: 'Expired activity: the Home widget retains last-known mixed work.', status: 'expired', + checkedAgo: 600, + rows: now => mixedRows(now, 'running'), }, stale: { - description: 'Delayed: the mixed counts under the updates-delayed copy.', + description: 'Delayed: mixed work retains its last successful confirmation time.', status: 'stale', + checkedAgo: 31, rows: now => mixedRows(now, 'running'), }, 'needs-approval': { @@ -203,6 +227,100 @@ export const GLANCEABLE_FIXTURES = { description: 'Mixed counts; newest change is idle (large footer).', rows: now => mixedRows(now, 'idle'), }, + 'scheduled-no-time': { + description: 'Scheduled agents without a usable wake time.', + rows: () => [ + { status: 'scheduled', ago: 12, title: 'Dependency audit' }, + { status: 'scheduled', ago: 30, title: 'Usage report' }, + ], + }, + 'scheduled-overdue': { + description: 'The earliest scheduled wake passed without confirmation; never invent working.', + rows: now => [ + { + status: 'scheduled', + ago: 35, + title: 'Dependency audit', + scheduledAt: new Date(now - 15 * MINUTE_MS).toISOString(), + }, + { status: 'idle', ago: 50, title: 'Review documentation' }, + ], + }, + 'scheduled-tomorrow': { + description: 'Three future runs show non-today local dates and times.', + rows: now => + [1, 2, 3].map(day => ({ + status: 'scheduled', + ago: 20, + title: + ['Nightly dependency audit', 'Weekly usage report', 'Release readiness review'][ + day - 1 + ] ?? '', + scheduledAt: roundWake(now + day * 24 * 60 * MINUTE_MS), + })), + }, + 'retry-only': { + description: 'Needs input from provider retry states, with no permission to approve.', + rows: () => [{ status: 'retry', ago: 15, title: 'Recover the interrupted build' }], + }, + untitled: { + description: 'Waiting and scheduled rows without titles use a localized generic label.', + rows: now => [ + { status: 'question', ago: 5, title: '' }, + { status: 'scheduled', ago: 8, title: '', scheduledAt: roundWake(now) }, + ], + }, + 'stale-idle': { + description: 'Old idle work remains useful without claiming current confirmation.', + checkedAgo: 600, + status: 'stale', + rows: () => [{ status: 'idle', ago: 30, title: 'Review the onboarding copy' }], + }, + 'stale-empty': { + description: 'A last-known empty state retains creation and an honest timestamp.', + checkedAgo: 180, + status: 'stale', + }, + 'mix-input-running': { + description: 'Needs input and working.', + rows: combinationRows(['needsInput', 'running']), + }, + 'mix-input-scheduled': { + description: 'Needs input and scheduled.', + rows: combinationRows(['needsInput', 'scheduled']), + }, + 'mix-input-idle': { + description: 'Needs input and idle.', + rows: combinationRows(['needsInput', 'idle']), + }, + 'mix-running-scheduled': { + description: 'Working and scheduled.', + rows: combinationRows(['running', 'scheduled']), + }, + 'mix-running-idle': { + description: 'Working and idle.', + rows: combinationRows(['running', 'idle']), + }, + 'mix-scheduled-idle': { + description: 'Scheduled and idle; the wake time takes priority over idle details.', + rows: combinationRows(['scheduled', 'idle']), + }, + 'mix-input-running-scheduled': { + description: 'Needs input, working, and scheduled.', + rows: combinationRows(['needsInput', 'running', 'scheduled']), + }, + 'mix-input-running-idle': { + description: 'Needs input, working, and idle.', + rows: combinationRows(['needsInput', 'running', 'idle']), + }, + 'mix-input-scheduled-idle': { + description: 'Needs input, scheduled, and idle.', + rows: combinationRows(['needsInput', 'scheduled', 'idle']), + }, + 'mix-running-scheduled-idle': { + description: 'Working, scheduled, and idle.', + rows: combinationRows(['running', 'scheduled', 'idle']), + }, } satisfies Record; export type GlanceableFixtureName = keyof typeof GLANCEABLE_FIXTURES; diff --git a/apps/mobile/src/lib/glanceable/fixture-harness.ts b/apps/mobile/src/lib/glanceable/fixture-harness.ts index 99a40e04b1..2af4e78fe1 100644 --- a/apps/mobile/src/lib/glanceable/fixture-harness.ts +++ b/apps/mobile/src/lib/glanceable/fixture-harness.ts @@ -3,8 +3,10 @@ import { GLANCEABLE_TERMINAL_MS, isEligibleGlanceableWork, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { buildHomeWidgetDetails } from '@kilocode/app-shared/home-widget'; import { CLOUD_AGENT_CONNECTION_ID } from '@/lib/active-sessions-live'; +import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; import { GLANCEABLE_FIXTURES, @@ -12,7 +14,18 @@ import { type GlanceableFixtureName, isGlanceableFixtureName, } from './fixture-catalog'; -import { holdGlanceableFixture, releaseGlanceableFixtureHold } from './fixture-hold'; +import { getTerminalBlankEpoch } from './cleanup'; +import { + holdGlanceableFixture, + isGlanceableFixtureHeld, + releaseGlanceableFixtureHold, +} from './fixture-hold'; +import { + clearHomeWidgetData, + rememberHomeWidgetData, + setHomeWidgetDetails, +} from './home-widget-data'; +import { clearHomeWidgetRefresh, HomeWidgetRefresh } from './home-widget-refresh'; import { newestSessionTitle } from './newest-session'; import { getLastGlanceableSnapshot } from './persist'; import { type GlanceablePublisherContext } from './publisher'; @@ -37,6 +50,7 @@ let scope: GlanceablePublisherContext | null = null; /** A fixture requested before a scope existed (cold launch, restore in flight). */ let pendingName: GlanceableFixtureName | null = null; let terminalTimer: ReturnType | null = null; +let fixtureGeneration = 0; function log(message: string): void { // eslint-disable-next-line no-console -- dev-only harness feedback in the Metro log @@ -50,9 +64,29 @@ function cancelTerminal(): void { } } -function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContext): void { +async function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContext): Promise { + fixtureGeneration += 1; + const generation = fixtureGeneration; + const authEpoch = currentAuthEpoch(); + const blankEpoch = getTerminalBlankEpoch(); + await HomeWidgetRefresh?.setFixtureMode(true); + const superseded = () => + generation !== fixtureGeneration || + !isGlanceableFixtureHeld() || + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch || + scope?.userId !== ctx.userId || + scope.organizationId !== ctx.organizationId; + if (superseded()) { + return; + } const fixture: GlanceableFixture = GLANCEABLE_FIXTURES[name]; cancelTerminal(); + clearHomeWidgetData(); + await clearHomeWidgetRefresh(); + if (superseded()) { + return; + } const now = Date.now(); // Ids and the cloud connection let the waiting-ask selection name a session, // so the Live Activity and the ongoing card offer Approve like a real ask. @@ -66,14 +100,27 @@ function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContext): vo })); // Seeded from the persisted revision, which is never below what any sink // accepted, so the native surfaces take the frame instead of discarding it. - const snapshot = buildGlanceableSnapshot({ + const homeSnapshot = buildGlanceableSnapshot({ sessions: rows, userId: ctx.userId, organizationId: ctx.organizationId, - now, + now: now - (fixture.checkedAgo ?? 0) * 60_000, previousRevision: getLastGlanceableSnapshot()?.revision ?? 0, - status: fixture.status, }); + const details = buildHomeWidgetDetails(rows); + setHomeWidgetDetails(details); + rememberHomeWidgetData({ snapshot: homeSnapshot, details }); + const snapshot = + fixture.status === 'expired' + ? buildGlanceableSnapshot({ + sessions: [], + userId: ctx.userId, + organizationId: ctx.organizationId, + now, + previousRevision: homeSnapshot.revision, + status: 'expired', + }) + : { ...homeSnapshot, status: fixture.status ?? homeSnapshot.status }; const eligible = isEligibleGlanceableWork(snapshot); setSurfaceExtras({ newestSessionTitle: newestSessionTitle(rows), @@ -96,9 +143,20 @@ function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContext): vo log(`applied ${name} (revision ${snapshot.revision})`); } -function release(): void { +async function release(): Promise { + fixtureGeneration += 1; + const generation = fixtureGeneration; pendingName = null; cancelTerminal(); + clearHomeWidgetData(); + await clearHomeWidgetRefresh(); + if (generation !== fixtureGeneration) { + return; + } + await HomeWidgetRefresh?.setFixtureMode(false); + if (generation !== fixtureGeneration) { + return; + } // The rebuilt publisher spreads the current extras, so the fixture's action // feedback must not survive into the live surface. setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); @@ -117,7 +175,7 @@ export function handleGlanceableFixturePath(path: string): boolean { return false; } if (name === RELEASE) { - release(); + void release(); return true; } if (!isGlanceableFixtureName(name)) { @@ -132,7 +190,7 @@ export function handleGlanceableFixturePath(path: string): boolean { log(`queued ${name} until a signed-in scope publishes`); return true; } - apply(name, scope); + void apply(name, scope); return true; } @@ -142,6 +200,6 @@ export function setGlanceableFixtureScope(next: GlanceablePublisherContext | nul if (next !== null && pendingName !== null) { const name = pendingName; pendingName = null; - apply(name, next); + void apply(name, next); } } diff --git a/apps/mobile/src/lib/glanceable/home-widget-copy.ts b/apps/mobile/src/lib/glanceable/home-widget-copy.ts new file mode 100644 index 0000000000..bc3f9a309f --- /dev/null +++ b/apps/mobile/src/lib/glanceable/home-widget-copy.ts @@ -0,0 +1,31 @@ +import { i18n } from '@/i18n'; + +/** Both native renderers and the background provider use the same translated labels. */ +export function getHomeWidgetCopy() { + return { + needsInput: i18n.t('glanceable.needsInput'), + running: i18n.t('common.working'), + scheduled: i18n.t('common.scheduled'), + idle: i18n.t('common.idle'), + waiting: i18n.t('glanceable.waiting'), + empty: i18n.t('home.noLiveSessions'), + signed_out: i18n.t('glanceable.signedOut'), + privacy: i18n.t('glanceable.privacy'), + checked: i18n.t('glanceable.checked'), + lastKnown: i18n.t('glanceable.lastKnown'), + awaitingUpdate: i18n.t('glanceable.awaitingUpdate'), + nextRun: i18n.t('glanceable.nextRun'), + agent: i18n.t('common.agent'), + waitingForYou: i18n.t('glanceable.waitingForYou'), + nextScheduled: i18n.t('glanceable.nextScheduled'), + permissionRequired: i18n.t('agentChat.permissionCard.title'), + answerNeeded: i18n.t('glanceable.answerNeeded'), + waitingToRetry: i18n.t('glanceable.waitingToRetry'), + approving: i18n.t('glanceable.approving'), + couldNotApprove: i18n.t('glanceable.couldNotApprove'), + approve: i18n.t('common.approve'), + newAgent: i18n.t('glanceable.newAgent'), + openAgents: i18n.t('glanceable.openAgents'), + locale: i18n.language, + }; +} diff --git a/apps/mobile/src/lib/glanceable/home-widget-data.ts b/apps/mobile/src/lib/glanceable/home-widget-data.ts new file mode 100644 index 0000000000..e6e5e49ca4 --- /dev/null +++ b/apps/mobile/src/lib/glanceable/home-widget-data.ts @@ -0,0 +1,227 @@ +import { + GLANCEABLE_SNAPSHOT_EXPIRY_MS, + type GlanceableAgentsSnapshot, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetData, + homeWidgetDataSchema, + type HomeWidgetDetails, +} from '@kilocode/app-shared/home-widget'; + +import { reportSecureStoreFailure } from '@/lib/telemetry/secure-store-events'; + +const HOME_WIDGET_DATA_KEY = 'home-widget-data'; + +type HomeWidgetStore = { + getItemAsync(key: string): Promise; + setItemAsync(key: string, value: string): Promise; +}; + +let storeForTests: HomeWidgetStore | null = null; +let lastData: HomeWidgetData | null = null; +let details: HomeWidgetDetails = EMPTY_HOME_WIDGET_DETAILS; +let confirmedAt: number | null = null; +let epoch = 0; +let writes: Promise | null = null; +const listeners = new Set<() => void>(); + +function store(): HomeWidgetStore { + if (storeForTests !== null) { + return storeForTests; + } + // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- headless-safe lazy native load + return require('expo-secure-store') as HomeWidgetStore; +} + +function changed(): void { + for (const listener of listeners) { + listener(); + } +} + +/** Each write waits for the previous one so the mirror never lands out of order. */ +async function writeAfter(previous: Promise | null, raw: string): Promise { + await previous; + try { + await store().setItemAsync(HOME_WIDGET_DATA_KEY, raw); + } catch (error) { + reportSecureStoreFailure('write', error); + } +} + +function mirror(data: HomeWidgetData | null): void { + writes = writeAfter(writes, JSON.stringify(data)); +} + +/** Home-only private details never enter the generic glanceable snapshot. */ +export function setHomeWidgetDetails( + next: HomeWidgetDetails, + checkedAt: number | null = null +): void { + details = next; + confirmedAt = checkedAt; +} + +export function getLastHomeWidgetData(): HomeWidgetData | null { + return lastData; +} + +export function subscribeHomeWidgetData(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function clearHomeWidgetData(): void { + epoch += 1; + lastData = null; + details = EMPTY_HOME_WIDGET_DETAILS; + confirmedAt = null; + mirror(null); + changed(); +} + +/** Accept only a confirmed fetch. Render time and failed fetches cannot renew the timestamp. */ +export function rememberHomeWidgetData(data: HomeWidgetData): boolean { + const snapshot = data.snapshot; + if (snapshot.status === 'signed_out' || snapshot.status === 'privacy') { + epoch += 1; + details = EMPTY_HOME_WIDGET_DETAILS; + lastData = { snapshot, details }; + mirror(lastData); + changed(); + return true; + } + if (snapshot.status !== 'happy' && snapshot.status !== 'empty') { + return false; + } + if ( + lastData?.snapshot.scopeKey === snapshot.scopeKey && + Date.parse(snapshot.updatedAt) < Date.parse(lastData.snapshot.updatedAt) + ) { + return false; + } + epoch += 1; + lastData = data; + details = data.details; + mirror(data); + changed(); + return true; +} + +/** Record before native sinks draw. Activity expiry must not erase the Home record. */ +export function rememberHomeWidgetSnapshot(snapshot: GlanceableAgentsSnapshot): void { + const homeSnapshot = + confirmedAt !== null && (snapshot.status === 'happy' || snapshot.status === 'empty') + ? { + ...snapshot, + updatedAt: new Date(confirmedAt).toISOString(), + expiresAt: new Date(confirmedAt + GLANCEABLE_SNAPSHOT_EXPIRY_MS).toISOString(), + } + : snapshot; + rememberHomeWidgetData({ snapshot: homeSnapshot, details }); +} + +export function getHomeWidgetDataForSnapshot(snapshot: GlanceableAgentsSnapshot): HomeWidgetData { + if (snapshot.status === 'signed_out' || snapshot.status === 'privacy') { + return { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }; + } + if (lastData?.snapshot.status === 'signed_out' || lastData?.snapshot.status === 'privacy') { + return { snapshot: lastData.snapshot, details: EMPTY_HOME_WIDGET_DETAILS }; + } + const retained = lastData?.snapshot.scopeKey === snapshot.scopeKey ? lastData : null; + if (retained !== null) { + if (snapshot.status === 'stale' || snapshot.status === 'expired') { + return { + ...retained, + snapshot: + retained.snapshot.status === 'empty' + ? retained.snapshot + : { ...retained.snapshot, status: 'stale' }, + }; + } + if ( + snapshot.status === 'waiting' || + Date.parse(snapshot.updatedAt) <= Date.parse(retained.snapshot.updatedAt) + ) { + return retained; + } + } + return { + snapshot, + details: + snapshot.status === 'happy' || snapshot.status === 'empty' + ? details + : EMPTY_HOME_WIDGET_DETAILS, + }; +} + +/** Corrupt stored JSON is treated like a missing record. */ +function parseStoredHomeWidgetData(raw: string): HomeWidgetData | null { + try { + const parsed = homeWidgetDataSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +/** A scope fence and live-write epoch prevent restores from resurrecting private data. */ +export async function restoreHomeWidgetData(scopeKey: string | null): Promise { + if (scopeKey === null || scopeKey.startsWith('terminal:') || lastData !== null) { + return; + } + const startedAtEpoch = epoch; + try { + const raw = await store().getItemAsync(HOME_WIDGET_DATA_KEY); + if (raw === null || epoch !== startedAtEpoch) { + return; + } + const stored = parseStoredHomeWidgetData(raw); + if ( + stored === null || + stored.snapshot.scopeKey !== scopeKey || + (stored.snapshot.status !== 'happy' && + stored.snapshot.status !== 'empty' && + stored.snapshot.status !== 'privacy') + ) { + return; + } + lastData = stored; + details = stored.details; + changed(); + } catch (error) { + reportSecureStoreFailure('read', error); + } +} + +export function hasSameHomeWidgetDetails(a: HomeWidgetDetails, b: HomeWidgetDetails): boolean { + return ( + a.primaryTitle === b.primaryTitle && + a.waitingAgents.length === b.waitingAgents.length && + a.waitingAgents.every((row, index) => { + const other = b.waitingAgents[index]; + return row.title === other?.title && row.kind === other.kind; + }) && + a.scheduledAgents.length === b.scheduledAgents.length && + a.scheduledAgents.every((row, index) => { + const other = b.scheduledAgents[index]; + return row.title === other?.title && row.scheduledAt === other.scheduledAt; + }) + ); +} + +export function _setHomeWidgetStoreForTests(next: HomeWidgetStore | null): void { + storeForTests = next; +} + +export function _resetHomeWidgetDataForTests(): void { + epoch += 1; + lastData = null; + details = EMPTY_HOME_WIDGET_DETAILS; + confirmedAt = null; + storeForTests = null; + listeners.clear(); +} diff --git a/apps/mobile/src/lib/glanceable/home-widget-refresh.ts b/apps/mobile/src/lib/glanceable/home-widget-refresh.ts new file mode 100644 index 0000000000..39451cbc01 --- /dev/null +++ b/apps/mobile/src/lib/glanceable/home-widget-refresh.ts @@ -0,0 +1,272 @@ +import * as Sentry from '@sentry/react-native'; +import { + buildGlanceableSnapshot, + buildOpaqueScopeKey, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + buildHomeWidgetPresentation, + buildHomeWidgetPresentationTimeline, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetData, + homeWidgetDataSchema, + type HomeWidgetPresentation, + homeWidgetRefreshAt, + homeWidgetResponseSchema, + type HomeWidgetTimelineEntry, +} from '@kilocode/app-shared/home-widget'; +import { requireOptionalNativeModule } from 'expo'; +import { z } from 'zod'; + +import { end as endLiveUpdate, setWidgetSnapshot } from '@/glanceable-android/live-update'; +import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; +import { isSignOutTeardownActive } from '@/lib/auth/token-owner'; +import { API_BASE_URL } from '@/lib/config'; +import { trpcClient } from '@/lib/trpc'; + +import { getTerminalBlankEpoch, isGlanceableOrgLost } from './cleanup'; +import { isGlanceableFixtureHeld } from './fixture-hold'; +import { getHomeWidgetCopy } from './home-widget-copy'; +import { + getLastHomeWidgetData, + rememberHomeWidgetData, + subscribeHomeWidgetData, +} from './home-widget-data'; +import { getActiveUserId, getSelectedOrganizationId } from './scope'; + +type NativeRefreshContext = { + scopeKey: string; + accountEpoch: number; + generation: string; +}; + +type HomeWidgetRefreshModule = { + configure(config: { + endpoint: string; + token: string; + organizationId: string | null; + scopeKey: string; + accountEpoch: number; + operationEpoch: number; + copy: Record; + locale: string; + data: HomeWidgetData; + home: HomeWidgetPresentation; + refreshAt: number; + presentationTimeline: HomeWidgetTimelineEntry[]; + }): Promise; + clear(operationEpoch: number): Promise; + getOperationEpoch(): number; + setFixtureMode(enabled: boolean): Promise; + // oxlint-disable-next-line anti-slop/no-unknown-returns -- raw native payload, parsed by homeWidgetDataSchema in restoreNativeHomeWidgetData + getData(): Promise; + getWidgetPushToken(): Promise; + widgetsChanged(): Promise; + isCurrent(scopeKey: string, accountEpoch: number, generation: string): Promise; +}; + +export const HomeWidgetRefresh = + requireOptionalNativeModule('HomeWidgetRefresh'); + +const terminalResponseSchema = z.object({ terminal: z.literal('privacy') }); +let configurationEpoch = 0; +let clearPromise: Promise | null = null; +let credential: { + scopeKey: string; + accountEpoch: number; + token: string; + expiresAt: number; +} | null = null; + +function report(error: unknown, operation: string): void { + Sentry.captureException(error, { + tags: { 'error.subsystem': 'home_widget', 'error.operation': operation }, + }); +} + +async function clearNativeRefresh(): Promise { + try { + configurationEpoch = + Math.max(configurationEpoch, HomeWidgetRefresh?.getOperationEpoch() ?? 0) + 1; + await HomeWidgetRefresh?.clear(configurationEpoch); + } catch (error) { + report(error, 'clear_native_refresh'); + } finally { + clearPromise = null; + } +} + +/** Clears protected native state even when no publisher remains mounted. */ +export async function clearHomeWidgetRefresh(): Promise { + configurationEpoch += 1; + credential = null; + clearPromise ??= clearNativeRefresh(); + await clearPromise; +} + +/** The foreground app renews a dedicated read-only credential, never shares its refresh token. */ +export async function syncHomeWidgetRefresh(context: { + userId: string; + organizationId: string | null; +}): Promise { + if (HomeWidgetRefresh === null || isGlanceableFixtureHeld()) { + return; + } + if (clearPromise !== null) { + await clearPromise; + } + const data = getLastHomeWidgetData(); + if ( + data === null || + data.snapshot.status === 'signed_out' || + data.snapshot.status === 'privacy' || + isSignOutTeardownActive() || + isGlanceableOrgLost() + ) { + await clearHomeWidgetRefresh(); + return; + } + const scopeKey = buildOpaqueScopeKey(context); + if (data.snapshot.scopeKey !== scopeKey) { + return; + } + configurationEpoch += 1; + let epoch = configurationEpoch; + const authEpoch = currentAuthEpoch(); + const blankEpoch = getTerminalBlankEpoch(); + const superseded = () => + configurationEpoch !== epoch || + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch || + isSignOutTeardownActive() || + isGlanceableOrgLost() || + isGlanceableFixtureHeld() || + getLastHomeWidgetData()?.snapshot.scopeKey !== scopeKey; + try { + epoch = Math.max(epoch, HomeWidgetRefresh.getOperationEpoch() + 1); + configurationEpoch = epoch; + if ( + credential?.scopeKey !== scopeKey || + credential.accountEpoch !== authEpoch || + credential.expiresAt <= Date.now() + 86_400_000 + ) { + const issued = await trpcClient.activeSessions.widgetCredential.query({ + organizationId: context.organizationId, + }); + if (superseded()) { + return; + } + credential = { ...issued, scopeKey, accountEpoch: authEpoch }; + } + const configuredCredential = credential; + if (superseded()) { + return; + } + const now = Date.now(); + const copy = getHomeWidgetCopy(); + await HomeWidgetRefresh.configure({ + endpoint: `${API_BASE_URL}/api/mobile/widgets`, + token: configuredCredential.token, + organizationId: context.organizationId, + scopeKey, + accountEpoch: authEpoch, + operationEpoch: epoch, + copy, + locale: copy.locale, + data, + home: buildHomeWidgetPresentation(data, now), + refreshAt: homeWidgetRefreshAt(data, now), + presentationTimeline: buildHomeWidgetPresentationTimeline(data, now), + }); + } catch (error) { + if (!superseded()) { + report(error, 'configure_native_refresh'); + } + } +} + +/** Native callbacks cannot cross an account change, terminal blank, or newer native generation. */ +export async function applyHomeWidgetResponse( + response: unknown, + context: NativeRefreshContext +): Promise { + if (HomeWidgetRefresh === null || isGlanceableFixtureHeld() || isSignOutTeardownActive()) { + return false; + } + const authEpoch = currentAuthEpoch(); + const blankEpoch = getTerminalBlankEpoch(); + const [userId, organizationId] = await Promise.all([ + getActiveUserId(), + getSelectedOrganizationId(), + ]); + if ( + userId === null || + buildOpaqueScopeKey({ userId, organizationId }) !== context.scopeKey || + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch || + isGlanceableOrgLost() || + isSignOutTeardownActive() || + isGlanceableFixtureHeld() || + !(await HomeWidgetRefresh.isCurrent(context.scopeKey, context.accountEpoch, context.generation)) + ) { + return false; + } + if (currentAuthEpoch() !== authEpoch || getTerminalBlankEpoch() !== blankEpoch) { + return false; + } + if (terminalResponseSchema.safeParse(response).success) { + const snapshot = buildGlanceableSnapshot({ + sessions: [], + userId, + organizationId, + now: Date.now(), + accountEpoch: authEpoch, + status: 'privacy', + }); + rememberHomeWidgetData({ snapshot, details: EMPTY_HOME_WIDGET_DETAILS }); + setWidgetSnapshot(snapshot); + endLiveUpdate(); + return true; + } + const parsed = homeWidgetResponseSchema.safeParse(response); + if (!parsed.success || parsed.data.snapshot.scopeKey !== context.scopeKey) { + return false; + } + return rememberHomeWidgetData({ snapshot: parsed.data.snapshot, details: parsed.data.details }); +} + +/** Adopt native fetches after a JS restart without comparing process-local auth epochs. */ +export async function restoreNativeHomeWidgetData(): Promise { + if (HomeWidgetRefresh === null || isSignOutTeardownActive() || isGlanceableFixtureHeld()) { + return; + } + const authEpoch = currentAuthEpoch(); + const blankEpoch = getTerminalBlankEpoch(); + try { + const [raw, userId, organizationId] = await Promise.all([ + HomeWidgetRefresh.getData(), + getActiveUserId(), + getSelectedOrganizationId(), + ]); + const parsed = homeWidgetDataSchema.safeParse(raw); + if ( + !parsed.success || + userId === null || + parsed.data.snapshot.scopeKey !== buildOpaqueScopeKey({ userId, organizationId }) || + currentAuthEpoch() !== authEpoch || + getTerminalBlankEpoch() !== blankEpoch || + isSignOutTeardownActive() || + isGlanceableOrgLost() + ) { + return; + } + rememberHomeWidgetData(parsed.data); + } catch (error) { + report(error, 'restore_native_data'); + } +} + +subscribeHomeWidgetData(() => { + if (getLastHomeWidgetData() === null) { + void clearHomeWidgetRefresh(); + } +}); diff --git a/apps/mobile/src/lib/glanceable/mount.tsx b/apps/mobile/src/lib/glanceable/mount.tsx index b37c441cf5..b68d73a77a 100644 --- a/apps/mobile/src/lib/glanceable/mount.tsx +++ b/apps/mobile/src/lib/glanceable/mount.tsx @@ -1,6 +1,7 @@ import { hashKey, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { i18n } from '@/i18n'; import { buildActiveSessionsTrayInput, type CachedActiveSessionsData, @@ -15,6 +16,12 @@ import { resolveAnsweredRaises } from './attention-rows'; import { createGlanceablePublisher } from './create-publisher'; import { getGlanceableFixtureReleases, subscribeGlanceableFixtureReleases } from './fixture-hold'; import { setGlanceableFixtureScope } from './fixture-harness'; +import { getLastHomeWidgetData, subscribeHomeWidgetData } from './home-widget-data'; +import { + clearHomeWidgetRefresh, + restoreNativeHomeWidgetData, + syncHomeWidgetRefresh, +} from './home-widget-refresh'; import { persistGlanceableSink, restorePersistedGlanceable } from './persist'; import { type GlanceablePublisher } from './publisher'; import { registerGlanceableSink } from './sink-registry'; @@ -33,7 +40,7 @@ export function GlanceablePublisherMount(): null { const trpc = useTRPC(); const queryClient = useQueryClient(); const { organizationId, isLoaded } = useOrganization(); - const { token } = useAuth(); + const { token, isLoading: authLoading, restoreFailed } = useAuth(); const { userId } = useCurrentUserId(); const input = useMemo(() => buildActiveSessionsTrayInput(organizationId), [organizationId]); @@ -69,6 +76,7 @@ export function GlanceablePublisherMount(): null { let cancelled = false; const restore = async (): Promise => { await restorePersistedGlanceable(); + await restoreNativeHomeWidgetData(); if (!cancelled) { setRestored(true); } @@ -90,16 +98,16 @@ export function GlanceablePublisherMount(): null { if (__DEV__) { setGlanceableFixtureScope(ctx); } - const derive = (sessions: CachedActiveSessionsData['sessions']) => { - publisher.handleSessions(resolveAnsweredRaises(sessions), ctx); + const derive = (sessions: CachedActiveSessionsData['sessions'], checkedAt: number) => { + publisher.handleSessions(resolveAnsweredRaises(sessions), ctx, checkedAt); }; // Initial state: derive from the existing cache, or mark waiting while the // first fetch is in flight. const state = queryClient.getQueryState(queryKey); const data = queryClient.getQueryData(queryKey); - if (data !== undefined) { - derive(data.sessions); + if (data !== undefined && state !== undefined) { + derive(data.sessions, state.dataUpdatedAt); } else if (state?.fetchStatus === 'fetching') { publisher.handleFetchStarted(ctx); } @@ -111,7 +119,7 @@ export function GlanceablePublisherMount(): null { if (event.action.type === 'success') { const next = queryClient.getQueryData(queryKey); if (next !== undefined) { - derive(next.sessions); + derive(next.sessions, event.query.state.dataUpdatedAt); } } else if (event.action.type === 'error') { publisher.handleFetchError(ctx); @@ -147,9 +155,51 @@ export function GlanceablePublisherMount(): null { } const data = queryClient.getQueryData(queryKey); if (data !== undefined) { - current.publisher.handleSessions(resolveAnsweredRaises(data.sessions), current.ctx); + current.publisher.handleSessions( + resolveAnsweredRaises(data.sessions), + current.ctx, + queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0 + ); } }, [attentionRevision, queryClient, queryKey]); + useEffect(() => { + if (!restored || !isLoaded || authLoading || restoreFailed) { + return undefined; + } + if (!signedIn || userId === undefined) { + void clearHomeWidgetRefresh(); + return undefined; + } + const context = { userId, organizationId }; + const sync = () => { + const data = getLastHomeWidgetData(); + if ( + data !== null && + data.snapshot.status !== 'signed_out' && + data.snapshot.status !== 'privacy' + ) { + void syncHomeWidgetRefresh(context); + } + }; + const unsubscribe = subscribeHomeWidgetData(sync); + i18n.on('languageChanged', sync); + sync(); + return () => { + unsubscribe(); + i18n.off('languageChanged', sync); + }; + }, [ + restored, + isLoaded, + authLoading, + restoreFailed, + signedIn, + token, + userId, + organizationId, + fixtureReleases, + ]); + return null; } diff --git a/apps/mobile/src/lib/glanceable/persist.ts b/apps/mobile/src/lib/glanceable/persist.ts index d7389fa83c..76fa237dd0 100644 --- a/apps/mobile/src/lib/glanceable/persist.ts +++ b/apps/mobile/src/lib/glanceable/persist.ts @@ -4,6 +4,11 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { reportSecureStoreFailure } from '@/lib/telemetry/secure-store-events'; +import { + _resetHomeWidgetDataForTests, + _setHomeWidgetStoreForTests, + restoreHomeWidgetData, +} from './home-widget-data'; import { type GlanceableSink } from './sink-registry'; @@ -190,6 +195,7 @@ export async function restorePersistedGlanceable(): Promise { if (rawScope !== null && localScopeKey === null) { localScopeKey = rawScope; } + await restoreHomeWidgetData(localScopeKey); } catch (error) { // A malformed mirror is treated as absent; the publisher repopulates it. // A read failure is different: the record may be there and own a card, so @@ -230,6 +236,7 @@ export const persistGlanceableSink: GlanceableSink = { export function _setSecureStoreForTests(store: SecureStoreLike | null): void { secureStoreForTests = store; + _setHomeWidgetStoreForTests(store); } export function _setLastGlanceableSnapshotForTests( @@ -255,4 +262,5 @@ export function _resetGlanceablePersistForTests(): void { restoresInFlight = 0; settleWaiters = []; secureStoreForTests = null; + _resetHomeWidgetDataForTests(); } diff --git a/apps/mobile/src/lib/glanceable/platform-parity.test.ts b/apps/mobile/src/lib/glanceable/platform-parity.test.ts index 675078ddf1..3aeee2c24f 100644 --- a/apps/mobile/src/lib/glanceable/platform-parity.test.ts +++ b/apps/mobile/src/lib/glanceable/platform-parity.test.ts @@ -11,18 +11,11 @@ import { buildGlanceableViewProps } from '@/glanceable-ios/view-props'; import { type GlanceableActionFeedback, setSurfaceExtras } from './surface-extras'; /** - * The request builds the widget actions on iOS and on Android with each - * platform's own mechanism (App Intents on iOS, a headless task on Android), - * so the two props builders must agree on everything the user sees: the status - * line, the counts, the reserved newest/feedback line, which action is - * offered, and the spoken label. This pins that parity from one input matrix, - * so a change to either builder that forks the *visible* behaviour — as opposed - * to the drawing mechanism — fails here instead of on a device. - * - * The builders differ where the platform's surface does: iOS carries a numeric - * count and a compact primary for the Lock Screen accessory families, Android - * carries a formatted string. The comparison below normalizes those and ignores - * the mechanism-only fields. + * Both platforms draw widget actions with their own mechanism (App Intents on + * iOS, a headless task on Android), so the two props builders must agree on + * what the user sees: status, counts, the action feedback line, offered + * actions, and the spoken label. Numeric/string count formatting differs by + * platform and is normalized below. */ const NOW = 1_750_000_000_000; @@ -77,7 +70,7 @@ function visible(snapshot: GlanceableAgentsSnapshot) { android.countLines, ], primaryLabel: [ios.primaryLabel, android.primaryLabel], - newestLine: [ios.newestTitle, android.newestLine], + feedbackLine: [ios.actionLine, android.homeCopy?.detail ?? null], actions: [ { approve: ios.actions.approve, newAgent: ios.actions.newAgent }, { approve: android.actions.approve, newAgent: android.actions.newAgent }, @@ -115,36 +108,14 @@ describe('iOS and Android widget props parity', () => { expect(seen.primaryLabel[1]).toBe(seen.primaryLabel[0]); }); - const EXTRAS: { - label: string; - feedback: GlanceableActionFeedback | null; - title: string | null; - expected: string; - }[] = [ - { - label: 'newest title', - feedback: null, - title: 'Fix the flaky test', - expected: 'Newest: Fix the flaky test', - }, - { - label: 'approving', - feedback: 'approving', - title: 'Fix the flaky test', - expected: 'Approving…', - }, - { - label: 'could not approve', - feedback: 'couldNotApprove', - title: 'Fix the flaky test', - expected: 'Could not approve', - }, + const FEEDBACK: { label: string; feedback: GlanceableActionFeedback; expected: string }[] = [ + { label: 'approving', feedback: 'approving', expected: 'Approving…' }, + { label: 'could not approve', feedback: 'couldNotApprove', expected: 'Could not approve' }, ]; - it.each(EXTRAS)('draws the same reserved line for $label', ({ feedback, title, expected }) => { - setSurfaceExtras({ newestSessionTitle: title, actionFeedback: feedback }); + it.each(FEEDBACK)('draws the same action feedback for $label', ({ feedback, expected }) => { + setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: feedback }); const seen = visible(snapshotFor([{ status: 'busy' }, { status: 'idle' }])); - expect(seen.newestLine[0]).toBe(expected); - expect(seen.newestLine[1]).toBe(expected); + expect(seen.feedbackLine).toEqual([expected, expected]); }); }); diff --git a/apps/mobile/src/lib/glanceable/publisher.ts b/apps/mobile/src/lib/glanceable/publisher.ts index 0320fe6684..96d34464d5 100644 --- a/apps/mobile/src/lib/glanceable/publisher.ts +++ b/apps/mobile/src/lib/glanceable/publisher.ts @@ -9,8 +9,14 @@ import { isStartableGlanceableWork, shouldDiscardGlanceableRevision, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { + buildHomeWidgetDetails, + EMPTY_HOME_WIDGET_DETAILS, + type HomeWidgetDetails, +} from '@kilocode/app-shared/home-widget'; import { isGlanceableFixtureHeld } from './fixture-hold'; +import { hasSameHomeWidgetDetails, setHomeWidgetDetails } from './home-widget-data'; import { type NewestSessionRow, newestSessionTitle } from './newest-session'; import { hasSameGlanceableContent, withStatus } from './snapshot-transforms'; import { @@ -122,6 +128,8 @@ export class GlanceablePublisher { private readonly skipWaitingAskSessionId?: string; private current: GlanceableAgentsSnapshot | null; private activityStarted: boolean; + /** Home-only details from this publisher's previous tray read. */ + private homeDetails: HomeWidgetDetails | null = null; /** * `updatedAt` of the frame the native surface accepted, i.e. the one its stale * deadline keys off. A heartbeat whose visible content did not change leaves @@ -171,7 +179,8 @@ export class GlanceablePublisher { */ handleSessions( sessions: readonly (NewestSessionRow & WaitingAskRow)[], - ctx: GlanceablePublisherContext + ctx: GlanceablePublisherContext, + checkedAt: number = this.now() ): void { // A dev fixture owns the surfaces and the ask it recorded: stay silent // without clearing that ask. @@ -184,10 +193,13 @@ export class GlanceablePublisher { this.noteWaitingAsk(null); return; } - // The newest session's title never enters the snapshot (privacy contract): - // it rides in the surface extras every widget reads on redraw. const previousTitle = getSurfaceExtras().newestSessionTitle; const nextTitle = newestSessionTitle(sessions); + const nextHomeDetails = buildHomeWidgetDetails(sessions); + const sameHomeDetails = + this.homeDetails !== null && hasSameHomeWidgetDetails(this.homeDetails, nextHomeDetails); + this.homeDetails = nextHomeDetails; + setHomeWidgetDetails(nextHomeDetails, checkedAt); setSurfaceExtras({ ...getSurfaceExtras(), newestSessionTitle: nextTitle }); getGlanceableDelivery().registerScopeTokens(ctx.organizationId, ctx.userId); const now = this.now(); @@ -241,6 +253,7 @@ export class GlanceablePublisher { if ( this.lastWriteAttemptAt !== null && this.current !== null && + sameHomeDetails && hasSameGlanceableContent( { snapshot: this.current, newestSessionTitle: previousTitle }, { snapshot, newestSessionTitle: nextTitle } @@ -364,6 +377,7 @@ export class GlanceablePublisher { if (incoming.status !== 'signed_out' && incoming.status !== 'privacy') { getGlanceableDelivery().registerScopeTokens(ctx.organizationId, ctx.userId); } + setHomeWidgetDetails(EMPTY_HOME_WIDGET_DETAILS); // A late background delivery supersedes a pending coalesced emit and any // pending 8 s terminal, so neither can fire after the newer snapshot. this.cancelCoalesce(); diff --git a/apps/mobile/src/lib/glanceable/sink-registry.ts b/apps/mobile/src/lib/glanceable/sink-registry.ts index d7d9de4d29..b983d80a4d 100644 --- a/apps/mobile/src/lib/glanceable/sink-registry.ts +++ b/apps/mobile/src/lib/glanceable/sink-registry.ts @@ -2,6 +2,8 @@ import type * as SentryReactNative from '@sentry/react-native'; import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { type LiveActivity } from 'expo-widgets'; +import { rememberHomeWidgetSnapshot } from './home-widget-data'; + /** * One sink consumes the glanceable snapshot for one native surface (persist, * iOS Live Activity/widgets, Android widget/ongoing). Platform sinks register @@ -105,6 +107,7 @@ export function writeGlanceableFrame( snapshot: GlanceableAgentsSnapshot, ctx: GlanceableSinkContext | null ): boolean { + rememberHomeWidgetSnapshot(snapshot); let ok = true; for (const sink of targets) { const writePublish = () => { diff --git a/apps/mobile/src/lib/glanceable/widget-actions.test.ts b/apps/mobile/src/lib/glanceable/widget-actions.test.ts index 616dd6c363..112e2064fe 100644 --- a/apps/mobile/src/lib/glanceable/widget-actions.test.ts +++ b/apps/mobile/src/lib/glanceable/widget-actions.test.ts @@ -1,4 +1,6 @@ /* eslint-disable max-lines -- one cohesive headless-action suite sharing the trpcClient harness */ +// eslint-disable-next-line import/no-nodejs-modules -- test-only SHA-256 reference for the native digest +import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; @@ -12,12 +14,7 @@ import { unregisterGlanceableSink, } from './sink-registry'; import { setSurfaceExtras } from './surface-extras'; -import { - oldestPendingPermissionId, - resolveWaitingSession, - runWidgetApprove, - type WaitingSessionRow, -} from './widget-actions'; +import { resolveWaitingSession, runWidgetApprove, type WaitingSessionRow } from './widget-actions'; const ORGANIZATION_KEY = 'selected-organization'; const USER_KEY = 'active-user-id'; @@ -55,6 +52,21 @@ vi.mock('@/lib/auth/secure-store-value', () => ({ vi.mock('@/lib/trpc', () => ({ trpcClient: mocks.trpc })); +vi.mock('expo-crypto', () => ({ + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digestStringAsync: vi.fn(async (_algorithm: string, value: string) => { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); + }), +})); + +/** The key a widget carries for the request it displayed. */ +function keyFor(sessionId: string, permissionId: string): string { + return createHash('sha256') + .update(JSON.stringify([sessionId, permissionId])) + .digest('hex'); +} + vi.mock('./persist', () => ({ getLastGlanceableSnapshot: () => null, })); @@ -185,28 +197,6 @@ describe('resolveWaitingSession', () => { }); }); -describe('oldestPendingPermissionId', () => { - it('returns the first permission carrying a usable id', () => { - expect( - oldestPendingPermissionId([ - { id: 'perm-1', tool: 'bash' }, - { id: 'perm-2', tool: 'edit' }, - ]) - ).toBe('perm-1'); - }); - - it('skips entries the wire schema cannot describe', () => { - expect(oldestPendingPermissionId([null, 'nope', { id: '' }, { id: 4 }, { id: 'perm-9' }])).toBe( - 'perm-9' - ); - }); - - it('returns null when nothing carries an id', () => { - expect(oldestPendingPermissionId([])).toBeNull(); - expect(oldestPendingPermissionId([{ tool: 'bash' }])).toBeNull(); - }); -}); - describe('runWidgetApprove', () => { beforeEach(() => { mocks.secure.clear(); @@ -228,7 +218,7 @@ describe('runWidgetApprove', () => { it('reports none when the tray holds no waiting session', async () => { wireTrpc({ sessions: [{ id: 'busy', status: 'busy' }] }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'none' }); + await expect(runWidgetApprove(null)).resolves.toEqual({ kind: 'none' }); }); it('reports failed, not a rejection, when the stored scope cannot be read', async () => { @@ -237,7 +227,9 @@ describe('runWidgetApprove', () => { // A rejected read used to escape the container and leave the widget on its // progress line, because nothing settled the action's own line. - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'failed' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'failed', + }); }); it('does not republish when a terminal blank lands while the action runs', async () => { @@ -256,7 +248,9 @@ describe('runWidgetApprove', () => { }); const { snapshots, release } = collectSink(); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(snapshots).toEqual([]); release(); }); @@ -270,7 +264,9 @@ describe('runWidgetApprove', () => { mocks.orgLost = true; const { snapshots, release } = collectSink(); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(snapshots).toEqual([]); release(); }); @@ -286,20 +282,22 @@ describe('runWidgetApprove', () => { .mockResolvedValueOnce({ sessions: [{ id: 'waiting', status: 'permission' }] }) .mockRejectedValueOnce(new Error('tray down')); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); }); - it('reports none when the waiting session is not a cloud-agent session', async () => { + it('answers nothing when the displayed session is not a cloud-agent session', async () => { const rpc = wireTrpc({ sessions: [{ id: 'waiting', status: 'permission' }], cloudAgentSessionId: null, }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'none' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ kind: 'stale' }); expect(rpc.getPendingInteractions.query).not.toHaveBeenCalled(); }); - it('approves the oldest pending permission once and republishes the tray', async () => { + it('approves the displayed permission once and republishes the tray', async () => { const rpc = wireTrpc({ sessions: [ { id: 'busy', status: 'busy' }, @@ -310,7 +308,9 @@ describe('runWidgetApprove', () => { }); const { snapshots, started, release } = collectSink(); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(rpc.getPendingInteractions.query).toHaveBeenCalledWith({ cloudAgentSessionId: 'workspace_agent_1', @@ -339,6 +339,35 @@ describe('runWidgetApprove', () => { release(); }); + it('answers the displayed request, not merely the oldest one', async () => { + const rpc = wireTrpc({ + sessions: [{ id: 'waiting', status: 'permission' }], + cloudAgentSessionId: 'workspace_agent_1', + permissions: [{ id: 'perm-1' }, { id: 'perm-2' }], + }); + + await expect(runWidgetApprove(keyFor('waiting', 'perm-2'))).resolves.toEqual({ + kind: 'approved', + }); + expect(rpc.answerPermission.mutate).toHaveBeenCalledExactlyOnceWith({ + sessionId: 'workspace_agent_1', + permissionId: 'perm-2', + response: 'once', + }); + }); + + it('answers nothing for a stale press after the request was replaced', async () => { + const rpc = wireTrpc({ + sessions: [{ id: 'waiting', status: 'permission' }], + cloudAgentSessionId: 'workspace_agent_1', + permissions: [{ id: 'perm-2' }], + }); + + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ kind: 'stale' }); + expect(rpc.answerPermission.mutate).not.toHaveBeenCalled(); + expect(mocks.dismissNeedsInputNotification).not.toHaveBeenCalled(); + }); + it('leaves the raise notification alone when the approve never landed', async () => { const rpc = wireTrpc({ sessions: [{ id: 'waiting', status: 'permission' }], @@ -347,7 +376,9 @@ describe('runWidgetApprove', () => { }); rpc.answerPermission.mutate.mockRejectedValueOnce(new Error('network')); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'failed' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'failed', + }); expect(mocks.dismissNeedsInputNotification).not.toHaveBeenCalled(); }); @@ -358,7 +389,7 @@ describe('runWidgetApprove', () => { permissions: [], }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'no-permission' }); + await expect(runWidgetApprove(null)).resolves.toEqual({ kind: 'no-permission' }); }); // The chip is offered on `needsApproval`, which counts `permission` rows, so @@ -377,7 +408,9 @@ describe('runWidgetApprove', () => { permissions: [{ id: 'perm-1' }], }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(rpc.cliSessionsV2.get.query).toHaveBeenCalledWith({ session_id: 'waiting' }); expect(rpc.answerPermission.mutate).toHaveBeenCalledWith({ @@ -397,7 +430,9 @@ describe('runWidgetApprove', () => { rpc.answerPermission.mutate.mockRejectedValueOnce(new Error('network')); const { snapshots, release } = collectSink(); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'failed' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'failed', + }); expect(snapshots).toEqual([]); release(); }); @@ -410,7 +445,9 @@ describe('runWidgetApprove', () => { permissions: [{ id: 'perm-1' }], }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(rpc.activeSessions.list.query).toHaveBeenCalledWith({ organizationId: 'org-1', @@ -439,7 +476,9 @@ describe('runWidgetApprove', () => { permissions: [{ id: 'perm-1' }], }); - await expect(runWidgetApprove()).resolves.toEqual({ kind: 'approved' }); + await expect(runWidgetApprove(keyFor('waiting', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); expect(rpc.getPendingInteractions.query).toHaveBeenCalledWith({ cloudAgentSessionId: 'workspace_agent_1', diff --git a/apps/mobile/src/lib/glanceable/widget-actions.ts b/apps/mobile/src/lib/glanceable/widget-actions.ts index b4473cda87..e3e59502b8 100644 --- a/apps/mobile/src/lib/glanceable/widget-actions.ts +++ b/apps/mobile/src/lib/glanceable/widget-actions.ts @@ -2,6 +2,8 @@ import { buildGlanceableSnapshot, type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { buildHomeWidgetDetails } from '@kilocode/app-shared/home-widget'; +import { CryptoDigestAlgorithm, digestStringAsync } from 'expo-crypto'; import { buildActiveSessionsTrayInput, isAttentionStatus } from '@/lib/active-sessions-live'; import { readStoredValue } from '@/lib/auth/secure-store-value'; @@ -13,7 +15,7 @@ import { trpcClient } from '@/lib/trpc'; import { parseTimestamp } from '@/lib/utils'; import { getTerminalBlankEpoch, isGlanceableOrgLost } from './cleanup'; -import { pickFrontApprovableSession } from './front-approval'; +import { setHomeWidgetDetails } from './home-widget-data'; import { resolveAnsweredRaises } from './attention-rows'; import { newestSessionTitle } from './newest-session'; import { getLastGlanceableSnapshot } from './persist'; @@ -42,8 +44,13 @@ import { getSurfaceExtras, setSurfaceExtras } from './surface-extras'; * `none` = no waiting session to act on, so the caller opens the app instead. * `no-permission` = the waiting agent asks a free-form question; the widget * must never invent an answer, so the caller opens the app. + * `stale` = the request the widget displayed is no longer pending; nothing is + * answered and the caller shows the current state instead. */ -type WidgetApproveResultKind = 'approved' | 'none' | 'no-permission' | 'failed'; +type WidgetApproveResultKind = 'approved' | 'none' | 'no-permission' | 'stale' | 'failed'; + +/** Bounds the tray reads one press makes while it looks for the displayed request. */ +const MAX_APPROVAL_CANDIDATES = 3; export type WidgetApproveResult = { kind: WidgetApproveResultKind }; @@ -144,21 +151,6 @@ function permissionIdOf(permission: unknown): string | null { return typeof id === 'string' && id.length > 0 ? id : null; } -/** - * The oldest pending permission request id, or null when none waits. The - * server keeps the collection in arrival order, so the first entry with a - * usable id is the one that has waited longest. - */ -export function oldestPendingPermissionId(permissions: readonly unknown[]): string | null { - for (const permission of permissions) { - const id = permissionIdOf(permission); - if (id !== null) { - return id; - } - } - return null; -} - /** * One read whose failure must stay observable: reported at warning level and * rethrown, so the widget action settles on `failed` rather than acting on a @@ -183,65 +175,94 @@ async function readStoredScope(): Promise { } /** - * Answer the longest-waiting permission with `'once'`. A rejected call is - * `failed`; a tray with no permission waiting is `no-permission` when a - * free-form question waits, and `none` otherwise — the caller opens the app for - * either. + * The first pending permission on `sessionId` whose hashed ids equal the key + * the widget displayed, or null when none matches. Sequential so the search + * stops at the first match. */ -async function approveWaitingSession(organizationId: string | null): Promise { +async function findVisiblePermissionId( + sessionId: string, + permissions: readonly unknown[], + expectedApprovalKey: string +): Promise { + for (const permission of permissions) { + const permissionId = permissionIdOf(permission); + if (permissionId !== null) { + // eslint-disable-next-line no-await-in-loop -- sequential: stops at the first match + const approvalKey = await digestStringAsync( + CryptoDigestAlgorithm.SHA256, + JSON.stringify([sessionId, permissionId]) + ); + if (approvalKey === expectedApprovalKey) { + return permissionId; + } + } + } + return null; +} + +/** + * Answer exactly the permission the widget displayed. The key is a hash of + * the visible session and permission ids; a press whose request was answered, + * withdrawn, or replaced matches nothing and answers nothing. + */ +async function approveVisiblePermission( + organizationId: string | null, + expectedApprovalKey: string | null +): Promise { const { sessions } = await trpcClient.activeSessions.list.query( buildActiveSessionsTrayInput(organizationId) ); - // The Approve chip is offered on `needsApproval`, which counts exactly the - // `permission` rows, so the press must answer one of the rows that count - // describes: the longest-waiting permission, the same pick the wrist and - // notification controls make (`pickFrontApprovableSession`). Ranking every - // attention row instead would let an older `question` or `retry` shadow a - // newer permission — the chip would draw, and the press would only open the - // app instead of approving the permission the chip was offered for. - const waiting = pickFrontApprovableSession(sessions); - if (waiting === null) { - // Nothing approvable: a free-form question opens the app for an answer, - // while a tray with nothing to act on needs only the agents list. + const candidates = sessions + .filter(row => row.status === 'permission') + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; filter already copies so nothing shared is mutated + .sort((a, b) => (waitingSince(a) ?? Infinity) - (waitingSince(b) ?? Infinity)) + .slice(0, MAX_APPROVAL_CANDIDATES); + if (candidates.length === 0) { return { kind: resolveWaitingSession(sessions) === null ? 'none' : 'no-permission', answeredSessionId: null, }; } - // Only a cloud-agent session carries pending interactions the control plane - // can answer; a remote CLI session has none, so the app owns it. - const session = await trpcClient.cliSessionsV2.get.query({ session_id: waiting.id }); - const cloudAgentSessionId = session.cloud_agent_session_id; - if (cloudAgentSessionId === null) { - return { kind: 'none', answeredSessionId: null }; - } - // The personal `getPendingInteractions` refuses an organization session (its - // ownership check requires a null `organization_id`), so pick the - // organization-scoped twin exactly like `answerPermission` below. - const pending = organizationId - ? await trpcClient.organizations.cloudAgentNext.getPendingInteractions.query({ - cloudAgentSessionId, - organizationId, - }) - : await trpcClient.cloudAgentNext.getPendingInteractions.query({ - cloudAgentSessionId, - }); - const permissionId = oldestPendingPermissionId(pending.permissions); - if (permissionId === null) { - return { kind: 'no-permission', answeredSessionId: null }; + if (expectedApprovalKey === null) { + return { kind: 'stale', answeredSessionId: null }; } - const answer = { sessionId: cloudAgentSessionId, permissionId, response: 'once' as const }; - if (organizationId) { - await trpcClient.organizations.cloudAgentNext.answerPermission.mutate({ - ...answer, - organizationId, - }); - ackSessionAttention(waiting.id); - return { kind: 'approved', answeredSessionId: waiting.id }; + for (const row of candidates) { + // Sequential and bounded: each read can stop the search once it matches. + // eslint-disable-next-line no-await-in-loop -- see above + const session = await trpcClient.cliSessionsV2.get.query({ session_id: row.id }); + const cloudAgentSessionId = session.cloud_agent_session_id; + if (cloudAgentSessionId !== null) { + // The personal read refuses an organization session, so an organization + // scope uses its own twin for both the read and the answer. + const pending = organizationId + ? // eslint-disable-next-line no-await-in-loop -- see above + await trpcClient.organizations.cloudAgentNext.getPendingInteractions.query({ + cloudAgentSessionId, + organizationId, + }) + : // eslint-disable-next-line no-await-in-loop -- see above + await trpcClient.cloudAgentNext.getPendingInteractions.query({ cloudAgentSessionId }); + // eslint-disable-next-line no-await-in-loop -- see above + const permissionId = await findVisiblePermissionId( + row.id, + pending.permissions, + expectedApprovalKey + ); + if (permissionId !== null) { + const answer = { sessionId: cloudAgentSessionId, permissionId, response: 'once' as const }; + // eslint-disable-next-line no-await-in-loop -- the matching request is answered once + await (organizationId + ? trpcClient.organizations.cloudAgentNext.answerPermission.mutate({ + ...answer, + organizationId, + }) + : trpcClient.cloudAgentNext.answerPermission.mutate(answer)); + ackSessionAttention(row.id); + return { kind: 'approved', answeredSessionId: row.id }; + } + } } - await trpcClient.cloudAgentNext.answerPermission.mutate(answer); - ackSessionAttention(waiting.id); - return { kind: 'approved', answeredSessionId: waiting.id }; + return { kind: 'stale', answeredSessionId: null }; } /** @@ -268,6 +289,7 @@ async function republishTray(scope: WidgetScope, blankEpochAtStart: number): Pro if (isGlanceableOrgLost() || getTerminalBlankEpoch() !== blankEpochAtStart) { return; } + setHomeWidgetDetails(buildHomeWidgetDetails(resolveAnsweredRaises(sessions))); const snapshot: GlanceableAgentsSnapshot = buildGlanceableSnapshot({ // A raise the user answered from the needs-input notification is no longer // waiting: count it the way the in-app list does. @@ -295,7 +317,9 @@ async function republishTray(scope: WidgetScope, blankEpochAtStart: number): Pro * rejected call reports `failed` so the widget can say so and keep Approve * offered. */ -export async function runWidgetApprove(): Promise { +export async function runWidgetApprove( + expectedApprovalKey: string | null +): Promise { // Read the publication gate as the action starts; `republishTray` compares it // after the action, so a blank that lands while it runs wins the surface. const blankEpochAtStart = getTerminalBlankEpoch(); @@ -304,8 +328,8 @@ export async function runWidgetApprove(): Promise { // rejection must settle the widget on its failure line instead of leaving // the progress line up with nothing driving it. const scope = await readStoredScope(); - const outcome = await approveWaitingSession(scope.organizationId); - if (outcome.kind === 'approved') { + const outcome = await approveVisiblePermission(scope.organizationId, expectedApprovalKey); + if (outcome.kind === 'approved' || outcome.kind === 'stale') { // A republish that fails must not turn a completed approve into `failed`: // the approve landed, the failure is reported by the sink guard, and the // next tray event redraws the counts. diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 93ebe031fa..3c5dbebbed 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -24,12 +24,17 @@ import { type GlanceableAgentsSnapshot, isEligibleGlanceableWork, } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { EMPTY_HOME_WIDGET_DETAILS } from '@kilocode/app-shared/home-widget'; import { captureEvent } from '@/lib/analytics/posthog'; import { refreshActiveSessionsFromPush } from '@/lib/active-sessions-live-sync'; import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; import { applyStoredLanguage } from '@/lib/glanceable/apply-stored-language'; import { getTerminalBlankEpoch } from '@/lib/glanceable/cleanup'; +import { + rememberHomeWidgetSnapshot, + setHomeWidgetDetails, +} from '@/lib/glanceable/home-widget-data'; import { getLastGlanceableSnapshot, getLocalScopeKey, @@ -221,7 +226,7 @@ export async function applyGlanceablePushData( // Fence and rebase against the latest publication after storage reads. // A publication during the reads also wins a timestamp tie. - const { type: _type, ...fields } = data; + const { type: _type, homeWidgetDetails, ...fields } = data; const current = getLastGlanceableSnapshot(); if ( current !== null && @@ -236,6 +241,8 @@ export async function applyGlanceablePushData( revision: current === null ? fields.revision : current.revision + 1, accountEpoch: authEpoch, }; + setHomeWidgetDetails(homeWidgetDetails ?? EMPTY_HOME_WIDGET_DETAILS); + rememberHomeWidgetSnapshot(snapshot); const ctx = { userId, organizationId }; const eligible = isEligibleGlanceableWork(snapshot); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42b2a9ce81..776a38d9a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1722,6 +1722,9 @@ importers: packages/notifications: dependencies: + '@kilocode/app-shared': + specifier: workspace:* + version: link:../app-shared i18next: specifier: ^26.3.6 version: 26.3.6(typescript@5.9.3) diff --git a/tools/oxlint/no-literal-copy/allowlist.json b/tools/oxlint/no-literal-copy/allowlist.json index ad9924c882..26e95c5794 100644 --- a/tools/oxlint/no-literal-copy/allowlist.json +++ b/tools/oxlint/no-literal-copy/allowlist.json @@ -1,5 +1,5 @@ { "files": [], - "values": ["KiloClaw"], + "values": ["Kilo", "KiloClaw"], "constants": ["BYOK_MODEL_LABEL", "KILO_PASS_TITLE", "MESSAGE_TEXT_MAX_CHARS", "URL_PLACEHOLDER"] } From 1d73214d7ee528b296fdf7d4cce044db106f9061 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:29:33 +0000 Subject: [PATCH 04/31] chore(kilo-mcp): refresh catalog.json and SKILL.md from CI --- .kilo/skills/kilo-mcp/SKILL.md | 4 +-- services/kilo-mcp/catalog.json | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.kilo/skills/kilo-mcp/SKILL.md b/.kilo/skills/kilo-mcp/SKILL.md index 7317184403..c550b7b0a7 100644 --- a/.kilo/skills/kilo-mcp/SKILL.md +++ b/.kilo/skills/kilo-mcp/SKILL.md @@ -51,7 +51,7 @@ to send platform feedback and bug reports rather than a support channel. The first path segment names an area. Search to learn an area's vocabulary: each row's `summary` and `tags` are what the search matches. -**863 procedures** — **370 queries**, **493 mutations** — under **53 prefixes**. +**865 procedures** — **372 queries**, **493 mutations** — under **53 prefixes**. | Prefix | Procedures | Queries | Mutations | |---|---:|---:|---:| @@ -83,12 +83,12 @@ each row's `summary` and `tags` are what the search matches. | `codeIndexing` | 7 | 5 | 2 | | `personalAutoFix` | 7 | 3 | 4 | | `userExports` | 7 | 3 | 4 | +| `activeSessions` | 6 | 5 | 1 | | `autoTriage` | 6 | 4 | 2 | | `dolthub` | 6 | 4 | 2 | | `modelPreferences` | 6 | 1 | 5 | | `usageAnalytics` | 6 | 6 | 0 | | `workspaceFolders` | 6 | 1 | 5 | -| `activeSessions` | 4 | 3 | 1 | | `debug` | 4 | 4 | 0 | | `discord` | 4 | 1 | 3 | | `securityAuditLog` | 4 | 3 | 1 | diff --git a/services/kilo-mcp/catalog.json b/services/kilo-mcp/catalog.json index 00091b6731..9a22eb6961 100644 --- a/services/kilo-mcp/catalog.json +++ b/services/kilo-mcp/catalog.json @@ -65,6 +65,70 @@ ], "searchBlob": "activeSessions.listInstances List the currently active/connected agent instances or devices from the session ingest worker. activesessions listinstances" }, + "activeSessions.widgetCredential": { + "path": "activeSessions.widgetCredential", + "kind": "query", + "summary": "Issue a short-lived authentication token and expiry for the home widget so it can perform authenticated background reads of agent session data. Use this to get the credential a widget needs before making its own authenticated requests.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "organizationId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "organizationId" + ] + }, + "tags": [ + "activesessions", + "widgetcredential", + "organizationid" + ], + "searchBlob": "activeSessions.widgetCredential Issue a short-lived authentication token and expiry for the home widget so it can perform authenticated background reads of agent session data. Use this to get the credential a widget needs before making its own authenticated requests. activesessions widgetcredential organizationid organizationId" + }, + "activeSessions.widgetSnapshot": { + "path": "activeSessions.widgetSnapshot", + "kind": "query", + "summary": "Get the home widget snapshot of active agents and sessions for the current user, optionally scoped to an organization, for rendering a glanceable dashboard widget. Use this to fetch the widget's data payload.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "organizationId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "organizationId" + ] + }, + "tags": [ + "activesessions", + "widgetsnapshot", + "organizationid" + ], + "searchBlob": "activeSessions.widgetSnapshot Get the home widget snapshot of active agents and sessions for the current user, optionally scoped to an organization, for rendering a glanceable dashboard widget. Use this to fetch the widget's data payload. activesessions widgetsnapshot organizationid organizationId" + }, "agentProfiles.bindToRepo": { "path": "agentProfiles.bindToRepo", "kind": "mutation", From 4df96dd1fdd7edf8e69adf2f207ce795c6e7ca71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:59:50 +0200 Subject: [PATCH 05/31] fix(web): forbid org-scoped authority from minting another scope's widget credential --- apps/mobile/src/i18n/profiles-copy.test.ts | 199 ------------------ .../lib/auth/home-widget-credential.test.ts | 21 ++ .../src/lib/auth/home-widget-credential.ts | 6 + 3 files changed, 27 insertions(+), 199 deletions(-) delete mode 100644 apps/mobile/src/i18n/profiles-copy.test.ts diff --git a/apps/mobile/src/i18n/profiles-copy.test.ts b/apps/mobile/src/i18n/profiles-copy.test.ts deleted file mode 100644 index 004bc7525e..0000000000 --- a/apps/mobile/src/i18n/profiles-copy.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import en from './locales/en.json'; - -/** - * The profile-manager screens are written against these key names, so this - * literal is the interface: adding or renaming a key here means the screens - * that cite it must change too. - * - * It covers every nested section the surfaces use: the s3 overview copy, the - * s6 variables and s7 skills editors, and the s11 MCP/agents/slash-command - * editors plus the s12 repo bindings and s13 secrets lock/unlock copy. - */ -const EXPECTED_PROFILES = { - title: 'Profiles', - entrySubtitle: 'Reuse environment settings across sessions', - newProfile: 'New profile', - emptyTitle: 'No profiles yet', - emptyDescription: - 'Create a profile to reuse environment variables, setup commands, and skills across sessions.', - loadFailed: "Couldn't load profiles", - counts: { - vars: '{{count}} vars', - mcp: '{{count}} MCP', - skills: '{{count}} skills', - commands: '{{count}} cmds', - varsShort: '{{count}}v', - skillsShort: '{{count}}s', - commandsShort: '{{count}}c', - }, - nameLabel: 'Profile name', - namePlaceholder: 'e.g. Backend debugging', - descriptionLabel: 'Profile description', - descriptionPlaceholder: 'Optional description', - ownerLabel: 'Profile owner', - nameRequired: 'Enter a profile name', - createAction: 'Create profile', - createFailed: "Couldn't create profile", - createdToast: 'Profile "{{name}}" created', - settingsSectionTitle: 'Configuration', - defaultSectionTitle: 'Default profile', - personalDefaultDescription: 'Auto-loaded when no repository has a pinned profile.', - organizationDefaultDescription: 'Auto-loaded for members who have no personal default.', - setAsDefault: 'Set as default', - removeDefault: 'Remove as default', - saveFailed: "Couldn't save profile", - deleteTitle: 'Delete profile', - deleteMessage: 'This permanently removes the profile and its settings. This cannot be undone.', - deleteFailed: "Couldn't delete profile", - deleteBlocked: 'This profile is used by a webhook trigger. Remove it from those triggers first.', - deletedToast: 'Profile "{{name}}" deleted', - variablesTitle: 'Environment variables', - addVariable: 'Add variable', - keyLabel: 'Key', - keyTooLong: 'Key must be 256 characters or fewer', - keyDuplicate: 'This key already exists in the profile', - valueLabel: 'Value', - secretLabel: 'Secret', - secrets: { - markSecret: 'Mark as secret', - masked: 'Hidden value', - reveal: 'Reveal value', - newValuePlaceholder: 'Enter new secret value', - valuePlaceholder: 'Secret value (encrypted)', - }, - variablesEmpty: 'No variables yet', - variablesSaveFailed: "Couldn't save variable", - commandsTitle: 'Setup commands', - addCommand: 'Add command', - commandLabel: 'Command', - commandPlaceholder: 'e.g. pnpm install', - commandsEmpty: 'No setup commands yet', - commandsSaveFailed: "Couldn't save setup commands", - moveUp: 'Move up', - moveDown: 'Move down', - skillsTitle: 'Skills', - addSkill: 'Add skill', - skillNameLabel: 'Skill name', - skillMarkdownLabel: 'Skill content', - skillsEmpty: 'No skills yet', - skillNameRequired: 'Enter a skill name', - skillNameInvalid: - 'Skill name must start with a letter or digit and use only lowercase letters, digits, and dashes', - skillContentRequired: 'Skill content is required', - skillSaveFailed: "Couldn't save skill", - skillsHint: - 'Paste a SKILL.md and its name fills in; add a skill from your phone without drag and drop.', - skillEditTitle: 'Edit skill', - mcp: { - title: 'MCP servers', - add: 'Add MCP server', - empty: 'No MCP servers yet', - name: 'Server name', - type: 'Server type', - localType: 'Local', - remoteType: 'Remote', - command: 'Command line', - url: 'URL', - environment: 'Environment (JSON)', - headers: 'Headers (JSON)', - timeout: 'Timeout', - secretCount: 'Secret count', - namePlaceholder: 'my-server', - commandPlaceholder: 'npx @example/mcp-server', - jsonPlaceholder: '{\n "API_KEY": "sk-..."\n}', - nameRequired: 'Enter a server name', - nameInvalid: 'Name can use only letters, numbers, underscores, and dashes', - commandRequired: 'Enter a command', - commandTooLong: 'Use at most 50 arguments of 500 characters each', - urlRequired: 'Enter a URL', - urlInvalid: 'Enter a valid URL', - urlTooLong: 'URL must be 2048 characters or fewer', - jsonInvalid: 'Enter valid JSON — an object of string values', - jsonTooLarge: 'Use at most 50 entries, keys up to 128 and values up to 4096 characters', - timeoutInvalid: 'Timeout must be a whole number of milliseconds', - edit: 'Edit MCP server', - saveFailed: "Couldn't save MCP server", - }, - agents: { - title: 'Profile agents', - add: 'Add agent', - empty: 'No agents yet', - name: 'Agent name', - slug: 'Agent slug', - description: 'Agent description', - model: 'Model override', - modelPlaceholder: 'Use session default', - prompt: 'System prompt', - slugPlaceholder: 'my-agent', - namePlaceholder: 'My Agent', - promptPlaceholder: 'You are a ...', - steps: 'Max steps', - stepsPlaceholder: 'e.g. 50', - temperature: 'Temperature', - temperaturePlaceholder: 'e.g. 0.2', - topP: 'top_p', - topPPlaceholder: 'e.g. 0.95', - tools: 'Tools', - visibility: 'Visibility', - visibilityPrimary: 'Primary — shown in the picker', - visibilitySubagent: 'Subagent — only callable from others', - visibilityAll: 'All — both', - saveFailed: "Couldn't save agent", - nameRequired: 'Enter an agent name', - slugRequired: 'Enter an agent slug', - slugInvalid: 'Slug must start with a letter and use only lowercase letters, digits, and dashes', - slugConflict: 'That slug is reserved by a built-in agent', - edit: 'Edit agent', - }, - slashCommands: { - title: 'Slash commands', - add: 'Add slash command', - empty: 'No slash commands yet', - name: 'Command name', - description: 'Command description', - template: 'Template', - namePlaceholder: 'explain', - templatePlaceholder: 'Explain $ARGUMENTS', - agent: 'Agent', - subtask: 'Run as subtask', - edit: 'Edit slash command', - saveFailed: "Couldn't save command", - nameRequired: 'Enter a command name', - nameInvalid: - 'Name must start with a lowercase letter and use only lowercase letters, digits, and dashes', - templateRequired: 'Enter a command template', - nameConflict: 'That name is reserved by a built-in command', - }, - repoBindings: { - title: 'Default Profiles for Repos', - add: 'Add default', - empty: 'No defaults configured', - emptyDescription: - 'Add a default to automatically apply a profile when starting sessions for a repo.', - repo: 'Select repository…', - profile: 'Select profile', - remove: 'Unbind', - private: 'Private', - search: 'Search repositories…', - addedToast: 'Default profile set for "{{repo}}"', - addFailed: "Couldn't set default profile", - removedToast: 'Default profile removed for "{{repo}}"', - removeFailed: "Couldn't remove default profile", - pinnedTitle: 'Pinned to repositories', - pinRepo: 'Pin a repo', - pinnedEmpty: 'Not pinned to any repositories.', - pinnedDescription: 'Auto-loaded whenever a session targets one of these repositories.', - pinnedToast: 'Pinned to "{{repo}}"', - pinFailed: "Couldn't pin repo", - unboundToast: 'Repo unbound', - unbindFailed: "Couldn't unbind repo", - }, -} as const; - -describe('profiles copy', () => { - it('pins the profile-manager key set', () => { - expect(en.profiles).toEqual(EXPECTED_PROFILES); - }); -}); diff --git a/apps/web/src/lib/auth/home-widget-credential.test.ts b/apps/web/src/lib/auth/home-widget-credential.test.ts index 05df613a0a..24c47886d8 100644 --- a/apps/web/src/lib/auth/home-widget-credential.test.ts +++ b/apps/web/src/lib/auth/home-widget-credential.test.ts @@ -112,6 +112,27 @@ describe('dedicated Home widget credential', () => { }); }); + it.each([ + ['another organization', '22222222-2222-4222-8222-222222222222'], + ['the personal account', null], + ])('refuses an organization-scoped credential minting for %s', async (_target, requested) => { + authority.mockResolvedValue({ + user, + credentialKind: 'device-access', + deviceSessionId, + organizationId, + isModern: false, + runtimeAdmission: { + source: 'user', + authorizationUserId: user.id, + authorizationPepper: user.api_token_pepper, + }, + }); + await expect(issueHomeWidgetCredential(user, requested)).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + it.each([ 'user missing', 'blocked', diff --git a/apps/web/src/lib/auth/home-widget-credential.ts b/apps/web/src/lib/auth/home-widget-credential.ts index 344cfa29dd..3a0a8a168c 100644 --- a/apps/web/src/lib/auth/home-widget-credential.ts +++ b/apps/web/src/lib/auth/home-widget-credential.ts @@ -106,6 +106,12 @@ export async function issueHomeWidgetCredential( if (authority.user.id !== user.id || authority.deviceSessionId === undefined) { unauthorized(); } + if (authority.organizationId && authority.organizationId !== organizationId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Scoped credentials cannot mint widget credentials for another organization', + }); + } const now = Math.floor(Date.now() / 1000); if (authority.expiresAt !== undefined && authority.expiresAt <= now) unauthorized(); const claims = { From 99875710dc51dddedb745816a158203c523203dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:59:58 +0200 Subject: [PATCH 06/31] fix(kilo-mcp): keep the widget credential issuer out of the MCP catalog --- .kilo/skills/kilo-mcp/SKILL.md | 4 +-- .../src/scripts/mcp-catalog/catalog.test.ts | 3 ++ apps/web/src/scripts/mcp-catalog/catalog.ts | 24 +++++++++----- services/kilo-mcp/catalog.json | 32 ------------------- 4 files changed, 21 insertions(+), 42 deletions(-) diff --git a/.kilo/skills/kilo-mcp/SKILL.md b/.kilo/skills/kilo-mcp/SKILL.md index c550b7b0a7..9ed399f9a4 100644 --- a/.kilo/skills/kilo-mcp/SKILL.md +++ b/.kilo/skills/kilo-mcp/SKILL.md @@ -51,7 +51,7 @@ to send platform feedback and bug reports rather than a support channel. The first path segment names an area. Search to learn an area's vocabulary: each row's `summary` and `tags` are what the search matches. -**865 procedures** — **372 queries**, **493 mutations** — under **53 prefixes**. +**864 procedures** — **371 queries**, **493 mutations** — under **53 prefixes**. | Prefix | Procedures | Queries | Mutations | |---|---:|---:|---:| @@ -83,12 +83,12 @@ each row's `summary` and `tags` are what the search matches. | `codeIndexing` | 7 | 5 | 2 | | `personalAutoFix` | 7 | 3 | 4 | | `userExports` | 7 | 3 | 4 | -| `activeSessions` | 6 | 5 | 1 | | `autoTriage` | 6 | 4 | 2 | | `dolthub` | 6 | 4 | 2 | | `modelPreferences` | 6 | 1 | 5 | | `usageAnalytics` | 6 | 6 | 0 | | `workspaceFolders` | 6 | 1 | 5 | +| `activeSessions` | 5 | 4 | 1 | | `debug` | 4 | 4 | 0 | | `discord` | 4 | 1 | 3 | | `securityAuditLog` | 4 | 3 | 1 | diff --git a/apps/web/src/scripts/mcp-catalog/catalog.test.ts b/apps/web/src/scripts/mcp-catalog/catalog.test.ts index 48b9115640..242ef5d1b8 100644 --- a/apps/web/src/scripts/mcp-catalog/catalog.test.ts +++ b/apps/web/src/scripts/mcp-catalog/catalog.test.ts @@ -130,6 +130,9 @@ describe('mcp-catalog catalog', () => { expect(isDenylistedPath('slack.testConnection')).toBe(false); // `debug` is published and carries the guard marker, so it is not internal. expect(isDenylistedPath('debug.ping')).toBe(false); + // Native-only credential issuers are withheld by exact path. + expect(isDenylistedPath('activeSessions.widgetCredential')).toBe(true); + expect(isDenylistedPath('activeSessions.getToken')).toBe(false); }); it('fails on a zero-row catalog instead of emitting an empty one', () => { diff --git a/apps/web/src/scripts/mcp-catalog/catalog.ts b/apps/web/src/scripts/mcp-catalog/catalog.ts index 4bb0faa43b..5e080a0163 100644 --- a/apps/web/src/scripts/mcp-catalog/catalog.ts +++ b/apps/web/src/scripts/mcp-catalog/catalog.ts @@ -38,16 +38,24 @@ export const CATALOG_JSON_DISPLAY_PATH = relative(REPO_ROOT, CATALOG_JSON_PATH); export const ROOT_ROUTER_PATH = join(__dirname, '..', '..', 'routers', 'root-router.ts'); /** - * Whether a procedure path is internal-only. A path is internal when any - * segment is `test` or starts with `admin` or `dev` — the routers name admin - * and dev-only procedures `adminX` and `devX` (for example - * `organizations.admin.grantCredit` and `slack.devRemoveDbRowOnly`). `test` - * stays exact-only so user-facing calls such as `slack.testConnection` remain - * published, and `debug` stays published too: its rows carry a `debug: true` - * marker and are guarded wherever they are offered or called. Subscriptions - * are never exported regardless of this check. + * Exact procedure paths withheld from MCP even though no segment is internal. + * `activeSessions.widgetCredential` mints a 30-day Home widget bearer and only + * native device sessions may call it, so no MCP credential can use it. + */ +const NATIVE_ONLY_PATHS: Record = { 'activeSessions.widgetCredential': true }; + +/** + * Whether a procedure path is internal-only. A path is internal when it is in + * {@link NATIVE_ONLY_PATHS} or any segment is `test` or starts with `admin` or + * `dev` — the routers name admin and dev-only procedures `adminX` and `devX` + * (for example `organizations.admin.grantCredit` and `slack.devRemoveDbRowOnly`). + * `test` stays exact-only so user-facing calls such as `slack.testConnection` + * remain published, and `debug` stays published too: its rows carry a + * `debug: true` marker and are guarded wherever they are offered or called. + * Subscriptions are never exported regardless of this check. */ export function isDenylistedPath(path: string): boolean { + if (Object.hasOwn(NATIVE_ONLY_PATHS, path)) return true; return path.split('.').some(segment => { const lower = segment.toLowerCase(); return lower === 'test' || lower.startsWith('admin') || lower.startsWith('dev'); diff --git a/services/kilo-mcp/catalog.json b/services/kilo-mcp/catalog.json index 9a22eb6961..5d14811a3b 100644 --- a/services/kilo-mcp/catalog.json +++ b/services/kilo-mcp/catalog.json @@ -65,38 +65,6 @@ ], "searchBlob": "activeSessions.listInstances List the currently active/connected agent instances or devices from the session ingest worker. activesessions listinstances" }, - "activeSessions.widgetCredential": { - "path": "activeSessions.widgetCredential", - "kind": "query", - "summary": "Issue a short-lived authentication token and expiry for the home widget so it can perform authenticated background reads of agent session data. Use this to get the credential a widget needs before making its own authenticated requests.", - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "organizationId": { - "anyOf": [ - { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "organizationId" - ] - }, - "tags": [ - "activesessions", - "widgetcredential", - "organizationid" - ], - "searchBlob": "activeSessions.widgetCredential Issue a short-lived authentication token and expiry for the home widget so it can perform authenticated background reads of agent session data. Use this to get the credential a widget needs before making its own authenticated requests. activesessions widgetcredential organizationid organizationId" - }, "activeSessions.widgetSnapshot": { "path": "activeSessions.widgetSnapshot", "kind": "query", From 2fe9d8d5e384b729c68e1f0f2efe4e7072b07b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 06:59:59 +0200 Subject: [PATCH 07/31] fix(app-shared): refresh waiting, stale, and expired widgets on the active interval --- packages/app-shared/src/home-widget.test.ts | 7 +++++++ packages/app-shared/src/home-widget.ts | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/app-shared/src/home-widget.test.ts b/packages/app-shared/src/home-widget.test.ts index 9f1baca055..9790eab90c 100644 --- a/packages/app-shared/src/home-widget.test.ts +++ b/packages/app-shared/src/home-widget.test.ts @@ -172,6 +172,13 @@ describe('Home-only widget policy', () => { expect(homeWidgetRefreshAt(scheduled, now)).toBe(now + 5 * 60 * 1000); expect(homeWidgetRefreshAt(scheduled, now + 5 * 60 * 1000)).toBe(now + 35 * 60 * 1000); }); + + it('refreshes uncertain empty snapshots on the active cadence', () => { + for (const status of ['waiting', 'stale', 'expired'] as const) { + const data = buildHomeWidgetData({ ...scope, sessions: [], status }); + expect(homeWidgetRefreshAt(data, now)).toBe(now + 30 * 60 * 1000); + } + }); }); describe('native Home presentation timeline', () => { diff --git a/packages/app-shared/src/home-widget.ts b/packages/app-shared/src/home-widget.ts index 6959448366..36e01c95e8 100644 --- a/packages/app-shared/src/home-widget.ts +++ b/packages/app-shared/src/home-widget.ts @@ -248,8 +248,7 @@ export function buildHomeWidgetPresentation( export function homeWidgetRefreshAt(data: HomeWidgetData, now: number): number { const { snapshot } = data; const terminal = snapshot.status === 'signed_out' || snapshot.status === 'privacy'; - const active = !terminal && snapshot.needsInput + snapshot.running + snapshot.scheduled > 0; - const next = now + (active ? ACTIVE_REFRESH_MS : QUIET_REFRESH_MS); + const next = now + (terminal ? QUIET_REFRESH_MS : refreshInterval(snapshot)); const wake = terminal || snapshot.scheduled === 0 ? null : usableTime(snapshot.scheduledAt); // An overdue wake is not proof of running and must not create a busy refresh loop. return wake !== null && wake > now ? Math.min(next, wake) : next; From e28086a441a5aaa2cf2836f7ccb926f7e7a9ec62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 07:00:05 +0200 Subject: [PATCH 08/31] fix(web): stop widget reads and token writes after the route deadline --- .../api/mobile/widgets/push-token/route.ts | 4 ++- apps/web/src/app/api/mobile/widgets/route.ts | 4 +-- apps/web/src/lib/active-sessions-list.ts | 4 +++ .../cloud-agent-next/cloud-agent-client.ts | 18 +++++++---- .../glanceable-agents-snapshot-server.test.ts | 15 +++++---- .../lib/glanceable-agents-snapshot-server.ts | 32 ++++++++++++------- apps/web/src/lib/home-widget-http.ts | 12 +++++-- apps/web/src/lib/home-widget-routes.test.ts | 2 +- 8 files changed, 60 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/api/mobile/widgets/push-token/route.ts b/apps/web/src/app/api/mobile/widgets/push-token/route.ts index 3b3304844b..db603d8844 100644 --- a/apps/web/src/app/api/mobile/widgets/push-token/route.ts +++ b/apps/web/src/app/api/mobile/widgets/push-token/route.ts @@ -17,7 +17,7 @@ const bodySchema = z /** Bounded, widget-only registration. Account and organization come solely from the verified credential. */ export async function POST(request: NextRequest) { - return homeWidgetRequest(async () => { + return homeWidgetRequest(async signal => { const principal = await authenticateHomeWidget(request.headers); const text = await request.text(); if (text.length > 2048) throw new TRPCError({ code: 'BAD_REQUEST' }); @@ -34,6 +34,8 @@ export async function POST(request: NextRequest) { principal.organizationId === null ? isNull(user_activity_tokens.organization_id) : eq(user_activity_tokens.organization_id, principal.organizationId); + // The DB write cannot be cancelled once sent; never start it after a 504. + signal.throwIfAborted(); if (!enabled) { await db .delete(user_activity_tokens) diff --git a/apps/web/src/app/api/mobile/widgets/route.ts b/apps/web/src/app/api/mobile/widgets/route.ts index 3eba658687..c4cd6d60bf 100644 --- a/apps/web/src/app/api/mobile/widgets/route.ts +++ b/apps/web/src/app/api/mobile/widgets/route.ts @@ -5,9 +5,9 @@ import { homeWidgetJson, homeWidgetRequest } from '@/lib/home-widget-http'; /** The read-only widget audience is accepted here, never by ordinary tRPC authentication. */ export async function GET(request: NextRequest) { - return homeWidgetRequest(async () => { + return homeWidgetRequest(async signal => { const principal = await authenticateHomeWidget(request.headers); - const response = await buildHomeWidgetResponseForUser(principal); + const response = await buildHomeWidgetResponseForUser(principal, signal); return homeWidgetJson(response); }); } diff --git a/apps/web/src/lib/active-sessions-list.ts b/apps/web/src/lib/active-sessions-list.ts index 62d148716f..288cd1caf9 100644 --- a/apps/web/src/lib/active-sessions-list.ts +++ b/apps/web/src/lib/active-sessions-list.ts @@ -356,6 +356,8 @@ export type ListActiveSessionsInput = { includeCloudAgentSessions: boolean; /** Snapshot refreshes must fail rather than confirm partial/empty work on an upstream outage. */ requireCompleteSnapshot?: boolean; + /** Caller deadline; aborts the session-ingest fetch. Postgres reads cannot be cancelled. */ + signal?: AbortSignal; }; /** @@ -369,6 +371,7 @@ export async function listActiveSessions({ organizationId, includeCloudAgentSessions, requireCompleteSnapshot = false, + signal, }: ListActiveSessionsInput): Promise<{ sessions: ActiveSession[] }> { // Normal list callers retain best-effort behavior. Background snapshots // require an authoritative read so outages never erase last-known work. @@ -398,6 +401,7 @@ export async function listActiveSessions({ // exactly as any other upstream failure does. const response = await fetchWithinBudget(url, { headers: { Authorization: `Bearer ${token}` }, + signal, }); if (!response.ok) { diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 5d92d531a2..0895596058 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -1308,12 +1308,16 @@ const widgetApprovalKeyResponseSchema = z.object({ * Kilo/cloud session pairing; the key grants no authority. Bounded, and throws * on any non-OK answer so the caller can drop the key while keeping counts. */ -export async function readCloudAgentWidgetApprovalKey(input: { - userId: string; - organizationId: string | null; - kiloSessionId: string; - cloudAgentSessionId: string; -}): Promise { +export async function readCloudAgentWidgetApprovalKey( + input: { + userId: string; + organizationId: string | null; + kiloSessionId: string; + cloudAgentSessionId: string; + }, + signal?: AbortSignal +): Promise { + const budget = AbortSignal.timeout(WIDGET_APPROVAL_KEY_BUDGET_MS); const response = await fetchWithinBudget( `${CLOUD_AGENT_NEXT_API_URL}/internal/widgets/approval-key`, { @@ -1324,7 +1328,7 @@ export async function readCloudAgentWidgetApprovalKey(input: { }, body: JSON.stringify(input), cache: 'no-store', - signal: AbortSignal.timeout(WIDGET_APPROVAL_KEY_BUDGET_MS), + signal: signal ? AbortSignal.any([budget, signal]) : budget, } ); if (!response.ok) { diff --git a/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts b/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts index bc5891ff39..3d808e273a 100644 --- a/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts +++ b/apps/web/src/lib/glanceable-agents-snapshot-server.test.ts @@ -168,12 +168,15 @@ describe('buildHomeWidgetResponseForUser', () => { }); expect(mockedReadApprovalKey).toHaveBeenCalledTimes(1); - expect(mockedReadApprovalKey).toHaveBeenCalledWith({ - userId: 'oauth/user-1', - organizationId: 'org-9', - kiloSessionId: 'ses_oldest_permission', - cloudAgentSessionId: CLOUD_SESSION, - }); + expect(mockedReadApprovalKey).toHaveBeenCalledWith( + { + userId: 'oauth/user-1', + organizationId: 'org-9', + kiloSessionId: 'ses_oldest_permission', + cloudAgentSessionId: CLOUD_SESSION, + }, + undefined + ); expect(response.details.approvalKey).toBe(KEY); expect(response.home).toMatchObject({ approvalKey: KEY, diff --git a/apps/web/src/lib/glanceable-agents-snapshot-server.ts b/apps/web/src/lib/glanceable-agents-snapshot-server.ts index a68effc0cf..8f314a4fd5 100644 --- a/apps/web/src/lib/glanceable-agents-snapshot-server.ts +++ b/apps/web/src/lib/glanceable-agents-snapshot-server.ts @@ -46,7 +46,8 @@ function pickFrontPermissionRow(sessions: readonly ActiveSession[]): ActiveSessi */ async function readApprovalKey( scope: HomeWidgetScope, - front: ActiveSession + front: ActiveSession, + signal: AbortSignal | undefined ): Promise { try { const [row] = await db @@ -64,11 +65,14 @@ async function readApprovalKey( .limit(1); // A remote CLI session has no cloud pending set; the app owns its approval. if (!row?.cloudAgentSessionId) return null; - return await readCloudAgentWidgetApprovalKey({ - ...scope, - kiloSessionId: front.id, - cloudAgentSessionId: row.cloudAgentSessionId, - }); + return await readCloudAgentWidgetApprovalKey( + { + ...scope, + kiloSessionId: front.id, + cloudAgentSessionId: row.cloudAgentSessionId, + }, + signal + ); } catch (error) { console.warn( '[home-widget] approval identity unavailable:', @@ -78,20 +82,24 @@ async function readApprovalKey( } } -/** Authorized callers only. Home details and counts use one complete read. */ -export async function buildHomeWidgetResponseForUser({ - userId, - organizationId, -}: HomeWidgetScope): Promise { +/** + * Authorized callers only. Home details and counts use one complete read. + * `signal` stops the upstream fetches when the caller's deadline expires. + */ +export async function buildHomeWidgetResponseForUser( + { userId, organizationId }: HomeWidgetScope, + signal?: AbortSignal +): Promise { const { sessions } = await listActiveSessions({ userId, organizationId, includeCloudAgentSessions: true, requireCompleteSnapshot: true, + signal, }); const front = pickFrontPermissionRow(sessions); const approvalKey = - front === null ? null : await readApprovalKey({ userId, organizationId }, front); + front === null ? null : await readApprovalKey({ userId, organizationId }, front, signal); const rows: HomeWidgetSessionRow[] = approvalKey === null ? sessions diff --git a/apps/web/src/lib/home-widget-http.ts b/apps/web/src/lib/home-widget-http.ts index 7dff615ce6..381feade0d 100644 --- a/apps/web/src/lib/home-widget-http.ts +++ b/apps/web/src/lib/home-widget-http.ts @@ -10,8 +10,16 @@ export function homeWidgetJson(body: unknown, status = 200): NextResponse { }); } -/** Bound auth, source reads, and registration even without foreground-app headers. */ -export async function homeWidgetRequest(work: () => Promise): Promise { +/** + * Bound auth, source reads, and registration even without foreground-app headers. + * `work` receives the deadline signal: forward it to upstream fetches so they stop + * on expiry. Drizzle over node-postgres takes no AbortSignal, so an in-flight DB + * query still finishes after a 504; check the signal before any write so a timed + * out request never starts a side effect. + */ +export async function homeWidgetRequest( + work: (signal: AbortSignal) => Promise +): Promise { try { return await withDeadline(10_000, work); } catch (error) { diff --git a/apps/web/src/lib/home-widget-routes.test.ts b/apps/web/src/lib/home-widget-routes.test.ts index 74fa6595fd..8827e0e8fb 100644 --- a/apps/web/src/lib/home-widget-routes.test.ts +++ b/apps/web/src/lib/home-widget-routes.test.ts @@ -69,7 +69,7 @@ describe('dedicated native widget routes', () => { ); const response = await GET(request); expect(auth).toHaveBeenCalledWith(request.headers); - expect(source).toHaveBeenCalledWith(principal); + expect(source).toHaveBeenCalledWith(principal, expect.any(AbortSignal)); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toContain('no-store'); expect(await response.json()).toMatchObject({ From 47acab7a1a4cc51c6c447130e5420d2c283c9e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 07:00:51 +0200 Subject: [PATCH 09/31] fix(mobile): use the shared Home empty copy in the iOS widget --- apps/mobile/src/glanceable-ios/active-agents-widget.tsx | 2 +- apps/mobile/src/glanceable-ios/layout-copy.test.ts | 2 +- apps/mobile/src/glanceable-ios/layout-copy.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index 92870b0855..3e8f2795b8 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -186,7 +186,7 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React const primaryLabel = primaryKind === null ? '' : labels[primaryKind]; let statusLabel = COPY.signed_out ?? genericStatus; if (status === 'empty') { - statusLabel = COPY.homeEmpty ?? COPY.noneWaiting ?? 'No agents waiting'; + statusLabel = COPY.homeEmpty ?? 'Nothing running right now'; } else if (status === 'waiting') { statusLabel = COPY.waiting ?? 'Checking agents'; } else if (status === 'privacy' || status === 'unavailable') { diff --git a/apps/mobile/src/glanceable-ios/layout-copy.test.ts b/apps/mobile/src/glanceable-ios/layout-copy.test.ts index 0214ca0a46..7257fd63c7 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.test.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.test.ts @@ -185,7 +185,7 @@ describe('withGlanceableCopy', () => { lastKnown: i18n.t('glanceable.lastKnown'), awaitingUpdate: i18n.t('glanceable.awaitingUpdate'), agent: i18n.t('common.agent'), - noneWaiting: i18n.t('glanceable.noneWaiting'), + homeEmpty: i18n.t('home.noLiveSessions'), }); expect(copy.checked).not.toBe('glanceable.checked'); expect(copy.awaitingUpdate).not.toBe('glanceable.awaitingUpdate'); diff --git a/apps/mobile/src/glanceable-ios/layout-copy.ts b/apps/mobile/src/glanceable-ios/layout-copy.ts index 54b251afb5..130bd9c18b 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.ts @@ -73,7 +73,9 @@ export function glanceableLayoutCopy() { awaitingUpdate: i18n.t('glanceable.awaitingUpdate'), nextRun: i18n.t('glanceable.nextRun'), agent: i18n.t('common.agent'), - noneWaiting: i18n.t('glanceable.noneWaiting'), + // The Home tile's empty state reads the key the Android Home widget and the + // in-app Home card read, so an empty account is worded the same everywhere. + homeEmpty: i18n.t('home.noLiveSessions'), waitingForYou: i18n.t('glanceable.waitingForYou'), permissionRequired: i18n.t('agentChat.permissionCard.title'), answerNeeded: i18n.t('glanceable.answerNeeded'), From 673cfdede2623552129e1d2798326153dd23aa0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 07:01:21 +0200 Subject: [PATCH 10/31] fix(mobile): rank approval candidates like the server's displayed request --- .../src/lib/glanceable/front-approval.ts | 33 +++++++++++-------- .../src/lib/glanceable/widget-actions.test.ts | 28 ++++++++++++++++ .../src/lib/glanceable/widget-actions.ts | 9 +++-- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/lib/glanceable/front-approval.ts b/apps/mobile/src/lib/glanceable/front-approval.ts index c6ecd24b71..fa6c78481e 100644 --- a/apps/mobile/src/lib/glanceable/front-approval.ts +++ b/apps/mobile/src/lib/glanceable/front-approval.ts @@ -24,23 +24,28 @@ export type FrontApprovableRow = GlanceableSessionRow & { id: string }; export function pickFrontApprovableSession( rows: readonly T[] ): T | null { - let front: T | null = null; - let frontAt: number | null = null; - for (const row of rows) { + return rankApprovableSessions(rows)[0] ?? null; +} + +/** + * Every permission row, front first, under the `pickFrontApprovableSession` + * rule: earliest usable `statusUpdatedAt`, rows without one last, list order + * breaking ties. The server binds the widget's approval key to the row this + * rule puts first (`pickFrontPermissionRow` in + * `apps/web/src/lib/glanceable-agents-snapshot-server.ts`), so a bounded scan + * of this ranking always includes the displayed request. + */ +export function rankApprovableSessions(rows: readonly T[]): T[] { + const ranked: { row: T; at: number; index: number }[] = []; + for (const [index, row] of rows.entries()) { if (row.status === 'permission') { const parsed = row.statusUpdatedAt === undefined ? Number.NaN : Date.parse(row.statusUpdatedAt); - const at = Number.isNaN(parsed) ? null : parsed; - if (front === null) { - front = row; - frontAt = at; - } else if (at !== null && (frontAt === null || at < frontAt)) { - // A row without a usable timestamp sorts last: it cannot displace a row - // that carried one, and the earlier list position breaks its own ties. - front = row; - frontAt = at; - } + ranked.push({ row, at: Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed, index }); } } - return front; + // Two untimed rows subtract to NaN, which falls through to list order. + // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; `ranked` is a local copy + ranked.sort((a, b) => a.at - b.at || a.index - b.index); + return ranked.map(entry => entry.row); } diff --git a/apps/mobile/src/lib/glanceable/widget-actions.test.ts b/apps/mobile/src/lib/glanceable/widget-actions.test.ts index 112e2064fe..4349771b2c 100644 --- a/apps/mobile/src/lib/glanceable/widget-actions.test.ts +++ b/apps/mobile/src/lib/glanceable/widget-actions.test.ts @@ -356,6 +356,34 @@ describe('runWidgetApprove', () => { }); }); + it('reaches the request the server displayed past rows without a status time', async () => { + // The server binds the key to the permission row with the earliest + // `statusUpdatedAt`, rows without one last. Ranking these rows by their + // older `createdAt` instead filled the bounded scan before the displayed + // row and reported a live request as stale. + const rpc = wireTrpc({ + sessions: [ + { id: 'untimed-1', status: 'permission', createdAt: '2025-01-01T00:00:00.000Z' }, + { id: 'untimed-2', status: 'permission', createdAt: '2025-01-02T00:00:00.000Z' }, + { id: 'untimed-3', status: 'permission', createdAt: '2025-01-03T00:00:00.000Z' }, + { id: 'newer', status: 'permission', statusUpdatedAt: '2026-01-03T00:00:00.000Z' }, + { id: 'front', status: 'permission', statusUpdatedAt: '2026-01-02T00:00:00.000Z' }, + ], + cloudAgentSessionId: 'workspace_agent_1', + permissions: [{ id: 'perm-1' }], + }); + + await expect(runWidgetApprove(keyFor('front', 'perm-1'))).resolves.toEqual({ + kind: 'approved', + }); + expect(rpc.cliSessionsV2.get.query).toHaveBeenCalledExactlyOnceWith({ session_id: 'front' }); + expect(rpc.answerPermission.mutate).toHaveBeenCalledExactlyOnceWith({ + sessionId: 'workspace_agent_1', + permissionId: 'perm-1', + response: 'once', + }); + }); + it('answers nothing for a stale press after the request was replaced', async () => { const rpc = wireTrpc({ sessions: [{ id: 'waiting', status: 'permission' }], diff --git a/apps/mobile/src/lib/glanceable/widget-actions.ts b/apps/mobile/src/lib/glanceable/widget-actions.ts index e3e59502b8..3d9d571b37 100644 --- a/apps/mobile/src/lib/glanceable/widget-actions.ts +++ b/apps/mobile/src/lib/glanceable/widget-actions.ts @@ -15,6 +15,7 @@ import { trpcClient } from '@/lib/trpc'; import { parseTimestamp } from '@/lib/utils'; import { getTerminalBlankEpoch, isGlanceableOrgLost } from './cleanup'; +import { rankApprovableSessions } from './front-approval'; import { setHomeWidgetDetails } from './home-widget-data'; import { resolveAnsweredRaises } from './attention-rows'; import { newestSessionTitle } from './newest-session'; @@ -212,11 +213,9 @@ async function approveVisiblePermission( const { sessions } = await trpcClient.activeSessions.list.query( buildActiveSessionsTrayInput(organizationId) ); - const candidates = sessions - .filter(row => row.status === 'permission') - // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; filter already copies so nothing shared is mutated - .sort((a, b) => (waitingSince(a) ?? Infinity) - (waitingSince(b) ?? Infinity)) - .slice(0, MAX_APPROVAL_CANDIDATES); + // The server binds the displayed key to the front of this same ranking, so + // the bounded scan always reaches the displayed request first. + const candidates = rankApprovableSessions(sessions).slice(0, MAX_APPROVAL_CANDIDATES); if (candidates.length === 0) { return { kind: resolveWaitingSession(sessions) === null ? 'none' : 'no-permission', From a533929024dd94cbb525cffc8f5be4ad03fe71f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 07:01:47 +0200 Subject: [PATCH 11/31] fix(mobile): degrade instead of crashing when the widget app group is unavailable --- .../ios/HomeWidgetRefreshModule.swift | 13 +-- .../ios/HomeWidgetRefreshStore.swift | 80 +++++++++++-------- .../HomeWidgetTimelineProvider.swift | 10 +-- 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift index 1a61b9f38b..ed0c43fb9b 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift @@ -32,9 +32,10 @@ public class HomeWidgetRefreshModule: Module { guard !destroyed, epoch > operationEpoch else { return nil } operationEpoch = epoch pendingClearEpoch = epoch + // Without the app group there is no stored credential to unregister; the clear still completes. return HomeWidgetRefreshStore.locked { - (HomeWidgetRefreshStore.context(), HomeWidgetRefreshStore.defaults.string(forKey: "homeWidgetPushToken")) - } + (HomeWidgetRefreshStore.context(), $0.string(forKey: "homeWidgetPushToken")) + } ?? (nil, nil) } private func finishClear(_ epoch: Int) { @@ -65,15 +66,15 @@ public class HomeWidgetRefreshModule: Module { } AsyncFunction("setFixtureMode") { (enabled: Bool) in HomeWidgetRefreshStore.fixture(enabled) } AsyncFunction("getData") { () -> [String: Any]? in - HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.readJSON("homeWidgetData") } + HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.readJSON($0, "homeWidgetData") } ?? nil } AsyncFunction("getWidgetPushToken") { () -> String? in - HomeWidgetRefreshStore.defaults.string(forKey: "homeWidgetPushToken") + HomeWidgetRefreshStore.defaults?.string(forKey: "homeWidgetPushToken") } AsyncFunction("isCurrent") { (scope: String, epoch: Int, generation: String) -> Bool in HomeWidgetRefreshStore.locked { - HomeWidgetRefreshStore.current(["scopeKey": scope, "accountEpoch": epoch], generation) - } + HomeWidgetRefreshStore.current($0, ["scopeKey": scope, "accountEpoch": epoch], generation) + } ?? false } AsyncFunction("widgetsChanged") { WidgetCenter.shared.reloadTimelines(ofKind: HomeWidgetRefreshStore.widgetName) } OnDestroy { diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift index f8be86466b..6b22642517 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -9,7 +9,10 @@ enum HomeWidgetRefreshStore { static let widgetName = "ActiveAgentsWidget" static let service = "com.kilocode.home-widget-refresh" static let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String ?? "group.com.kilocode.kiloapp" - static let defaults = UserDefaults(suiteName: group)! + /// Nil when the app group is unusable; `locked` then skips the work instead of trapping. + static let defaults = UserDefaults(suiteName: group) + static let lockURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)? + .appendingPathComponent("home-widget-refresh.lock") static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "HomeWidgetKeychainAccessGroup") as? String static let session: URLSession = { let config = URLSessionConfiguration.ephemeral @@ -18,7 +21,7 @@ enum HomeWidgetRefreshStore { return URLSession(configuration: config, delegate: HomeWidgetNetworkDelegate(), delegateQueue: nil) }() static var layoutDirection: LayoutDirection { - let locale = defaults.string(forKey: "homeWidgetLocale") ?? Locale.current.identifier + let locale = defaults?.string(forKey: "homeWidgetLocale") ?? Locale.current.identifier let language = locale.replacingOccurrences(of: "_", with: "-").split(separator: "-").first.map(String.init) ?? "en" return Locale.characterDirection(forLanguage: language) == .rightToLeft ? .rightToLeft : .leftToRight } @@ -28,11 +31,11 @@ enum HomeWidgetRefreshStore { return query } - static func saveJSON(_ value: Any?, key: String) { + static func saveJSON(_ defaults: UserDefaults, _ value: Any?, key: String) { guard let value, let data = try? JSONSerialization.data(withJSONObject: value) else { return } defaults.set(data, forKey: key) } - static func readJSON(_ key: String) -> [String: Any]? { + static func readJSON(_ defaults: UserDefaults, _ key: String) -> [String: Any]? { guard let bytes = defaults.data(forKey: key) else { return nil } return (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] } @@ -44,16 +47,18 @@ enum HomeWidgetRefreshStore { return value } - static func locked(_ body: () throws -> T) rethrows -> T { - let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)! - .appendingPathComponent("home-widget-refresh.lock") - let fd = open(url.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) - precondition(fd >= 0, "Cannot lock widget state") + /// Runs `body` under the cross-process lock, or returns nil without running it when the + /// app group's defaults, container or lock file are unavailable (a misprovisioned build). + @discardableResult + static func locked(_ body: (UserDefaults) throws -> T) rethrows -> T? { + guard let defaults, let lockURL else { return nil } + let fd = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } flock(fd, LOCK_EX) defer { flock(fd, LOCK_UN); close(fd) } // UserDefaults caches across processes; refresh under the cross-process lock. defaults.synchronize() - return try body() + return try body(defaults) } static func context() -> [String: Any]? { @@ -67,10 +72,10 @@ enum HomeWidgetRefreshStore { } static func configure(_ config: [String: Any]) throws { - try locked { + let stored: Void? = try locked { defaults in let previous = context() if previous?["scopeKey"] as? String != config["scopeKey"] as? String || - previous?["accountEpoch"] as? Int != config["accountEpoch"] as? Int { clearLocked() } + previous?["accountEpoch"] as? Int != config["accountEpoch"] as? Int { clearLocked(defaults) } let data = try JSONSerialization.data(withJSONObject: config) var query = keyQuery query[kSecValueData as String] = data @@ -81,17 +86,20 @@ enum HomeWidgetRefreshStore { } defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") defaults.removeObject(forKey: "homeWidgetTerminalFence") - saveJSON(config["data"], key: "homeWidgetData") - saveJSON(config["home"], key: "homeWidgetPresentation") + saveJSON(defaults, config["data"], key: "homeWidgetData") + saveJSON(defaults, config["home"], key: "homeWidgetPresentation") defaults.set(config["refreshAt"], forKey: "homeWidgetRefreshAt") defaults.set(config["locale"], forKey: "homeWidgetLocale") defaults.set(max(900, (config["refreshAt"] as? Double ?? 0) / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") defaults.synchronize() } + guard stored != nil else { + throw NSError(domain: service, code: 3, userInfo: [NSLocalizedDescriptionKey: "Widget storage unavailable"]) + } WidgetCenter.shared.reloadTimelines(ofKind: widgetName) } - static func clearLocked() { + static func clearLocked(_ defaults: UserDefaults) { SecItemDelete(keyQuery as CFDictionary) defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") for key in ["homeWidgetData", "homeWidgetPresentation", "homeWidgetRefreshAt", "homeWidgetRefreshDelay", "homeWidgetTerminalFence", "__expo_widgets_\(widgetName)_timeline"] { @@ -99,15 +107,19 @@ enum HomeWidgetRefreshStore { } defaults.synchronize() } - static func clear() { locked { clearLocked() }; WidgetCenter.shared.reloadTimelines(ofKind: widgetName) } + static func clear() { + // Without the group container there is nothing else to clear, but the credential still goes. + if locked({ clearLocked($0) }) == nil { SecItemDelete(keyQuery as CFDictionary) } + WidgetCenter.shared.reloadTimelines(ofKind: widgetName) + } static func fixture(_ enabled: Bool) { - locked { + locked { defaults in defaults.set(enabled, forKey: "homeWidgetFixture") defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") defaults.synchronize() } } - static func current(_ config: [String: Any], _ generation: String) -> Bool { + static func current(_ defaults: UserDefaults, _ config: [String: Any], _ generation: String) -> Bool { guard !defaults.bool(forKey: "homeWidgetFixture"), defaults.string(forKey: "homeWidgetGeneration") == generation, let latest = context() ?? defaults.dictionary(forKey: "homeWidgetTerminalFence") else { return false } @@ -132,11 +144,11 @@ enum HomeWidgetRefreshStore { } static func refresh() async { - let captured: ([String: Any], String)? = locked { + let captured = locked { defaults -> ([String: Any], String)? in guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } return (config, generation) - } + } ?? nil guard let (config, generation) = captured, let request = request(config) else { return } do { @@ -144,9 +156,9 @@ enum HomeWidgetRefreshStore { guard let http = response as? HTTPURLResponse else { return } // Only an authentication refusal is terminal; 403 and every other failure keep retained content. if http.statusCode == 401 { - locked { - if current(config, generation) { - clearLocked() + locked { defaults in + if current(defaults, config, generation) { + clearLocked(defaults) defaults.set(["scopeKey": config["scopeKey"]!, "accountEpoch": config["accountEpoch"]!], forKey: "homeWidgetTerminalFence") let copy = config["copy"] as? [String: String] ?? [:] defaults.set([["timestamp": Int(Date().timeIntervalSince1970 * 1000), @@ -164,12 +176,12 @@ enum HomeWidgetRefreshStore { let details = payload["details"] as? [String: Any], let home = payload["home"] as? [String: Any], let refreshAt = payload["refreshAt"] as? Double else { return } - locked { - guard current(config, generation) else { return } - let oldSnapshot = readJSON("homeWidgetData")?["snapshot"] as? [String: Any] + locked { defaults in + guard current(defaults, config, generation) else { return } + let oldSnapshot = readJSON(defaults, "homeWidgetData")?["snapshot"] as? [String: Any] if let oldAt = oldSnapshot?["updatedAt"] as? String, let newAt = snapshot["updatedAt"] as? String, newAt < oldAt { return } - saveJSON(["snapshot": snapshot, "details": details], key: "homeWidgetData") - saveJSON(home, key: "homeWidgetPresentation") + saveJSON(defaults, ["snapshot": snapshot, "details": details], key: "homeWidgetData") + saveJSON(defaults, home, key: "homeWidgetPresentation") defaults.set(refreshAt, forKey: "homeWidgetRefreshAt") defaults.set(max(900, refreshAt / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") let copy = config["copy"] as? [String: String] ?? [:] @@ -226,7 +238,7 @@ enum HomeWidgetRefreshStore { } static func registerStoredPushToken() async { - guard let token = locked({ defaults.string(forKey: "homeWidgetPushToken") }) else { return } + guard let token = locked({ $0.string(forKey: "homeWidgetPushToken") }) ?? nil else { return } let widgets: [WidgetInfo]? = await withCheckedContinuation { continuation in WidgetCenter.shared.getCurrentConfigurations { result in continuation.resume(returning: try? result.get()) @@ -238,21 +250,21 @@ enum HomeWidgetRefreshStore { static func pushToken(_ token: Data, widgets: [WidgetInfo]) async { let hex = token.map { String(format: "%02x", $0) }.joined() - locked { defaults.set(hex, forKey: "homeWidgetPushToken"); defaults.synchronize() } + locked { defaults in defaults.set(hex, forKey: "homeWidgetPushToken"); defaults.synchronize() } await uploadPushToken(hex, enabled: widgets.contains(where: { $0.kind == widgetName })) } static func uploadPushToken(_ token: String, enabled: Bool) async { - let captured: ([String: Any], String)? = locked { + let captured = locked { defaults -> ([String: Any], String)? in guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } return (config, generation) - } + } ?? nil guard let (config, generation) = captured, - locked({ current(config, generation) }), + locked({ current($0, config, generation) }) == true, let request = request(config, input: ["token": token, "enabled": enabled]) else { return } _ = try? await session.data(for: request) - if !locked({ current(config, generation) }) { + if locked({ current($0, config, generation) }) != true { // A completed older upload cannot leave the current installation bound to its former scope. await registerStoredPushToken() } diff --git a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift index bac03ffb28..cf800c6e72 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift @@ -13,18 +13,18 @@ struct HomeWidgetTimelineProvider: TimelineProvider { Task { await HomeWidgetRefreshStore.refresh() base.getTimeline(in: context) { timeline in - let next: Double? = HomeWidgetRefreshStore.locked { - guard !HomeWidgetRefreshStore.defaults.bool(forKey: "homeWidgetFixture"), + let next = HomeWidgetRefreshStore.locked { defaults -> Double? in + guard !defaults.bool(forKey: "homeWidgetFixture"), HomeWidgetRefreshStore.context() != nil else { return nil } - return HomeWidgetRefreshStore.defaults.double(forKey: "homeWidgetRefreshAt") - } + return defaults.double(forKey: "homeWidgetRefreshAt") + } ?? nil guard let next else { completion(Timeline(entries: timeline.entries, policy: .never)) return } // WidgetKit budgets requests; the requested wake is not an exact alarm. let now = Date().timeIntervalSince1970 - let retryDelay = HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.defaults.double(forKey: "homeWidgetRefreshDelay") } + let retryDelay = HomeWidgetRefreshStore.locked { $0.double(forKey: "homeWidgetRefreshDelay") } ?? 0 let wake = Date(timeIntervalSince1970: next / 1000 > now ? max(now + 900, next / 1000) : now + max(900, retryDelay)) completion(Timeline(entries: timeline.entries, policy: .after(wake))) } From 6c18d66b9e7af67697e94d1c85a101ff1465b899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 07:08:13 +0200 Subject: [PATCH 12/31] test(notifications): type the widget-hint fake read --- .../src/lib/glanceable-delivery.test.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 9715efcd94..4434ac88fb 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -197,17 +197,8 @@ describe('NotificationsService.refreshGlanceableSessions', () => { return { rows: [] }; } if (sql.includes('from "user_activity_tokens"') && params.includes('ios_widget')) { - // The widget hint read selects id, token, updated_at for ios_widget rows only. - return { - rows: [...activityRows] - .filter( - ([, row]) => - row.kind === 'ios_widget' && - matches('user_id', row.userId ?? 'usr_1') && - matches('organization_id', row.organizationId ?? null) - ) - .map(([token, row]) => [row.id, token, row.updated_at]), - }; + // These suites seed no widget tokens; keep the widget-hint read out of the Live Activity read. + return { rows: [] }; } if (sql.includes('from "user_activity_tokens"')) { if (params.includes('android_ongoing')) return { rows: [['subscription']] }; From 87696b26e444ba906f64623267202ab95660c625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 17:32:29 +0200 Subject: [PATCH 13/31] fix(mobile): send the widget configuration as JSON so null fields survive the bridge --- .../HomeWidgetRefreshModule.kt | 4 +- .../homewidgetrefresh/HomeWidgetStore.kt | 34 +++++--- .../ios/HomeWidgetRefreshModule.swift | 9 ++- .../ios/HomeWidgetRefreshStore.swift | 10 +++ .../src/lib/glanceable/fixture-harness.ts | 32 +++++++- .../src/lib/glanceable/home-widget-refresh.ts | 79 +++++++++++-------- 6 files changed, 118 insertions(+), 50 deletions(-) diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt index ab77747ced..98f633b078 100644 --- a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt @@ -13,8 +13,8 @@ class HomeWidgetRefreshModule : Module() { override fun definition() = ModuleDefinition { Name("HomeWidgetRefresh") Function("getOperationEpoch") { synchronized(operationLock) { operationEpoch.toDouble() } } - AsyncFunction("configure") { config: Map -> - val json = JSONObject(config) + AsyncFunction("configure") { payload: String -> + val json = JSONObject(payload) val epoch = json.getLong("operationEpoch") synchronized(operationLock) { if (!destroyed && epoch > operationEpoch) { diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt index df7001f86c..517eee1659 100644 --- a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt @@ -22,6 +22,10 @@ internal object HomeWidgetStore { private const val ALIAS = "kilo-home-widget-context" private const val WORK = "kilo-home-widget-refresh" private fun prefs(context: Context) = context.getSharedPreferences("home-widget-refresh", Context.MODE_PRIVATE) + /** A missing key and a JSON null are both "no value"; `optString` would return the literal "null". */ + private fun JSONObject.text(key: String): String? = if (isNull(key)) null else optString(key, null) + private fun JSONObject.integer(key: String): Int? = if (isNull(key)) null else getInt(key) + private fun JSONObject.number(key: String): Long? = if (isNull(key)) null else getLong(key) private fun key(): SecretKey { val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } @@ -41,14 +45,15 @@ internal object HomeWidgetStore { } @Synchronized fun configure(context: Context, config: JSONObject) { val previous = HomeWidgetStore.config(context) - if (previous != null && (previous.optString("scopeKey") != config.optString("scopeKey") || previous.optInt("accountEpoch") != config.optInt("accountEpoch"))) clear(context) + if (previous != null && (previous.text("scopeKey") != config.text("scopeKey") || previous.integer("accountEpoch") != config.integer("accountEpoch"))) clear(context) + val refreshAt = config.number("refreshAt") ?: (System.currentTimeMillis() + 1_800_000) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, key()) val encrypted = cipher.iv + cipher.doFinal(config.toString().toByteArray(Charsets.UTF_8)) check(prefs(context).edit().putString("protectedContext", Base64.encodeToString(encrypted, Base64.NO_WRAP)) .putString("generation", UUID.randomUUID().toString()).putString("data", config.getJSONObject("data").toString()) - .remove("terminalFence").putLong("refreshAt", config.getLong("refreshAt")) - .putLong("refreshDelay", (config.getLong("refreshAt") - System.currentTimeMillis()).coerceAtLeast(900_000)).commit()) + .remove("terminalFence").putLong("refreshAt", refreshAt) + .putLong("refreshDelay", (refreshAt - System.currentTimeMillis()).coerceAtLeast(900_000)).commit()) schedule(context, replace = true) } @Synchronized fun clear(context: Context, cancel: Boolean = true) { @@ -66,7 +71,7 @@ internal object HomeWidgetStore { @Synchronized fun current(context: Context, scope: String, epoch: Int, generation: String): Boolean { if (!hasWidgets(context) || prefs(context).getBoolean("fixture", false) || generation(context) != generation) return false val config = config(context) ?: prefs(context).getString("terminalFence", null)?.let { JSONObject(it) } ?: return false - return config.optString("scopeKey") == scope && config.optInt("accountEpoch") == epoch + return config.text("scopeKey") == scope && config.integer("accountEpoch") == epoch } @Synchronized fun data(context: Context): JSONObject? = prefs(context).getString("data", null)?.let { JSONObject(it) } @Synchronized fun schedule(context: Context, replace: Boolean = false) { @@ -92,23 +97,26 @@ internal object HomeWidgetStore { config = config(context) ?: return null generation = generation(context) } - val endpoint = URL(config.getString("endpoint")) + val endpoint = config.text("endpoint")?.let { URL(it) } ?: return null require(endpoint.protocol == "https" || endpoint.host == "localhost" || endpoint.host == "127.0.0.1" || endpoint.host == "10.0.2.2") + val token = config.text("token") ?: return null + val scopeKey = config.text("scopeKey") ?: return null + val accountEpoch = config.integer("accountEpoch") ?: return null val connection = endpoint.openConnection() as HttpURLConnection connection.connectTimeout = 15_000 connection.readTimeout = 15_000 connection.instanceFollowRedirects = false - connection.setRequestProperty("Authorization", "Bearer ${config.getString("token")}") + connection.setRequestProperty("Authorization", "Bearer $token") connection.setRequestProperty("Accept", "application/json") try { val status = connection.responseCode // Only an authentication refusal is terminal; 403 and every other failure keep retained content. if (status == 401) { synchronized(this) { - if (!current(context, config.getString("scopeKey"), config.getInt("accountEpoch"), generation)) return null + if (!current(context, scopeKey, accountEpoch, generation)) return null clear(context, cancel = false) - val fence = JSONObject().put("scopeKey", config.getString("scopeKey")) - .put("accountEpoch", config.getInt("accountEpoch")).put("generation", generation(context)) + val fence = JSONObject().put("scopeKey", scopeKey) + .put("accountEpoch", accountEpoch).put("generation", generation(context)) prefs(context).edit().putString("terminalFence", fence.toString()).commit() return fence.put("terminal", "privacy") } @@ -131,15 +139,15 @@ internal object HomeWidgetStore { response.getJSONObject("home") val data = JSONObject().put("snapshot", snapshot).put("details", response.getJSONObject("details")) synchronized(this) { - if (!current(context, config.getString("scopeKey"), config.getInt("accountEpoch"), generation)) return null + if (!current(context, scopeKey, accountEpoch, generation)) return null val oldAt = HomeWidgetStore.data(context)?.getJSONObject("snapshot")?.optString("updatedAt") if (oldAt != null && snapshot.getString("updatedAt") < oldAt) return null prefs(context).edit().putString("data", data.toString()).putLong("refreshAt", response.getLong("refreshAt")) .putLong("refreshDelay", (response.getLong("refreshAt") - System.currentTimeMillis()).coerceAtLeast(900_000)).commit() } - return JSONObject().put("response", response).put("scopeKey", config.getString("scopeKey")) - .put("accountEpoch", config.getInt("accountEpoch")).put("generation", generation) - .put("copy", config.getJSONObject("copy")).put("locale", config.getString("locale")) + return JSONObject().put("response", response).put("scopeKey", scopeKey) + .put("accountEpoch", accountEpoch).put("generation", generation) + .put("copy", config.optJSONObject("copy") ?: JSONObject()).put("locale", config.text("locale") ?: "") } finally { connection.disconnect() } } } diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift index ed0c43fb9b..276b2fb8c2 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift @@ -50,7 +50,13 @@ public class HomeWidgetRefreshModule: Module { public func definition() -> ModuleDefinition { Name("HomeWidgetRefresh") Function("getOperationEpoch") { self.currentOperationEpoch() } - AsyncFunction("configure") { (config: [String: Any]) in + AsyncFunction("configure") { (payload: String) in + // The payload crosses the bridge as JSON text: a null field (a Personal + // scope's `organizationId`, a row without an approval key) makes Expo's + // Swift argument conversion fail before this body runs. + guard let config = (try? JSONSerialization.jsonObject(with: Data(payload.utf8))) as? [String: Any] else { + throw NSError(domain: HomeWidgetRefreshStore.service, code: 2, userInfo: [NSLocalizedDescriptionKey: "Widget configuration is not JSON"]) + } guard let epoch = config["operationEpoch"] as? Int else { throw NSError(domain: HomeWidgetRefreshStore.service, code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing widget operation epoch"]) } @@ -65,6 +71,7 @@ public class HomeWidgetRefreshModule: Module { self.finishClear(epoch) } AsyncFunction("setFixtureMode") { (enabled: Bool) in HomeWidgetRefreshStore.fixture(enabled) } + AsyncFunction("setFixtureLocale") { (locale: String) in HomeWidgetRefreshStore.fixtureLocale(locale) } AsyncFunction("getData") { () -> [String: Any]? in HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.readJSON($0, "homeWidgetData") } ?? nil } diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift index 6b22642517..a08200f68f 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -119,6 +119,16 @@ enum HomeWidgetRefreshStore { defaults.synchronize() } } + /// Fixture capture only: the fixture path never runs `configure`, so the app + /// mirrors its active language into the same key the widget chrome reads for + /// its layout direction. Without this the extension would draw a localized + /// surface inside stale chrome. + static func fixtureLocale(_ locale: String) { + locked { defaults in + defaults.set(locale, forKey: "homeWidgetLocale") + defaults.synchronize() + } + } static func current(_ defaults: UserDefaults, _ config: [String: Any], _ generation: String) -> Bool { guard !defaults.bool(forKey: "homeWidgetFixture"), defaults.string(forKey: "homeWidgetGeneration") == generation, diff --git a/apps/mobile/src/lib/glanceable/fixture-harness.ts b/apps/mobile/src/lib/glanceable/fixture-harness.ts index 2af4e78fe1..c9f125b799 100644 --- a/apps/mobile/src/lib/glanceable/fixture-harness.ts +++ b/apps/mobile/src/lib/glanceable/fixture-harness.ts @@ -25,7 +25,11 @@ import { rememberHomeWidgetData, setHomeWidgetDetails, } from './home-widget-data'; -import { clearHomeWidgetRefresh, HomeWidgetRefresh } from './home-widget-refresh'; +import { + clearHomeWidgetRefresh, + HomeWidgetRefresh, + syncFixtureWidgetLocale, +} from './home-widget-refresh'; import { newestSessionTitle } from './newest-session'; import { getLastGlanceableSnapshot } from './persist'; import { type GlanceablePublisherContext } from './publisher'; @@ -44,6 +48,8 @@ import { recordWaitingAsk, selectWaitingAsk } from './waiting-ask'; const FIXTURE_PATH = /(?:^|\/)dev\/glanceable-fixture\/([\w-]+)\/?(?:[?#].*)?$/; const RELEASE = 'release'; +/** Shape-valid, dev-only approval key: it matches no live permission. */ +const FIXTURE_APPROVAL_KEY = 'devfixture'.padEnd(64, '0'); /** The signed-in scope the publisher mount runs under, or null while none. */ let scope: GlanceablePublisherContext | null = null; @@ -70,6 +76,11 @@ async function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContex const authEpoch = currentAuthEpoch(); const blankEpoch = getTerminalBlankEpoch(); await HomeWidgetRefresh?.setFixtureMode(true); + // Fixture mode blocks `syncHomeWidgetRefresh`, so the app-group locale the + // widget chrome mirrors its direction from is written here instead: without + // this the extension keeps the direction of the last real configure and draws + // Arabic copy inside left-to-right chrome. + await syncFixtureWidgetLocale(); const superseded = () => generation !== fixtureGeneration || !isGlanceableFixtureHeld() || @@ -90,13 +101,19 @@ async function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContex const now = Date.now(); // Ids and the cloud connection let the waiting-ask selection name a session, // so the Live Activity and the ongoing card offer Approve like a real ask. - const rows = (fixture.rows?.(now) ?? []).map((row, index) => ({ + // A recorded ask also carries an approval key, which is what makes the Home + // widget draw Approve; the key grants nothing and the press still has to + // match a live permission. + const fixtureRows = fixture.rows?.(now) ?? []; + const approvableIndex = fixtureRows.findIndex(row => row.status === 'permission'); + const rows = fixtureRows.map((row, index) => ({ id: `glanceable-fixture-${index}`, connectionId: CLOUD_AGENT_CONNECTION_ID, status: row.status, statusUpdatedAt: new Date(now - row.ago * 60_000).toISOString(), scheduledAt: row.scheduledAt, title: row.title, + approvalKey: index === approvableIndex ? FIXTURE_APPROVAL_KEY : undefined, })); // Seeded from the persisted revision, which is never below what any sink // accepted, so the native surfaces take the frame instead of discarding it. @@ -109,7 +126,6 @@ async function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContex }); const details = buildHomeWidgetDetails(rows); setHomeWidgetDetails(details); - rememberHomeWidgetData({ snapshot: homeSnapshot, details }); const snapshot = fixture.status === 'expired' ? buildGlanceableSnapshot({ @@ -121,6 +137,16 @@ async function apply(name: GlanceableFixtureName, ctx: GlanceablePublisherContex status: 'expired', }) : { ...homeSnapshot, status: fixture.status ?? homeSnapshot.status }; + // The Home card retains last-known work across the delayed and expiry frames, so + // the record it keeps is the confirmed (happy/empty) snapshot the fixture's rows + // imply — not the fixture's display override, which `rememberHomeWidgetData` + // rejects as an unconfirmed state. The waiting fixture has no confirmed work at + // all, so it retains nothing: an empty record would draw the empty copy instead + // of "Checking agents". Every other fixture remembers the same snapshot the + // sinks receive, so `home.status` matches what the widget draws. + const retained = + fixture.status === 'stale' || fixture.status === 'expired' ? homeSnapshot : snapshot; + rememberHomeWidgetData({ snapshot: retained, details }); const eligible = isEligibleGlanceableWork(snapshot); setSurfaceExtras({ newestSessionTitle: newestSessionTitle(rows), diff --git a/apps/mobile/src/lib/glanceable/home-widget-refresh.ts b/apps/mobile/src/lib/glanceable/home-widget-refresh.ts index 39451cbc01..c0ccdc4ca4 100644 --- a/apps/mobile/src/lib/glanceable/home-widget-refresh.ts +++ b/apps/mobile/src/lib/glanceable/home-widget-refresh.ts @@ -7,12 +7,9 @@ import { buildHomeWidgetPresentation, buildHomeWidgetPresentationTimeline, EMPTY_HOME_WIDGET_DETAILS, - type HomeWidgetData, homeWidgetDataSchema, - type HomeWidgetPresentation, homeWidgetRefreshAt, homeWidgetResponseSchema, - type HomeWidgetTimelineEntry, } from '@kilocode/app-shared/home-widget'; import { requireOptionalNativeModule } from 'expo'; import { z } from 'zod'; @@ -40,23 +37,21 @@ type NativeRefreshContext = { }; type HomeWidgetRefreshModule = { - configure(config: { - endpoint: string; - token: string; - organizationId: string | null; - scopeKey: string; - accountEpoch: number; - operationEpoch: number; - copy: Record; - locale: string; - data: HomeWidgetData; - home: HomeWidgetPresentation; - refreshAt: number; - presentationTimeline: HomeWidgetTimelineEntry[]; - }): Promise; + /** + * The same fields `syncHomeWidgetRefresh` builds, as JSON text. Native parses + * it, so null fields survive the bridge: Expo's Kotlin and Swift argument + * converters reject a null inside an object argument. + */ + configure(payload: string): Promise; clear(operationEpoch: number): Promise; getOperationEpoch(): number; setFixtureMode(enabled: boolean): Promise; + /** + * iOS only: mirror the active app language into the app-group locale key the + * widget chrome reads for its direction. The fixture path never runs + * `configure`, so it is the one writer that can keep the chrome in step. + */ + setFixtureLocale?(locale: string): Promise; // oxlint-disable-next-line anti-slop/no-unknown-returns -- raw native payload, parsed by homeWidgetDataSchema in restoreNativeHomeWidgetData getData(): Promise; getWidgetPushToken(): Promise; @@ -103,6 +98,21 @@ export async function clearHomeWidgetRefresh(): Promise { await clearPromise; } +/** + * Fixture capture only: mirror the active app language into the app-group locale + * key the widget extension reads for its layout direction. `configure` is the + * production writer, but fixture mode holds `syncHomeWidgetRefresh` back and + * clears native state, so nothing else keeps the chrome in step with the copy. + * The Android module has no such key; the optional call is a no-op there. + */ +export async function syncFixtureWidgetLocale(): Promise { + try { + await HomeWidgetRefresh?.setFixtureLocale?.(getHomeWidgetCopy().locale); + } catch (error) { + report(error, 'set_fixture_locale'); + } +} + /** The foreground app renews a dedicated read-only credential, never shares its refresh token. */ export async function syncHomeWidgetRefresh(context: { userId: string; @@ -163,20 +173,27 @@ export async function syncHomeWidgetRefresh(context: { } const now = Date.now(); const copy = getHomeWidgetCopy(); - await HomeWidgetRefresh.configure({ - endpoint: `${API_BASE_URL}/api/mobile/widgets`, - token: configuredCredential.token, - organizationId: context.organizationId, - scopeKey, - accountEpoch: authEpoch, - operationEpoch: epoch, - copy, - locale: copy.locale, - data, - home: buildHomeWidgetPresentation(data, now), - refreshAt: homeWidgetRefreshAt(data, now), - presentationTimeline: buildHomeWidgetPresentationTimeline(data, now), - }); + // The payload crosses the bridge as JSON text and the native side parses it. + // A null field — a Personal scope's `organizationId`, a row without an + // approval key — otherwise fails Expo's Kotlin/Swift argument conversion + // with "Cannot convert '[object Object]' to a Kotlin type", which rejects + // every `configure` and leaves the widget with no credential to refresh. + await HomeWidgetRefresh.configure( + JSON.stringify({ + endpoint: `${API_BASE_URL}/api/mobile/widgets`, + token: configuredCredential.token, + organizationId: context.organizationId, + scopeKey, + accountEpoch: authEpoch, + operationEpoch: epoch, + copy, + locale: copy.locale, + data, + home: buildHomeWidgetPresentation(data, now), + refreshAt: homeWidgetRefreshAt(data, now), + presentationTimeline: buildHomeWidgetPresentationTimeline(data, now), + }) + ); } catch (error) { if (!superseded()) { report(error, 'configure_native_refresh'); From f8bcd2e58e0e1bec5f8a785bea46c1d90666903d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 9 Oct 2026 17:32:37 +0200 Subject: [PATCH 14/31] fix(mobile): fill every widget size and keep both platforms in step --- .../active-agents-widget-card.tsx | 340 ++++++++++++ .../active-agents-widget-parts.tsx | 149 +++-- .../active-agents-widget-short.tsx | 180 ++++++ .../active-agents-widget.tsx | 205 +------ .../src/glanceable-android/home-copy.ts | 3 + .../glanceable-ios/active-agents-widget.tsx | 518 ++++++++++++------ .../src/glanceable-ios/widget-actions.test.ts | 52 +- 7 files changed, 1039 insertions(+), 408 deletions(-) create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-card.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-short.tsx diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx new file mode 100644 index 0000000000..3de9a0f891 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx @@ -0,0 +1,340 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget, type WidgetInfo } from 'react-native-android-widget'; + +import { + ACTION_TARGET, + type Copy, + countRow, + detail, + entryRows, + footer, + header, + hero, + heroVariantFor, + type Paint, + readingOrder, + text, +} from './active-agents-widget-parts'; +import { type AndroidWidgetProps } from './widget-props'; + +/** Keep the existing native renderer's height buckets. */ +export const SHORT_HEIGHT = 180; +/** Every card insets its content by this much on every side. */ +export const MARGIN = 12; +/** Footer line height: the checked stamp owns only its own line, not the leftover. */ +const FOOTER_HEIGHT = 16; +/** One line of secondary copy between the hero and the entries. */ +const DETAIL_HEIGHT = 24; +/** Copy size is a fraction of the card width so a locked portrait cell reads deliberate. */ +const COPY_RATIO = 0.078; +/** Capped so a locked cell reads as a state panel, not a headline stretched to the width. */ +const COPY_MAX = 24; +/** The lock is sized from the copy, not the cell: its full height is ~1.35x the copy's + * cap height (the host font's cap height is ~0.75 of its size) so glyph and words match. */ +const LOCK_PER_COPY = 1.35 * 0.75; +/** The host font's line box and the glyph-to-copy gap, both per unit of copy size. */ +const COPY_LINE = 1.32; +const LOCK_GAP = 0.5; + +/** Everything one layout needs to draw a placed widget in one palette. */ +export type Frame = { + props: AndroidWidgetProps; + copy: Copy; + paint: Paint; + info: WidgetInfo; +}; + +/** One of the three equal flexible gaps that spread the free height down the card. */ +function gap(key: string) { + return ; +} + +/** The primary line for a band, told how much width it actually has. */ +export function rowDetail( + copy: Copy, + paint: Paint, + slot: { height: number; size: number; available: number } +) { + return detail(copy, paint, { ...slot, fallback: false }); +} + +/** Row typography scales with the height a cell can afford a row. */ +const ENTRY_SIZES = [ + { min: 60, title: 16, reason: 13 }, + { min: 50, title: 15, reason: 12 }, +]; + +function entrySizes(rowHeight: number) { + return ( + ENTRY_SIZES.find(entry => rowHeight >= entry.min) ?? { + title: 14, + reason: 12, + } + ); +} + +/** + * The list owns exactly the height its rows need: rows stay capped so a tall cell + * shows roomy rows rather than two blocks pushed to the edges, and the block keeps + * its rows centred. The leftover belongs to the three gaps around it. + */ +function entries(copy: Copy, paint: Paint, slot: { slots: number; rowHeight: number }) { + const { title: titleSize, reason: reasonSize } = entrySizes(slot.rowHeight); + return ( + + {entryRows(copy) + .slice(0, slot.slots) + .map((row, index) => ( + + {text(row.title, paint, { size: titleSize })} + {row.time === null ? null : text(row.time, paint, { size: reasonSize, muted: true })} + + ))} + + ); +} + +/** The natural hero height for a body, picked in bands so no count or label is clipped. */ +function naturalHero(body: number): number { + if (body >= 300) { + return 84; + } + if (body >= 210) { + return 68; + } + if (body >= 140) { + return 44; + } + return 32; +} + +/** + * The full card: brand header, a hero block scaled to the cell (the primary line + * and support counts travel with it), the entry rows, then the footer. Three equal + * flexible gaps spread the leftover, so the free height never pools in one place. + */ +export function card({ props, copy, paint, info }: Frame) { + const wide = info.width >= 266; + const inner = info.height - MARGIN * 2; + const rows = entryRows(copy); + const hasDetail = copy.detail !== null && copy.detail !== ''; + // The primary line and the first entry are the same agent while it waits: show it once. + const firstEntry = rows[0]; + const duplicatesEntry = + copy.detail !== null && + copy.detail !== '' && + firstEntry !== undefined && + firstEntry.title !== '' && + copy.detail.includes(firstEntry.title); + const supportRows = wide ? 0 : Math.min(3, copy.secondaryCounts.length); + // Wide cards show the support counts beside the hero, so the hero must clear them. + const besideCounts = wide ? Math.min(3, copy.secondaryCounts.length) * 26 : 0; + const plan = (suppressDetail: boolean) => { + const body = + inner - + ACTION_TARGET - + (suppressDetail || !hasDetail ? 0 : DETAIL_HEIGHT) - + supportRows * 26 - + FOOTER_HEIGHT; + const heroHeight = Math.max(besideCounts, Math.min(naturalHero(body), Math.max(32, body))); + const slots = Math.max(0, Math.min(3, Math.floor((body - heroHeight) / 44))); + const rowHeight = + slots === 0 ? 0 : Math.max(44, Math.min(64, Math.floor((body - heroHeight) / slots))); + return { heroHeight, slots, rowHeight }; + }; + // Suppressing the primary line is only worth it while its entry is actually listed. + let suppressed = duplicatesEntry; + let planned = plan(suppressed); + if (suppressed && planned.slots === 0) { + suppressed = false; + planned = plan(suppressed); + } + const { rowHeight } = planned; + // Never reserve height for rows the copy does not carry. + const slots = Math.min(planned.slots, rows.length); + // With no rows the hero may take the room the entries would have used. + const free = + inner - + ACTION_TARGET - + supportRows * 26 - + FOOTER_HEIGHT - + (suppressed || !hasDetail ? 0 : DETAIL_HEIGHT); + const heroHeight = slots === 0 ? Math.min(free, 140) : planned.heroHeight; + const variant = heroVariantFor(heroHeight); + // A 172dp-wide card can only fit the longest agent title at 11dp. + const detailSize = info.width >= 280 ? 12 : 11; + const avail = info.width - MARGIN * 2; + return ( + + {header(props, paint, wide)} + {gap('gap-top')} + + + {readingOrder( + [ + + {hero(copy, paint, variant)} + , + wide ? ( + + {copy.secondaryCounts.map(line => countRow(line, paint))} + + ) : null, + ], + paint.rtl + )} + + {suppressed || !hasDetail + ? null + : rowDetail(copy, paint, { + height: DETAIL_HEIGHT, + size: detailSize, + available: avail, + })} + {wide ? null : ( + + {copy.secondaryCounts.slice(0, supportRows).map(line => countRow(line, paint))} + + )} + + {slots > 0 ? gap('gap-middle') : null} + {slots > 0 ? entries(copy, paint, { slots, rowHeight }) : null} + {gap('gap-bottom')} + {footer(copy, paint, FOOTER_HEIGHT)} + + ); +} + +/** A padlock drawn from two rectangles: a rounded shackle sitting on a rounded body. */ +function lockGlyph(paint: Paint, size: number) { + const stroke = Math.max(2, Math.round(size * 0.13)); + const bodyHeight = Math.round(size * 0.6); + const shackleWidth = Math.round(size * 0.54); + return ( + + + + + ); +} + +/** + * The locked card (privacy/signed-out): the brand header, then one state block - + * a lock and the existing privacy copy - centred in the free height. The copy is + * sized from the cell so a portrait card reads as a deliberate composition. + */ +export function locked({ copy, paint, info }: Frame) { + const band = info.height < SHORT_HEIGHT; + const brandHeight = band ? 22 : ACTION_TARGET; + let brandSize = 18; + if (band) { + brandSize = 14; + } + if (info.height < 96) { + brandSize = 12; + } + // The thinnest bands drop the glyph: a copy on one line beats a squeezed lock. + const showLock = info.height >= 92; + const footerHeight = copy.checked === null ? 0 : FOOTER_HEIGHT; + // Copy is capped by width; the free height then bounds it through the whole stack + // (glyph, gap and one line), so the block stays whole and the spacing carries the rest. + const byWidth = Math.min(COPY_MAX, Math.max(12, Math.round(info.width * COPY_RATIO))); + const stack = showLock ? LOCK_PER_COPY + LOCK_GAP + COPY_LINE : COPY_LINE; + const inner = info.height - (band ? 0 : MARGIN * 2) - brandHeight - footerHeight - 8; + const size = Math.max(11, Math.min(byWidth, Math.floor(inner / stack))); + const lockSize = Math.max(12, Math.round(size * LOCK_PER_COPY)); + return ( + + + {text('Kilo', paint, { size: brandSize, bold: true })} + + + {showLock ? lockGlyph(paint, lockSize) : null} + {text(copy.status ?? '', paint, { size, align: 'center' })} + + {copy.checked === null ? null : footer(copy, paint, FOOTER_HEIGHT)} + + ); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx index 1580686ec9..f7bf2f6847 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx @@ -7,9 +7,8 @@ import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; import { type AndroidWidgetProps } from './widget-props'; -// Short rows reserve two 48dp targets. +// Short rows reserve two 48dp targets, side by side on one band or stacked in a column. export const ACTION_TARGET = 48; -export const ENTRY_ROW_HEIGHT = 48; type Palette = { background: HexColor; @@ -23,6 +22,7 @@ type Palette = { export type Paint = { palette: Palette; rtl: boolean }; export type Copy = NonNullable; type CountLine = Copy['secondaryCounts'][number]; +export type EntryRow = { title: string; time: string | null }; /** Strip and compact heroes put the label beside the number; cards stack it. */ export type HeroVariant = 'strip' | 'compact' | 'regular' | 'large'; @@ -40,10 +40,39 @@ const DOT_COLORS = { scheduled: 'mutedForeground', } as const satisfies Record; +const TALL_SCRIPT = /[\u0600-\u08FF\u0900-\u0DFF]/u; + +/** + * Largest font whose line box fits `height`. Scripts with tall fallback metrics + * (Arabic-Indic, Devanagari) need the host's 1.62 line ratio, not the Latin 1.32. + */ +function fittedSize(value: string, height: number, preferred: number): number { + const ratio = TALL_SCRIPT.test(value) ? 1.62 : 1.32; + const sizes = [preferred, 12, 11, 10, 9].filter(size => size <= preferred); + return sizes.find(size => Math.ceil(size * ratio) <= height) ?? 9; +} + export function readingOrder(children: React.ReactNode[], rtl: boolean) { return rtl ? [...children].toReversed() : children; } +/** + * Pick the hero variant a band can hold, using the tallest fallback line metrics + * so neither the count nor its label is ever clipped. + */ +export function heroVariantFor(heroHeight: number): HeroVariant { + if (heroHeight >= 82) { + return 'large'; + } + if (heroHeight >= 68) { + return 'regular'; + } + if (heroHeight >= 33) { + return 'compact'; + } + return 'strip'; +} + export function text( value: string, paint: Paint, @@ -52,6 +81,8 @@ export function text( bold?: boolean; muted?: boolean; lines?: number; + /** Centred copy ignores the surface direction: it is a composition axis, not a reading edge. */ + align?: 'left' | 'center' | 'right'; } ) { return ( @@ -64,7 +95,7 @@ export function text( fontSize: options.size, fontWeight: options.bold ? 'bold' : 'normal', color: options.muted ? paint.palette.mutedForeground : paint.palette.foreground, - textAlign: paint.rtl ? 'right' : 'left', + textAlign: options.align ?? (paint.rtl ? 'right' : 'left'), }} /> ); @@ -128,10 +159,21 @@ export function action( export function header(props: AndroidWidgetProps, paint: Paint, wide: boolean) { return ( - + {readingOrder( [ - + {text('Kilo', paint, { size: 18, bold: true })} , action(props, wide ? 'approve-labeled' : 'approve', paint), @@ -162,6 +204,7 @@ export function hero(copy: Copy, paint: Paint, variant: HeroVariant) { return ( {number}, - + {label} , ], @@ -190,7 +236,13 @@ export function countRow(line: CountLine, paint: Paint) { return ( {readingOrder( [ @@ -204,7 +256,10 @@ export function countRow(line: CountLine, paint: Paint) { }} />, {text(line.count, paint, { size: 15, bold: true })}, - + {text(line.label, paint, { size: 13, muted: true })} , ], @@ -215,44 +270,66 @@ export function countRow(line: CountLine, paint: Paint) { } /** `fallback` fills an empty detail slot with the first support count. */ -export function detail(copy: Copy, paint: Paint, slot: { height: number; fallback: boolean }) { +export function detailValue(copy: Copy, fallback: boolean): string { + if (copy.detail !== null && copy.detail !== '') { + return copy.detail; + } const first = copy.secondaryCounts[0]; - const value = - copy.detail ?? (slot.fallback && first !== undefined ? `${first.count} ${first.label}` : ''); + return fallback && first !== undefined ? `${first.count} ${first.label}` : ''; +} + +export function detail( + copy: Copy, + paint: Paint, + slot: { height: number; fallback: boolean; size?: number; available?: number } +) { + const composed = detailValue(copy, slot.fallback); + const first = entryRows(copy)[0]; + // A 148-179dp line fits the agent title, not "Last known - ". + const prefixed = + first !== undefined && + first.title !== '' && + composed.includes(first.title) && + composed !== first.title; + const value = prefixed && (slot.available ?? Infinity) < 200 ? first.title : composed; + const preferred = slot.size ?? (slot.height < 20 ? 11 : 12); return ( - <FlexWidget style={{ height: slot.height, justifyContent: 'center' }}> - {text(value, paint, { size: slot.height < 20 ? 11 : 12, muted: true })} + <FlexWidget + style={{ + width: 'match_parent', + height: slot.height, + justifyContent: 'center', + alignItems: paint.rtl ? 'flex-end' : 'flex-start', + }} + > + {text(value, paint, { + size: fittedSize(value, slot.height, preferred), + muted: true, + })} </FlexWidget> ); } export function footer(copy: Copy, paint: Paint, height = 20) { + const value = copy.checked ?? ''; + const preferred = height >= 20 ? 12 : 11; return ( - <FlexWidget style={{ height, justifyContent: 'center' }}> - {text(copy.checked ?? '', paint, { size: 11, muted: true })} + <FlexWidget + style={{ + width: 'match_parent', + height, + justifyContent: 'center', + alignItems: paint.rtl ? 'flex-end' : 'flex-start', + }} + > + {text(value, paint, { size: fittedSize(value, height, preferred), muted: true })} </FlexWidget> ); } -export function entries(copy: Copy, paint: Paint, slots: number) { - const rows = - copy.waitingAgents.length > 0 - ? copy.waitingAgents.map(agent => ({ title: agent.title, time: agent.reason })) - : copy.scheduledAgents; - return ( - <FlexWidget style={{ height: slots * ENTRY_ROW_HEIGHT }}> - {rows.slice(0, slots).map((row, index) => ( - <FlexWidget - key={index} - style={{ - height: ENTRY_ROW_HEIGHT, - justifyContent: 'center', - }} - > - {text(row.title, paint, { size: 14 })} - {row.time === null ? null : text(row.time, paint, { size: 12, muted: true })} - </FlexWidget> - ))} - </FlexWidget> - ); +/** Earliest waits first, then scheduled wakes: the card lists whichever the copy carries. */ +export function entryRows(copy: Copy): EntryRow[] { + return copy.waitingAgents.length > 0 + ? copy.waitingAgents.map(agent => ({ title: agent.title, time: agent.reason })) + : copy.scheduledAgents.map(agent => ({ title: agent.title, time: agent.time })); } diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx new file mode 100644 index 0000000000..8fcb4af73a --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx @@ -0,0 +1,180 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget } from 'react-native-android-widget'; + +import { type Frame, MARGIN, rowDetail } from './active-agents-widget-card'; +import { + action, + ACTION_TARGET, + detailValue, + footer, + hero, + type Paint, + readingOrder, + text, +} from './active-agents-widget-parts'; +import { type AndroidWidgetProps } from './widget-props'; + +/** Above this width a short cell puts the actions beside the text instead of under it. */ +const SIDE_ACTIONS_WIDTH = 300; + +/** The two 48dp controls, side by side, always in a fixed 100dp group. */ +function actions(props: AndroidWidgetProps, paint: Paint) { + return ( + <FlexWidget key="actions" style={{ flexDirection: 'row', alignItems: 'center', flexGap: 4 }}> + {readingOrder([action(props, 'approve', paint), action(props, 'create', paint)], paint.rtl)} + </FlexWidget> + ); +} + +/** + * A wide short cell (landscape rows, 4x1 portrait): Kilo and the count share the + * first band, then the detail and the checked time; the actions sit in a 100dp + * column beside them so both lines get the full width. Each band is one line. + */ +function shortWide({ props, copy, paint, info }: Frame) { + const hasDetail = detailValue(copy, false) !== ''; + const textWidth = info.width - (ACTION_TARGET * 2 + 4) - MARGIN * 2 - 4; + const smallSize = info.width >= 400 ? 12 : 10; + const hasChecked = (copy.checked ?? '') !== ''; + const lines = 1 + (hasDetail ? 1 : 0) + (hasChecked ? 1 : 0); + // A 30dp head (22dp count) plus 15dp per extra line is the tightest every script fits. + const extra = lines - 1; + const outer = Math.max(0, Math.min(MARGIN, Math.floor((info.height - (30 + extra * 15)) / 2))); + const content = info.height - 2 * outer; + let small = Math.max(15, Math.min(24, Math.floor((content - 30) / Math.max(1, extra)))); + let head = content - small * extra; + if (head < 30) { + small = Math.floor((content - 30) / Math.max(1, extra)); + head = content - small * extra; + } + return ( + <FlexWidget + style={{ + width: 'match_parent', + flexDirection: 'row', + alignItems: 'center', + flexGap: 4, + marginHorizontal: MARGIN, + marginVertical: outer, + }} + > + {readingOrder( + [ + <FlexWidget key="text" style={{ width: 0, flex: 1 }}> + <FlexWidget + key="head" + style={{ + width: 'match_parent', + height: head, + flexDirection: 'row', + alignItems: 'center', + flexGap: 6, + }} + > + {readingOrder( + [ + <FlexWidget key="brand"> + {text('Kilo', paint, { size: 14, bold: true })} + </FlexWidget>, + <FlexWidget key="hero" style={{ width: 0, flex: 1 }}> + {hero(copy, paint, 'strip')} + </FlexWidget>, + ], + paint.rtl + )} + </FlexWidget> + {hasDetail + ? rowDetail(copy, paint, { height: small, size: smallSize, available: textWidth }) + : null} + {hasChecked ? footer(copy, paint, small) : null} + </FlexWidget>, + actions(props, paint), + ], + paint.rtl + )} + </FlexWidget> + ); +} + +/** + * The narrowest short cell (2x1 portrait, 172dp): the count owns the header band + * beside the two 48dp controls, then the brand with its label, the detail and the + * checked time each get the full 148dp width. Every line is one line, so nothing + * ellipsises. The label cannot join the count band: a 22dp count, a 48dp control + * band and two tall-script-safe 15dp lines already need 108dp of the 104dp cell. + */ +function shortNarrow({ props, copy, paint, info }: Frame) { + const count = copy.primaryLabel !== null; + const label = count ? copy.primaryCount : 'Kilo'; + const hasDetail = detailValue(copy, false) !== ''; + const hasChecked = (copy.checked ?? '') !== ''; + const band = 20; + const line = 15; + const avail = info.width - MARGIN * 2; + const total = ACTION_TARGET + band + (hasDetail ? line : 0) + (hasChecked ? line : 0); + const outer = Math.max(0, Math.min(MARGIN, Math.floor((info.height - total) / 2))); + return ( + <FlexWidget style={{ width: 'match_parent', marginHorizontal: MARGIN, marginVertical: outer }}> + <FlexWidget + key="head" + style={{ + width: 'match_parent', + height: ACTION_TARGET, + flexDirection: 'row', + alignItems: 'center', + flexGap: 6, + }} + > + {readingOrder( + [ + <FlexWidget + key="count" + style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} + > + {count + ? text(label, paint, { size: 22, bold: true }) + : text(label, paint, { size: 15, bold: true })} + </FlexWidget>, + actions(props, paint), + ], + paint.rtl + )} + </FlexWidget> + <FlexWidget + key="label" + style={{ + width: 'match_parent', + height: band, + flexDirection: 'row', + alignItems: 'center', + flexGap: 6, + }} + > + {readingOrder( + [ + count ? ( + <FlexWidget key="brand">{text('Kilo', paint, { size: 12, bold: true })}</FlexWidget> + ) : null, + <FlexWidget + key="value" + style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} + > + {count + ? text(copy.primaryLabel ?? '', paint, { size: 12, muted: true }) + : text(copy.status ?? '', paint, { size: 12, bold: true })} + </FlexWidget>, + ], + paint.rtl + )} + </FlexWidget> + {hasDetail ? rowDetail(copy, paint, { height: line, size: 11, available: avail }) : null} + {hasChecked ? footer(copy, paint, line) : null} + </FlexWidget> + ); +} + +export function short(frame: Frame) { + return frame.info.width >= SIDE_ACTIONS_WIDTH ? shortWide(frame) : shortNarrow(frame); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx index 0accf60cf3..708bcb6113 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -9,183 +9,20 @@ import { import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; -import { - action, - ACTION_TARGET, - type Copy, - countRow, - detail, - entries, - ENTRY_ROW_HEIGHT, - footer, - header, - hero, - type Paint, - readingOrder, - text, -} from './active-agents-widget-parts'; +import { card, type Frame, locked, MARGIN, SHORT_HEIGHT } from './active-agents-widget-card'; +import { type Copy, type Paint } from './active-agents-widget-parts'; +import { short } from './active-agents-widget-short'; import { type AndroidWidgetProps } from './widget-props'; export const WIDGET_NAME = 'ActiveAgentsWidget'; -// Keep the existing native renderer's height buckets. -const SHORT_HEIGHT = 180; - -/** Everything one layout needs to draw a placed widget in one palette. */ -type Frame = { props: AndroidWidgetProps; copy: Copy; paint: Paint; info: WidgetInfo }; - -/** A landscape launcher row: brand/checked, hero/detail, then two 48dp actions. */ -function strip({ props, copy, paint, info }: Frame) { - const wide = info.width >= 467; - return ( - <FlexWidget style={{ flexDirection: 'row', alignItems: 'center', flexGap: 6 }}> - {readingOrder( - [ - <FlexWidget key="brand" style={{ width: 96 }}> - {text('Kilo', paint, { size: 14, bold: true })} - {footer(copy, paint)} - </FlexWidget>, - <FlexWidget - key="body" - style={{ flex: 1, flexDirection: 'row', alignItems: 'center', flexGap: 8 }} - > - {readingOrder( - [ - <FlexWidget key="hero" style={{ flex: 1 }}> - {hero(copy, paint, 'strip')} - {detail(copy, paint, { height: 18, fallback: !wide })} - </FlexWidget>, - wide ? ( - <FlexWidget - key="support" - style={{ width: Math.floor((info.width - 226) * 0.4), height: 52 }} - > - {copy.secondaryCounts.slice(0, 2).map(line => countRow(line, paint))} - </FlexWidget> - ) : null, - ], - paint.rtl - )} - </FlexWidget>, - action(props, 'approve', paint), - action(props, 'create', paint), - ], - paint.rtl - )} - </FlexWidget> - ); -} - -/** 2x1 keeps the hero, wake/detail and checked slots; actions use the full height. */ -function compact({ props, copy, paint, info }: Frame) { - const innerHeight = info.height - 8; - const wide = info.width >= 266; - const supportSlots = Math.min(3, Math.floor((innerHeight - 34) / 26)); - return ( - <FlexWidget style={{ flexDirection: 'row', alignItems: 'center', flexGap: 4 }}> - {readingOrder( - [ - <FlexWidget key="body" style={{ flex: 1, height: innerHeight }}> - <FlexWidget style={{ height: 16 }}> - {text('Kilo', paint, { size: 12, bold: true })} - </FlexWidget> - <FlexWidget - style={{ - flex: 1, - flexDirection: 'row', - alignItems: 'center', - flexGap: wide ? 12 : 0, - }} - > - {readingOrder( - [ - <FlexWidget key="hero" style={{ flex: 1, height: innerHeight - 34 }}> - <FlexWidget style={{ flex: 1, justifyContent: 'center' }}> - {hero(copy, paint, info.height < 130 ? 'compact' : 'regular')} - </FlexWidget> - {detail(copy, paint, { height: 20, fallback: !wide })} - </FlexWidget>, - wide ? ( - <FlexWidget - key="support" - style={{ - width: Math.floor((info.width - 72) * 0.46), - height: supportSlots * 26, - }} - > - {copy.secondaryCounts - .slice(0, supportSlots) - .map(line => countRow(line, paint))} - </FlexWidget> - ) : null, - ], - paint.rtl - )} - </FlexWidget> - {footer(copy, paint, 18)} - </FlexWidget>, - <FlexWidget - key="actions" - style={{ width: ACTION_TARGET, height: innerHeight, justifyContent: 'space-between' }} - > - {action(props, 'create', paint)} - {action(props, 'approve', paint)} - </FlexWidget>, - ], - paint.rtl - )} - </FlexWidget> - ); -} - -function card({ props, copy, paint, info }: Frame) { - const large = info.height >= 300; - const wide = info.width >= 266; - const heroHeight = large ? 100 : 80; - // Wide cards show the support counts beside the hero instead of below it. - const stackedSupportSlots = large ? 3 : 1; - const supportSlots = wide ? 0 : stackedSupportSlots; - const innerHeight = info.height - 24; - const entryRoom = innerHeight - ACTION_TARGET - heroHeight - 24 - supportSlots * 26 - 20; - const entrySlots = Math.max(0, Math.min(3, Math.floor(entryRoom / ENTRY_ROW_HEIGHT))); - return ( - <FlexWidget style={{ height: 'match_parent' }}> - {header(props, paint, wide)} - <FlexWidget - style={{ height: heroHeight, flexDirection: 'row', alignItems: 'center', flexGap: 12 }} - > - {readingOrder( - [ - <FlexWidget key="hero" style={{ flex: 1 }}> - {hero(copy, paint, large ? 'large' : 'regular')} - </FlexWidget>, - wide ? ( - <FlexWidget - key="support" - style={{ - width: Math.floor((info.width - 36) * 0.46), - height: heroHeight, - justifyContent: 'center', - }} - > - {copy.secondaryCounts.map(line => countRow(line, paint))} - </FlexWidget> - ) : null, - ], - paint.rtl - )} - </FlexWidget> - {detail(copy, paint, { height: 24, fallback: false })} - {wide ? null : ( - <FlexWidget style={{ height: supportSlots * 26 }}> - {copy.secondaryCounts.slice(0, supportSlots).map(line => countRow(line, paint))} - </FlexWidget> - )} - {entries(copy, paint, entrySlots)} - <FlexWidget style={{ flex: 1 }} /> - {footer(copy, paint)} - </FlexWidget> - ); +/** Privacy, org-lock and signed-out all share the centred locked composition. */ +function compose(frame: Frame) { + const { statusKind } = frame.copy; + if (statusKind === 'privacy' || statusKind === 'unavailable' || statusKind === 'signed_out') { + return locked(frame); + } + return frame.info.height < SHORT_HEIGHT ? short(frame) : card(frame); } // Old persisted props have no Home presentation. They get the same hierarchy; @@ -196,6 +33,7 @@ function copyFor(props: AndroidWidgetProps): Copy { } const primary = props.countLines.find(line => line.label === props.primaryLabel); return { + statusKind: primary === undefined ? 'signed_out' : 'content', primaryCount: primary?.count ?? '', primaryLabel: primary?.label ?? null, status: props.statusLine, @@ -208,34 +46,23 @@ function copyFor(props: AndroidWidgetProps): Copy { }; } -function layoutFor(frame: Frame) { - if (frame.info.height < 80) { - return strip(frame); - } - if (frame.info.height < SHORT_HEIGHT) { - return compact(frame); - } - return card(frame); -} - function draw(props: AndroidWidgetProps, info: WidgetInfo, paint: Paint) { - const copy = copyFor(props); - const isStrip = info.height < 80; - const isCompact = info.height < SHORT_HEIGHT; + const frame: Frame = { props, copy: copyFor(props), paint, info }; return ( <FlexWidget clickAction="OPEN_URI" clickActionData={{ uri: 'kiloapp:///cloud/sessions' }} - accessibilityLabel={copy.accessibilityLabel} + accessibilityLabel={frame.copy.accessibilityLabel} style={{ width: 'match_parent', height: 'match_parent', borderRadius: 20, backgroundColor: paint.palette.background, - padding: isStrip || isCompact ? 4 : 12, + // Short bands inset their own content so the free height reaches the text. + padding: info.height < SHORT_HEIGHT ? 0 : MARGIN, }} > - {layoutFor({ props, copy, paint, info })} + {compose(frame)} </FlexWidget> ); } diff --git a/apps/mobile/src/glanceable-android/home-copy.ts b/apps/mobile/src/glanceable-android/home-copy.ts index be67a5ac13..ddca1ed81d 100644 --- a/apps/mobile/src/glanceable-android/home-copy.ts +++ b/apps/mobile/src/glanceable-android/home-copy.ts @@ -9,6 +9,8 @@ export type GlanceableClockFormat = (at: string, options?: { includeDate: boolea /** Translated Home widget copy; the layout never translates or formats on its own. */ export type AndroidWidgetHomeCopy = { + /** The presented state: privacy/signed_out/unavailable centre a locked composition. */ + statusKind: HomeWidgetPresentation['status']; primaryCount: string; primaryLabel: string | null; status: string | null; @@ -112,6 +114,7 @@ export function buildAndroidHomeCopy( ...secondaryCounts.map(line => `${line.count} ${line.label}`), ]; return { + statusKind: home.status, primaryCount: formatCount(home.primaryCount), primaryLabel, status, diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index 3e8f2795b8..d779c7e6a7 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -100,6 +100,29 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React ]; const family = widgetEnvironment.widgetFamily; + // A placed widget can carry props from an older app. This compatibility path + // only displays its existing values; freshness policy belongs to the shared builder. + const rawHome = props.home; + const home = + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + rawHome !== null && typeof rawHome === 'object' ? rawHome : undefined; + const homeStatus = safeText(home?.status); + // The circular accessory has no room for a count line when nothing is counting. + // A lone dash is near-invisible over a light wallpaper and says nothing, so the + // no-count state draws a state glyph instead — filled, so it keeps contrast. + const statusGlyphs = { + waiting: 'arrow.triangle.2.circlepath', + empty: 'checkmark.circle.fill', + privacy: 'lock.fill', + unavailable: 'lock.fill', + signed_out: 'person.fill', + } as const; + type HomeStatusGlyph = keyof typeof statusGlyphs; + const statusGlyph = + homeStatus !== null && Object.hasOwn(statusGlyphs, homeStatus) + ? statusGlyphs[homeStatus as HomeStatusGlyph] + : 'circle.fill'; + // Accessories deliberately ignore Home titles, feedback and retained work. const genericKind = kindOf(props.primaryKind); const genericCount = safeCount(props.primaryCount); @@ -109,16 +132,22 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React if (family === 'accessoryCircular') { return ( <VStack alignment="center" spacing={0} modifiers={[bodyURL, ...genericA11y]}> - {genericKind === null ? null : <Image systemName={glyphs[genericKind].icon} size={15} />} - <Text - modifiers={[ - font({ textStyle: 'title2', weight: 'bold' }), - monospacedDigit(), - lineLimit(1), - ]} - > - {genericKind === null ? '—' : count(genericCount)} - </Text> + {genericKind === null ? ( + <Image systemName={statusGlyph} size={24} /> + ) : ( + <Image systemName={glyphs[genericKind].icon} size={15} /> + )} + {genericKind === null ? null : ( + <Text + modifiers={[ + font({ textStyle: 'title2', weight: 'bold' }), + monospacedDigit(), + lineLimit(1), + ]} + > + {count(genericCount)} + </Text> + )} </VStack> ); } @@ -172,12 +201,6 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React ); } - // A placed widget can carry props from an older app. This compatibility path - // only displays its existing values; freshness policy belongs to the shared builder. - const rawHome = props.home; - const home = - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - rawHome !== null && typeof rawHome === 'object' ? rawHome : undefined; const primaryKind = home === undefined ? genericKind : kindOf(home.primaryKind); const primaryCount = home === undefined ? genericCount : safeCount(home.primaryCount); const status = home?.status ?? (genericKind === null ? 'signed_out' : 'content'); @@ -226,6 +249,19 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React const large = family === 'systemLarge'; const wide = family === 'systemMedium'; const square = !large && !wide; + const waitingAgents = (Array.isArray(home?.waitingAgents) ? home.waitingAgents : []) + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + .filter(row => row !== null && typeof row === 'object') + .slice(0, 3); + const scheduledAgents = (Array.isArray(home?.scheduledAgents) ? home.scheduledAgents : []) + // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders + .filter(row => row !== null && typeof row === 'object') + .slice(0, 3); + const detailSource = waitingAgents.length > 0 ? waitingAgents : scheduledAgents; + const detailRows = content ? detailSource : []; + // Medium prints up to two entry rows in its right column; large prints up to + // three below the count band. Both draw the same title + wait-kind row. + const mediumRows = wide ? detailRows.slice(0, 2) : []; const WidgetButton = Button as (props: WidgetButtonProps) => React.JSX.Element; const foregroundIntent = { openAppWhenRun: true }; const modifiers = [ @@ -303,7 +339,9 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React } if (title !== null && content) { return ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]}> + <Text + modifiers={[font({ textStyle: 'caption' }), lineLimit(square ? 2 : 1), mutedForeground]} + > {title} </Text> ); @@ -331,12 +369,15 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React <VStack alignment="leading" spacing={0} - modifiers={[frame({ height: large ? 20 : 26, alignment: 'leading' })]} + modifiers={[frame({ minHeight: large ? 20 : 26, maxWidth: 10_000, alignment: 'leading' })]} > {supportLine()} </VStack> ); const heroDigits = String(primaryCount).length; + // The rounded digit glyph is about 0.65em wide, so this keeps the widest count + // inside the 122pt hero column of the width-limited families. + const heroCap = Math.max(24, Math.floor(120 / (0.65 * heroDigits))); let heroSize = 40; if (heroDigits > 9) { heroSize = 16; @@ -349,6 +390,63 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React } else if (square) { heroSize = 26; } + // A large card owes its body a whole composition rather than a full top block and + // a hole above the footer. It prints four blocks — the header, one hero block (the + // count, its label and the support line), the entry rows and the footer — with a + // spacer of the same height between each. Two flexible spacers split their + // remainder by their own interior rules, which is what left ~22pt more above the + // footer than below the header, so the height is arithmetic here instead. + const entryCount = large ? detailRows.length : 0; + // Three entry rows share one band and step up a smaller slot so the count keeps + // its size; a lone row has no pitch to read and can afford the room. + let entryHeight = 40; + if (large && entryCount === 1) { + entryHeight = 44; + } else if (large && entryCount === 3) { + entryHeight = 34; + } + // A scheduled agent with no usable wake time draws a single line where a waiting + // agent draws the title and its wait, so those rows must be counted shorter or + // their frames pad the block into gaps. + const shortRows = large + ? detailRows.filter(row => !('scheduledAt' in row) && !('kind' in row)).length + : 0; + const rowsHeight = + entryCount === 0 + ? 0 + : (entryCount - shortRows) * entryHeight + shortRows * 18 + (entryCount - 1) * 10; + const rowMinHeight = (row: (typeof detailRows)[number]) => + 'scheduledAt' in row || 'kind' in row ? entryHeight : 18; + // Only a stale card's footer is two lines: "Last known" over "Checked". + // A card with no checked time still anchors its bottom — the waiting card prints + // the surface's own affordance there — while a locked card keeps its copy in the + // hero alone, because repeating "Open agents" under it read as a duplicate. + const lockedState = status === 'privacy' || status === 'unavailable' || status === 'signed_out'; + const footerNote = checkedAt === null && !lockedState ? (COPY.openAgents ?? 'Open agents') : null; + let footerHeight = stale ? 28 : 24; + if (checkedAt === null) { + footerHeight = footerNote === null ? 0 : 24; + } + + // With no entry rows there are two interior gaps, not three, so they take the + // thirds the rows would have split. + const gapCount = entryCount === 0 ? 2 : 3; + const gapBase = 14; + // What is left of the large card's ~328pt body once the fixed parts are placed: + // the 28pt header, the label's single 21pt line, the 20pt support slot, the rows, + // the footer and the gaps. The count's 1.2em line box fills the rest. + const countBudget = 328 - 28 - 21 - 20 - rowsHeight - footerHeight - gapCount * gapBase; + if (large) { + heroSize = Math.max(18, Math.min(heroCap, Math.floor(countBudget / 1.2))); + } + const secondaryHeight = + secondaryRows.length === 0 ? 0 : secondaryRows.length * 15 + (secondaryRows.length - 1) * 7; + // Equal gaps; a count capped for a wide number hands its leftover to them rather + // than leaving one hole. + const largeGap = large + ? gapBase + + Math.floor(Math.max(0, countBudget - Math.max(heroSize * 1.2, secondaryHeight)) / gapCount) + : gapBase; let heroMinHeight = 61; if (large) { heroMinHeight = 80; @@ -361,6 +459,32 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React } else if (status === 'empty') { heroValue = '0'; } + const heroNumber = ( + <Text + modifiers={[ + font({ size: heroSize, weight: 'bold', design: 'rounded' }), + monospacedDigit(), + lineLimit(1), + primaryForeground, + accessibilityLabel(content ? `${count(primaryCount)} ${primaryLabel}` : statusLabel), + ]} + > + {heroValue} + </Text> + ); + // The large card prints the label across the full card width, so it is always one + // known 21pt line and the count above it can be sized to fill the body exactly. + const heroLabel = ( + <Text + modifiers={[ + font({ textStyle: large ? 'headline' : 'caption', weight: 'semibold' }), + lineLimit(square || large ? 1 : 2), + primaryForeground, + ]} + > + {content ? primaryLabel : statusLabel} + </Text> + ); const hero = ( <VStack alignment="leading" @@ -373,26 +497,8 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React }), ]} > - <Text - modifiers={[ - font({ size: heroSize, weight: 'bold', design: 'rounded' }), - monospacedDigit(), - lineLimit(1), - primaryForeground, - accessibilityLabel(content ? `${count(primaryCount)} ${primaryLabel}` : statusLabel), - ]} - > - {heroValue} - </Text> - <Text - modifiers={[ - font({ textStyle: large ? 'headline' : 'caption', weight: 'semibold' }), - lineLimit(square ? 1 : 2), - primaryForeground, - ]} - > - {content ? primaryLabel : statusLabel} - </Text> + {heroNumber} + {heroLabel} </VStack> ); const secondary = ( @@ -427,167 +533,215 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React ))} </VStack> ); - const waitingAgents = (Array.isArray(home?.waitingAgents) ? home.waitingAgents : []) - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - .filter(row => row !== null && typeof row === 'object') - .slice(0, 3); - const scheduledAgents = (Array.isArray(home?.scheduledAgents) ? home.scheduledAgents : []) - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - .filter(row => row !== null && typeof row === 'object') - .slice(0, 3); - const detailSource = waitingAgents.length > 0 ? waitingAgents : scheduledAgents; - const detailRows = content ? detailSource : []; - return ( - <VStack alignment="leading" spacing={large ? 8 : 2} modifiers={modifiers}> - <HStack alignment="center" spacing={5} modifiers={[frame({ height: large ? 28 : 24 })]}> - {logoUri.length === 0 ? null : ( - <Image - uiImage={logoUri} - modifiers={[resizable(), frame({ width: 18, height: 18 }), cornerRadius(4)]} - /> - )} - <Text - modifiers={[ - font({ textStyle: 'caption', weight: 'bold' }), - lineLimit(1), - primaryForeground, - ]} - > - Kilo + // One entry row: the agent's title and why it waits, or when it wakes. The + // large card prints up to three below the count band; the medium card prints + // up to two in its right column, so a waiting agent is named without the + // large card's height. + const agentRow = (row: (typeof detailRows)[number], index: number, minHeight: number) => { + const date = 'scheduledAt' in row ? validDate(row.scheduledAt) : null; + let detailLine: React.JSX.Element | null = null; + if ('scheduledAt' in row) { + detailLine = + date === null || date.getTime() <= today.getTime() ? ( + <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> + {COPY.awaitingUpdate ?? 'Awaiting update'} + </Text> + ) : ( + wakeTime(date) + ); + } else if ('kind' in row) { + let waitLine = COPY.waitingToRetry ?? 'Waiting to retry'; + if (row.kind === 'permission') { + waitLine = COPY.permissionRequired ?? 'Permission required'; + } else if (row.kind === 'question') { + waitLine = COPY.answerNeeded ?? 'Answer needed'; + } + detailLine = ( + <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> + {waitLine} </Text> - <Spacer /> - <HStack spacing={4} modifiers={[frame({ width: 24, height: 24 })]}> - {canApprove ? ( - <WidgetButton - modifiers={[ - buttonStyle('plain'), - controlSize('small'), - accessibilityLabel(COPY.approve ?? 'Approve'), - ]} - onPress={() => ({ pendingAction: 'approve', pendingApprovalKey: approvalKey })} - > - <Image systemName="checkmark.circle" size={21} color={PlatformColor('label')} /> - </WidgetButton> - ) : null} + ); + } + return ( + <VStack + key={index} + alignment="leading" + spacing={3} + modifiers={[frame({ minHeight, maxWidth: 10_000, alignment: 'leading' })]} + > + <HStack spacing={6}> + <Image systemName={'scheduledAt' in row ? 'clock' : 'exclamationmark.circle'} size={12} /> + <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), primaryForeground]}> + {filledText(row.title) ?? filledText(COPY.agent) ?? 'Agent'} + </Text> </HStack> - <HStack spacing={0} modifiers={[frame({ width: 24, height: 24 })]}> - {canCreate ? ( - <WidgetButton - {...foregroundIntent} + {detailLine} + </VStack> + ); + }; + // The medium's right column: the waiting/scheduled rows when there are any, + // otherwise the support counts and the support line. The layout cannot use a + // fragment (only widget globals are in scope), so the fallback is two children. + const wideRows = + wide && mediumRows.length > 0 ? mediumRows.map((row, index) => agentRow(row, index, 0)) : null; + const wideFallback = wide && mediumRows.length === 0; + const headerRow = ( + <HStack alignment="center" spacing={5} modifiers={[frame({ height: large ? 28 : 24 })]}> + {logoUri.length === 0 ? null : ( + <Image + uiImage={logoUri} + modifiers={[resizable(), frame({ width: 18, height: 18 }), cornerRadius(4)]} + /> + )} + <Text + modifiers={[ + font({ textStyle: 'caption', weight: 'bold' }), + lineLimit(1), + primaryForeground, + ]} + > + Kilo + </Text> + <Spacer /> + <HStack spacing={4} modifiers={[frame({ width: 24, height: 24 })]}> + {canApprove ? ( + <WidgetButton + modifiers={[ + buttonStyle('plain'), + controlSize('small'), + accessibilityLabel(COPY.approve ?? 'Approve'), + ]} + onPress={() => ({ pendingAction: 'approve', pendingApprovalKey: approvalKey })} + > + <Image systemName="checkmark.circle" size={21} color={PlatformColor('label')} /> + </WidgetButton> + ) : null} + </HStack> + <HStack spacing={0} modifiers={[frame({ width: 24, height: 24 })]}> + {canCreate ? ( + <WidgetButton + {...foregroundIntent} + modifiers={[ + buttonStyle('plain'), + controlSize('small'), + accessibilityLabel(COPY.newAgent ?? 'New agent'), + ]} + onPress={() => ({ pendingAction: 'new-agent' })} + > + <Image systemName="plus.circle.fill" size={23} color={PlatformColor('label')} /> + </WidgetButton> + ) : null} + </HStack> + </HStack> + ); + // The footer carries the freshness line, or — before the first snapshot, when + // there is no time to print — the surface's own affordance, so the large card is + // anchored at both ends instead of trailing off into a blank band. + const footerNoteRow = + footerNote === null ? null : ( + <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> + {footerNote} + </Text> + ); + const footerChecked = + checkedAt === null ? null : ( + <VStack alignment="leading" spacing={0}> + {stale ? ( + <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> + {COPY.lastKnown ?? 'Last known'} + </Text> + ) : null} + <HStack spacing={4}> + <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> + {COPY.checked ?? 'Checked'} + </Text> + {square ? ( + // A square card has no room for a relative phrase, and "Checked 3 + // hours…" ellipsised. The clock time fits and matches Android. + wakeTime(checkedAt) + ) : ( + <Text + date={checkedAt} + dateStyle="ago" modifiers={[ - buttonStyle('plain'), - controlSize('small'), - accessibilityLabel(COPY.newAgent ?? 'New agent'), + font({ textStyle: 'caption2' }), + lineLimit(1), + monospacedDigit(), + mutedForeground, ]} - onPress={() => ({ pendingAction: 'new-agent' })} - > - <Image systemName="plus.circle.fill" size={23} color={PlatformColor('label')} /> - </WidgetButton> - ) : null} + /> + )} </HStack> - </HStack> - {wide || large ? ( - <HStack alignment="top" spacing={16}> - {hero} + </VStack> + ); + const footerContent = checkedAt === null ? footerNoteRow : footerChecked; + const footerRow = ( + <VStack + alignment="leading" + spacing={0} + modifiers={[frame({ height: footerHeight, alignment: 'topLeading' })]} + > + {footerContent} + </VStack> + ); + if (large) { + return ( + <VStack alignment="leading" spacing={0} modifiers={modifiers}> + {headerRow} + <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> + <HStack alignment="center" spacing={16}> + {heroNumber} <VStack alignment="leading" - spacing={2} + spacing={7} modifiers={[frame({ maxWidth: 10_000, alignment: 'leading' })]} > {secondary} - {wide && primaryKind === 'scheduled' && title !== null ? ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {title} - </Text> - ) : null} - {wide ? supportSlot : null} + </VStack> + </HStack> + {heroLabel} + {supportSlot} + {entryCount === 0 ? null : ( + <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> + )} + <VStack alignment="leading" spacing={10}> + {detailRows.map((row, index) => agentRow(row, index, rowMinHeight(row)))} + </VStack> + <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> + {footerRow} + </VStack> + ); + } + return ( + <VStack alignment="leading" spacing={2} modifiers={modifiers}> + {headerRow} + {wide ? ( + <HStack alignment="center" spacing={16}> + {hero} + <VStack + alignment="leading" + spacing={mediumRows.length > 0 ? 7 : 2} + modifiers={[frame({ maxWidth: 10_000, alignment: 'leading' })]} + > + {wideRows} + {wideFallback ? secondary : null} + {wideFallback ? supportSlot : null} </VStack> </HStack> ) : ( - <VStack alignment="leading" spacing={0}> + // A square card has one column: the hero and its line centre in the body + // so the slack splits above and below instead of pooling in one hole. + <VStack + alignment="leading" + spacing={0} + modifiers={[frame({ maxWidth: 10_000, maxHeight: 10_000, alignment: 'leading' })]} + > {hero} {supportSlot} </VStack> )} - {large ? supportSlot : null} - {large ? ( - <VStack alignment="leading" spacing={7}> - {detailRows.map((row, index) => { - const date = 'scheduledAt' in row ? validDate(row.scheduledAt) : null; - let detailLine: React.JSX.Element | null = null; - if ('scheduledAt' in row) { - detailLine = - date === null || date.getTime() <= today.getTime() ? ( - <Text - modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]} - > - {COPY.awaitingUpdate ?? 'Awaiting update'} - </Text> - ) : ( - wakeTime(date) - ); - } else if ('kind' in row) { - let waitLine = COPY.waitingToRetry ?? 'Waiting to retry'; - if (row.kind === 'permission') { - waitLine = COPY.permissionRequired ?? 'Permission required'; - } else if (row.kind === 'question') { - waitLine = COPY.answerNeeded ?? 'Answer needed'; - } - detailLine = ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {waitLine} - </Text> - ); - } - return ( - <VStack - key={index} - alignment="leading" - spacing={3} - modifiers={[frame({ minHeight: 25, maxWidth: 10_000, alignment: 'leading' })]} - > - <HStack spacing={6}> - <Image - systemName={'scheduledAt' in row ? 'clock' : 'exclamationmark.circle'} - size={12} - /> - <Text - modifiers={[font({ textStyle: 'caption' }), lineLimit(1), primaryForeground]} - > - {filledText(row.title) ?? filledText(COPY.agent) ?? 'Agent'} - </Text> - </HStack> - {detailLine} - </VStack> - ); - })} - </VStack> - ) : null} - <Spacer minLength={0} /> - <VStack alignment="leading" spacing={0} modifiers={[frame({ height: 24 })]}> - {checkedAt === null ? null : ( - <VStack alignment="leading" spacing={0}> - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {stale ? (COPY.lastKnown ?? 'Last known') : ''} - </Text> - <HStack spacing={4}> - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {COPY.checked ?? 'Checked'} - </Text> - <Text - date={checkedAt} - dateStyle="ago" - modifiers={[ - font({ textStyle: 'caption2' }), - lineLimit(1), - monospacedDigit(), - mutedForeground, - ]} - /> - </HStack> - </VStack> - )} - </VStack> + {wide ? <Spacer minLength={0} /> : null} + {footerRow} </VStack> ); }; diff --git a/apps/mobile/src/glanceable-ios/widget-actions.test.ts b/apps/mobile/src/glanceable-ios/widget-actions.test.ts index 5b6137e1c0..bbb2501485 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.test.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.test.ts @@ -745,6 +745,54 @@ describe('activeAgentsWidgetLayout', () => { } }); + it('draws a state glyph, never a placeholder dash, in the count-less circular', () => { + const expected = { + waiting: 'arrow.triangle.2.circlepath', + empty: 'checkmark.circle.fill', + privacy: 'lock.fill', + signed_out: 'person.fill', + } as const; + for (const status of Object.keys(expected) as (keyof typeof expected)[]) { + const snapshot = { ...snapshotFor([]), status }; + const home = buildHomeWidgetPresentation( + { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }, + NOW + ); + const tree = renderWidget({ home }, 'accessoryCircular'); + const images = collectOfKind(tree, 'Image').map(image => image.props.systemName); + expect(images).toEqual([expected[status]]); + expect(collectText(tree)).not.toContain('—'); + } + }); + + it('names up to two waiting agents in the medium column and falls back to counts', () => { + const waiting = renderWidget( + homeProps([{ status: 'permission' }], { + approvalKey: null, + primaryTitle: 'First wait', + waitingAgents: [ + { title: 'First wait', kind: 'permission' }, + { title: 'Second wait', kind: 'question' }, + { title: 'Third wait', kind: 'retry' }, + ], + scheduledAgents: [], + }), + 'systemMedium' + ); + const texts = collectText(waiting); + expect(texts).toContain('First wait'); + expect(texts).toContain('Second wait'); + expect(texts).not.toContain('Third wait'); + expect(texts).toContain('Permission required'); + // No entry rows: the column falls back to the support counts. + const counts = renderWidget( + homeProps([{ status: 'busy' }, { status: 'idle' }]), + 'systemMedium' + ); + expect(collectText(counts)).toContain('Working'); + expect(collectText(counts)).toContain('Idle'); + }); + it.each(HOME_FAMILIES)( 'keeps New agent navigation available alongside approval in %s', family => { @@ -803,8 +851,10 @@ describe('activeAgentsWidgetLayout', () => { expect(texts).not.toContain('Needs input'); expect(texts).not.toContain('Scheduled'); expect(texts).toContain('Checked'); + // The square card has no room for a relative phrase, so it prints the + // clock time; the wide and large cards keep the relative form. const footer = collectOfKind(renderWidget(props, family), 'Text').find( - text => text.props.dateStyle === 'ago' + text => text.props.dateStyle === (family === 'systemSmall' ? 'time' : 'ago') ); expect(footer?.props.date).toEqual(new Date(NOW)); } From 6e51e642d40889b8ade61fcb3dd29e39cb35ed70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Fri, 9 Oct 2026 18:43:31 +0200 Subject: [PATCH 15/31] fix(mobile): keep the fixture approval key hex and stop the card overflowing at its boundary --- .../active-agents-widget-card.tsx | 29 ++++++++++--------- .../active-agents-widget.test.ts | 18 ++++++++++++ .../src/lib/glanceable/fixture-harness.ts | 4 +-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx index 3de9a0f891..391fe985bc 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx @@ -144,9 +144,14 @@ export function card({ props, copy, paint, info }: Frame) { firstEntry !== undefined && firstEntry.title !== '' && copy.detail.includes(firstEntry.title); - const supportRows = wide ? 0 : Math.min(3, copy.secondaryCounts.length); // Wide cards show the support counts beside the hero, so the hero must clear them. const besideCounts = wide ? Math.min(3, copy.secondaryCounts.length) * 26 : 0; + const heroFloor = Math.max(32, besideCounts); + // Keep as many stacked counts as the card can hold without squeezing the hero out. + let supportRows = wide ? 0 : Math.min(3, copy.secondaryCounts.length); + while (supportRows > 0 && inner - ACTION_TARGET - FOOTER_HEIGHT - supportRows * 26 < heroFloor) { + supportRows -= 1; + } const plan = (suppressDetail: boolean) => { const body = inner - @@ -170,14 +175,12 @@ export function card({ props, copy, paint, info }: Frame) { const { rowHeight } = planned; // Never reserve height for rows the copy does not carry. const slots = Math.min(planned.slots, rows.length); - // With no rows the hero may take the room the entries would have used. - const free = - inner - - ACTION_TARGET - - supportRows * 26 - - FOOTER_HEIGHT - - (suppressed || !hasDetail ? 0 : DETAIL_HEIGHT); - const heroHeight = slots === 0 ? Math.min(free, 140) : planned.heroHeight; + // A short card cannot hold a hero, a detail line, the footer and the counts together: drop the detail first. + const bodyRoom = inner - ACTION_TARGET - supportRows * 26 - FOOTER_HEIGHT; + const showDetail = !(suppressed || !hasDetail) && bodyRoom - DETAIL_HEIGHT >= heroFloor; + // The hero keeps its floor and clears the counts beside it. + const free = bodyRoom - (showDetail ? DETAIL_HEIGHT : 0); + const heroHeight = slots === 0 ? Math.max(heroFloor, Math.min(free, 140)) : planned.heroHeight; const variant = heroVariantFor(heroHeight); // A 172dp-wide card can only fit the longest agent title at 11dp. const detailSize = info.width >= 280 ? 12 : 11; @@ -217,13 +220,13 @@ export function card({ props, copy, paint, info }: Frame) { paint.rtl )} </FlexWidget> - {suppressed || !hasDetail - ? null - : rowDetail(copy, paint, { + {showDetail + ? rowDetail(copy, paint, { height: DETAIL_HEIGHT, size: detailSize, available: avail, - })} + }) + : null} {wide ? null : ( <FlexWidget style={{ width: 'match_parent', height: supportRows * 26 }}> {copy.secondaryCounts.slice(0, supportRows).map(line => countRow(line, paint))} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index d48ad1f483..66c9ef859a 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -96,6 +96,24 @@ describe('Home widget native compositions', () => { } }); + it('never overflows at the card boundary with counts stacked and a detail line', () => { + // 180dp is where the card layout starts: hero floor, footer, three counts and a detail + // line do not all fit, so the layout must shed the detail and a count row instead. + for (const [width, height] of [ + [172, 180], + [266, 180], + [360, 180], + ]) { + const props = propsFor([...MIXED]); + const theme = render(props, [width, height]).light; + expect(minimumHeight(theme), `${width}x${height}`).toBeLessThanOrEqual(height); + const collapsed = nodes(theme).filter( + node => typeof node.props.style?.height === 'number' && node.props.style.height <= 0 + ); + expect(collapsed, `${width}x${height}`).toEqual([]); + } + }); + it.each(CELLS)( 'budgets German and Arabic/RTL without shrinking the count at %dx%d', (width, height) => { diff --git a/apps/mobile/src/lib/glanceable/fixture-harness.ts b/apps/mobile/src/lib/glanceable/fixture-harness.ts index c9f125b799..8691e469c4 100644 --- a/apps/mobile/src/lib/glanceable/fixture-harness.ts +++ b/apps/mobile/src/lib/glanceable/fixture-harness.ts @@ -48,8 +48,8 @@ import { recordWaitingAsk, selectWaitingAsk } from './waiting-ask'; const FIXTURE_PATH = /(?:^|\/)dev\/glanceable-fixture\/([\w-]+)\/?(?:[?#].*)?$/; const RELEASE = 'release'; -/** Shape-valid, dev-only approval key: it matches no live permission. */ -const FIXTURE_APPROVAL_KEY = 'devfixture'.padEnd(64, '0'); +/** Shape-valid hex approval key (`homeWidgetDetailsSchema`); it matches no live permission. */ +const FIXTURE_APPROVAL_KEY = 'a'.repeat(64); /** The signed-in scope the publisher mount runs under, or null while none. */ let scope: GlanceablePublisherContext | null = null; From 19773e2c9193aa1de836169d3e70a37cf09b7a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Fri, 9 Oct 2026 20:17:22 +0200 Subject: [PATCH 16/31] test(mobile): type the card boundary sizes as tuples --- apps/mobile/src/glanceable-android/active-agents-widget.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index 66c9ef859a..cb837e16e2 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -103,7 +103,7 @@ describe('Home widget native compositions', () => { [172, 180], [266, 180], [360, 180], - ]) { + ] as const) { const props = propsFor([...MIXED]); const theme = render(props, [width, height]).light; expect(minimumHeight(theme), `${width}x${height}`).toBeLessThanOrEqual(height); From 5d741c5fdb6d54dfc4da1c75b5801b0e9ac5872c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Fri, 9 Oct 2026 20:56:34 +0200 Subject: [PATCH 17/31] fix(mobile): keep a widget press alive until the app consumes it --- .../ios/HomeWidgetRefreshStore.swift | 63 +++++++++++++-- .../glanceable-ios/active-agents-widget.tsx | 20 ++++- apps/mobile/src/glanceable-ios/view-props.ts | 8 ++ .../src/glanceable-ios/widget-actions.test.ts | 76 ++++++++++++++++++- .../src/glanceable-ios/widget-actions.ts | 69 ++++++++++++++--- 5 files changed, 216 insertions(+), 20 deletions(-) diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift index a08200f68f..fd25c50cbe 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -102,7 +102,7 @@ enum HomeWidgetRefreshStore { static func clearLocked(_ defaults: UserDefaults) { SecItemDelete(keyQuery as CFDictionary) defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") - for key in ["homeWidgetData", "homeWidgetPresentation", "homeWidgetRefreshAt", "homeWidgetRefreshDelay", "homeWidgetTerminalFence", "__expo_widgets_\(widgetName)_timeline"] { + for key in ["homeWidgetData", "homeWidgetPresentation", "homeWidgetRefreshAt", "homeWidgetRefreshDelay", "homeWidgetTerminalFence", timelineKey] { defaults.removeObject(forKey: key) } defaults.synchronize() @@ -153,6 +153,41 @@ enum HomeWidgetRefreshStore { return request } + /// How long a pressed marker may be carried forward across timeline rebuilds. + /// Long enough for a cold launch's JavaScript to boot and read it, short + /// enough that a press from a previous session can never fire. The app + /// mirrors it as `PENDING_ACTION_TTL_MS`. + static let pendingActionTTL: Double = 5 * 60 * 1000 + static var timelineKey: String { "__expo_widgets_\(widgetName)_timeline" } + + /// The press marker the stored timeline still carries, if any. + /// + /// Only a real press can put one there: the App Intent merges the button's + /// press patch — `pendingAction`/`pendingApprovalKey` and the `pendingActionAt` + /// it was pressed at — into the pressed entry's props. This reads the stored + /// timeline, never the server presentation, so a refresh can never manufacture + /// a press. A marker carrying no press time (an older patch) falls back to now. + static func storedPress(_ entries: [[String: Any]]?) -> [String: Any]? { + for entry in entries ?? [] { + guard let props = entry["props"] as? [String: Any], + let action = props["pendingAction"] as? String, + action == "approve" || action == "new-agent" else { continue } + var press: [String: Any] = ["action": action] + if let approvalKey = props["pendingApprovalKey"] as? String { press["approvalKey"] = approvalKey } + press["at"] = (props["pendingActionAt"] as? Double) ?? Date().timeIntervalSince1970 * 1000 + return press + } + return nil + } + + /// The marker to re-attach to the rebuilt timeline's first entry, or nil when + /// nothing waits or the recorded press is older than the TTL. + static func carriedPress(_ entries: [[String: Any]]?) -> [String: Any]? { + guard let press = storedPress(entries), let at = press["at"] as? Double, + Date().timeIntervalSince1970 * 1000 - at <= pendingActionTTL else { return nil } + return press + } + static func refresh() async { let captured = locked { defaults -> ([String: Any], String)? in guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), @@ -173,7 +208,7 @@ enum HomeWidgetRefreshStore { let copy = config["copy"] as? [String: String] ?? [:] defaults.set([["timestamp": Int(Date().timeIntervalSince1970 * 1000), "props": ["statusLine": copy["privacy"] ?? "", "countLines": [], "primaryCount": 0, - "actions": ["approve": false, "newAgent": false]]]], forKey: "__expo_widgets_\(widgetName)_timeline") + "actions": ["approve": false, "newAgent": false]]]], forKey: timelineKey) defaults.synchronize() } } @@ -204,9 +239,15 @@ enum HomeWidgetRefreshStore { phases.append(["at": expires, "home": retained]) phases.sort { ($0["at"] as? Double ?? 0) < ($1["at"] as? Double ?? 0) } } - let entries = phases.compactMap { phase -> [String: Any]? in + // Read the marker before the rebuild: the entries below are built from + // the server presentation and the stored base props, and the marker is + // re-attached from this carried press alone while it is still fresh. + let stored = defaults.array(forKey: timelineKey) as? [[String: Any]] + let carried = carriedPress(stored) + let baseProps = (stored?.first?["props"] as? [String: Any]) ?? [:] + var entries = phases.compactMap { phase -> [String: Any]? in guard let at = phase["at"] as? Double, let presentation = phase["home"] as? [String: Any] else { return nil } - var props = (defaults.array(forKey: "__expo_widgets_\(widgetName)_timeline")?.first as? [String: Any])?["props"] as? [String: Any] ?? [:] + var props = baseProps props["home"] = presentation var counts = presentation["secondaryCounts"] as? [[String: Any]] ?? [] if let kind = presentation["primaryKind"] as? String, let count = presentation["primaryCount"] as? Int, count > 0 { @@ -235,11 +276,23 @@ enum HomeWidgetRefreshStore { props["newestTitle"] = nil props["actionLine"] = nil props["accessibilityLabel"] = counts.map { "\($0["count"] ?? 0) \(copy[$0["kind"] as? String ?? ""] ?? "")" }.joined(separator: ", ") + // The rebuild owns the marker: strip every copy so only the carried + // press below can put one back on the first entry while it is fresh. props.removeValue(forKey: "pendingAction") + props.removeValue(forKey: "pendingApprovalKey") + props.removeValue(forKey: "pendingActionAt") // JSON nulls aren't valid property-list values; expo-widgets omits them too. return ["timestamp": Int(at), "props": propertyList(props)] } - if !entries.isEmpty { defaults.set(entries, forKey: "__expo_widgets_\(widgetName)_timeline") } + if !entries.isEmpty { + if let carried, var props = entries[0]["props"] as? [String: Any] { + props["pendingAction"] = carried["action"] + if let approvalKey = carried["approvalKey"] { props["pendingApprovalKey"] = approvalKey } + props["pendingActionAt"] = carried["at"] + entries[0]["props"] = props + } + defaults.set(entries, forKey: timelineKey) + } defaults.synchronize() } } catch { diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index d779c7e6a7..0f4b38517e 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -27,7 +27,17 @@ import { withWidgetLogo } from './widget-logo'; /* eslint-disable new-cap -- PlatformColor is a React Native factory */ export type WidgetProps = GlanceableWidgetProps; -type WidgetPressPatch = { pendingAction: GlanceableWidgetAction; pendingApprovalKey?: string }; +type WidgetPressPatch = { + pendingAction: GlanceableWidgetAction; + pendingApprovalKey?: string; + /** + * When the press happened (ms since epoch). The extension carries the marker + * across timeline rebuilds only while it is younger than the TTL, so the + * press records its own time: a marker that outlives a rebuild keeps the time + * it was pressed with, and a fresh press always resets it. + */ + pendingActionAt: number; +}; type WidgetButtonProps = Omit<ButtonProps, 'onPress'> & { onPress?: () => WidgetPressPatch; }; @@ -611,7 +621,11 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React controlSize('small'), accessibilityLabel(COPY.approve ?? 'Approve'), ]} - onPress={() => ({ pendingAction: 'approve', pendingApprovalKey: approvalKey })} + onPress={() => ({ + pendingAction: 'approve', + pendingApprovalKey: approvalKey, + pendingActionAt: Date.now(), + })} > <Image systemName="checkmark.circle" size={21} color={PlatformColor('label')} /> </WidgetButton> @@ -626,7 +640,7 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React controlSize('small'), accessibilityLabel(COPY.newAgent ?? 'New agent'), ]} - onPress={() => ({ pendingAction: 'new-agent' })} + onPress={() => ({ pendingAction: 'new-agent', pendingActionAt: Date.now() })} > <Image systemName="plus.circle.fill" size={23} color={PlatformColor('label')} /> </WidgetButton> diff --git a/apps/mobile/src/glanceable-ios/view-props.ts b/apps/mobile/src/glanceable-ios/view-props.ts index c107c8bf87..01a0f37c7d 100644 --- a/apps/mobile/src/glanceable-ios/view-props.ts +++ b/apps/mobile/src/glanceable-ios/view-props.ts @@ -102,6 +102,14 @@ export type GlanceableWidgetProps = Partial<GlanceableViewProps> & { pendingAction?: GlanceableWidgetAction; /** The exact request the pressed Approve displayed. */ pendingApprovalKey?: string; + /** + * When the press happened (ms since epoch), recorded by the button's press + * patch and preserved by the extension across timeline rebuilds. A carried + * marker older than `PENDING_ACTION_TTL_MS` is dropped instead of run, so a + * press from an earlier session can never fire. Absent only for a marker no + * press patch wrote, which reads as fresh. + */ + pendingActionAt?: number; }; /** diff --git a/apps/mobile/src/glanceable-ios/widget-actions.test.ts b/apps/mobile/src/glanceable-ios/widget-actions.test.ts index bbb2501485..822762a9c0 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.test.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.test.ts @@ -20,6 +20,7 @@ import { type UserInteractionEvent, type WidgetFamily } from 'expo-widgets'; import { activeAgentsWidgetLayout, WIDGET_NAME } from './active-agents-widget'; import { + PENDING_ACTION_TTL_MS, pendingActionForEvent, pendingActionOf, registerWidgetActionHandling, @@ -235,6 +236,24 @@ describe('pendingActionOf', () => { expect(pendingActionOf(null)).toBeNull(); expect(pendingActionOf(undefined)).toBeNull(); }); + + it('keeps a carried marker inside the TTL and refuses one past it', () => { + // The first rebuild records the press; a marker read straight after the + // press carries no time yet and is never refused for age. + expect(pendingActionOf({ pendingAction: 'new-agent' }, NOW)).toBe('new-agent'); + expect( + pendingActionOf( + { pendingAction: 'approve', pendingActionAt: NOW - PENDING_ACTION_TTL_MS }, + NOW + ) + ).toBe('approve'); + expect( + pendingActionOf( + { pendingAction: 'approve', pendingActionAt: NOW - PENDING_ACTION_TTL_MS - 1 }, + NOW + ) + ).toBeNull(); + }); }); describe('pendingActionForEvent', () => { @@ -263,6 +282,19 @@ describe('pendingActionForEvent', () => { expect(pendingActionForEvent(event(), [{ props: { primaryCount: 1 } }])).toBeNull(); expect(pendingActionForEvent(event(), [])).toBeNull(); }); + + it('ignores a carried marker past its TTL', () => { + expect( + pendingActionForEvent(event(), [ + { + props: { + pendingAction: 'new-agent', + pendingActionAt: Date.now() - PENDING_ACTION_TTL_MS - 1, + }, + }, + ]) + ).toBeNull(); + }); }); // ── the sweep ─────────────────────────────────────────────────────────────── @@ -329,6 +361,41 @@ describe('runPendingWidgetActions', () => { expect(mocks.runWidgetApprove).toHaveBeenCalledTimes(1); }); + it('drops a carried press older than the TTL without running it', async () => { + widgetState.timeline = [ + { + date: new Date(1), + props: { + pendingAction: 'new-agent', + pendingActionAt: Date.now() - PENDING_ACTION_TTL_MS - 1, + }, + }, + ]; + + await runPendingWidgetActions(); + + // A press from a previous session is never answered late, and the stale + // marker is cleared so it cannot linger in the stored timeline. + expect(mocks.linkingOpenURL).not.toHaveBeenCalled(); + expect(widgetState.timeline).toEqual([{ date: new Date(1), props: {} }]); + }); + + it('runs a carried press inside the TTL and clears its recorded time', async () => { + widgetState.timeline = [ + { + date: new Date(1), + props: { pendingAction: 'new-agent', pendingActionAt: Date.now() - 1000 }, + }, + ]; + + await runPendingWidgetActions(); + + expect(mocks.linkingOpenURL).toHaveBeenCalledWith('kiloapp:///cloud/sessions/new'); + // Stripping the recorded time is the explicit clear: the extension finds no + // marker left to carry forward, so the press cannot run twice. + expect(widgetState.timeline).toEqual([{ date: new Date(1), props: {} }]); + }); + it('never runs the same press twice on a second sweep', async () => { widgetState.timeline = [{ date: new Date(1), props: { pendingAction: 'approve' } }]; mocks.runWidgetApprove.mockResolvedValue({ kind: 'approved' }); @@ -798,7 +865,14 @@ describe('activeAgentsWidgetLayout', () => { family => { const tree = renderWidget(homeProps([{ status: 'permission' }], APPROVABLE), family); const newAgent = pressButton(tree, { pendingAction: 'new-agent' }); - expect(newAgent?.props.openAppWhenRun).toBe(true); + if (newAgent === undefined) { + throw new Error(`New agent button missing in ${family}`); + } + expect(newAgent.props.openAppWhenRun).toBe(true); + // The press records its own time, so the extension carries the marker only + // while the press is younger than the TTL and a fresh press always resets it. + const patch = (newAgent.props.onPress as () => { pendingActionAt: number })(); + expect(Math.abs(patch.pendingActionAt - Date.now())).toBeLessThan(1000); const approve = pressButton(tree, { pendingAction: 'approve', pendingApprovalKey: APPROVAL_KEY, diff --git a/apps/mobile/src/glanceable-ios/widget-actions.ts b/apps/mobile/src/glanceable-ios/widget-actions.ts index 1fb74ea2d6..3a85345914 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.ts @@ -37,21 +37,57 @@ import { * Both paths funnel through one sweep that clears the marker from the timeline * before invoking the action, so a crash mid-action — or the foreground sweep * racing a live listener — can never run the same press twice. + * + * A press also reloads the widget, and the extension's rebuild replaces the + * timeline from the server presentation before a cold launch's JS may have + * booted. The extension therefore re-attaches the pressed marker to the rebuilt + * timeline while it is younger than `PENDING_ACTION_TTL_MS`, keeping the + * `pendingActionAt` the press recorded. The sweep strips that time along with + * the marker, which is what tells the extension to stop carrying the press. + */ + +/** + * How long a press marker stays live. + * + * A press triggers a widget reload, and the extension's rebuild replaces the + * timeline from the server presentation before a cold launch's JavaScript has + * booted. The extension re-attaches a pressed marker to the rebuilt timeline + * for this long (`HomeWidgetRefreshStore.pendingActionTTL`) so the launch sweep + * still finds it; past it the extension stops carrying the marker, and this + * read refuses one that somehow survived. Either way a press from an earlier + * session can never fire. */ +export const PENDING_ACTION_TTL_MS = 5 * 60 * 1000; -/** Read the press marker out of a timeline entry's props, if one is pending. */ +/** + * Read the press marker out of a timeline entry's props, if one is pending and + * still within its TTL. A marker with no recorded time was read before any + * rebuild carried it, so it is never refused for age; `null` reads as fresh so + * malformed stored data can never silently drop a real press. + */ export function pendingActionOf( - props: WidgetProps | null | undefined + props: WidgetProps | null | undefined, + now = Date.now() ): GlanceableWidgetAction | null { const pendingAction = props?.pendingAction; - return pendingAction === 'approve' || pendingAction === 'new-agent' ? pendingAction : null; + if (pendingAction !== 'approve' && pendingAction !== 'new-agent') { + return null; + } + const carriedAt = props?.pendingActionAt ?? now; + return now - carriedAt > PENDING_ACTION_TTL_MS ? null : pendingAction; } -/** The pressed entry's props without the marker, in the form the timeline stores. */ +/** + * The pressed entry's props without the marker — the action, the approval key + * and the time the extension recorded — in the form the timeline stores. This + * write is the explicit clear: once it lands, the extension finds no marker to + * carry forward and the press can never run twice. + */ function stripPendingAction(props: WidgetProps | null | undefined): WidgetProps { const { pendingAction: _pendingAction, pendingApprovalKey: _pendingApprovalKey, + pendingActionAt: _pendingActionAt, ...rest } = props ?? {}; return rest; @@ -277,20 +313,31 @@ function takeResweepRequest(): boolean { * read, which is dropped when this read still carries its marker so the same * press never runs twice. The marker is cleared before the action is invoked, so * a crash mid-action reads as a dropped press instead of a repeated one. + * + * A marker past its TTL is cleared by that same write but never run: the + * extension stops carrying a press after `PENDING_ACTION_TTL_MS`, and a marker + * that somehow outlived one must not be answered late. */ async function sweepPendingActions(): Promise<void> { const timeline = await ActiveAgentsWidget.getTimeline(); const pending = new Set<number>(); const pressed: PressedEntry[] = []; for (const [index, entry] of timeline.entries()) { - const action = pendingActionOf(entry.props); - if (action !== null) { + const rawAction = entry.props.pendingAction; + if (rawAction === 'approve' || rawAction === 'new-agent') { + // Every marker this read sees is cleared by the write below, a stale one + // included: the extension stops re-attaching a marker past its TTL, but + // one that outlived it must not sit in storage. Only a fresh marker runs + // — an old press is dropped, never answered late. pending.add(index); - pressed.push({ - date: entry.date.getTime(), - action, - approvalKey: entry.props.pendingApprovalKey ?? null, - }); + const action = pendingActionOf(entry.props); + if (action !== null) { + pressed.push({ + date: entry.date.getTime(), + action, + approvalKey: entry.props.pendingApprovalKey ?? null, + }); + } } } const carried = takeCarriedPresses().filter( From dc6d9693bb1bd13625b3cced503e1ce9c20907e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Fri, 9 Oct 2026 21:24:08 +0200 Subject: [PATCH 18/31] fix(mobile): keep the server-minted approval key through a tray derive --- .../src/lib/glanceable/publisher.test.ts | 69 ++++++++++++++++++- apps/mobile/src/lib/glanceable/publisher.ts | 20 +++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index fceb499ecd..219e9b5c7b 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- one cohesive publisher state-machine suite sharing the fake-sink harness */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildGlanceableSnapshot, @@ -11,6 +11,11 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { getTerminalBlankEpoch, writeSignedOutSnapshotAndEnd } from './cleanup'; +import { + _resetHomeWidgetDataForTests, + getHomeWidgetDataForSnapshot, + rememberHomeWidgetData, +} from './home-widget-data'; import { GLANCEABLE_RENEW_MARGIN_MS, GLANCEABLE_RENEW_RETRY_MAX_MS, @@ -963,3 +968,65 @@ describe('GlanceablePublisher waiting ask', () => { publisher.dispose(); }); }); + +describe('Home widget approval key retention', () => { + const KEY = 'b'.repeat(64); + + beforeEach(() => { + _resetHomeWidgetDataForTests(); + }); + + it('keeps a server-minted approval key while the visible ask is unchanged', () => { + const { sink, calls } = makeSink(); + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + rememberHomeWidgetData({ + snapshot: snapshotFor([{ status: 'permission' }], NOW - 1000), + details: { + primaryTitle: 'Migrate billing', + waitingAgents: [{ title: 'Migrate billing', kind: 'permission' }], + scheduledAgents: [], + approvalKey: KEY, + }, + }); + publisher.handleSessions([{ status: 'permission', title: 'Migrate billing' }], PUB_CTX); + const published = lastSnapshot(calls, 'startOrUpdate'); + expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBe(KEY); + publisher.dispose(); + }); + + it('drops the key when a different ask becomes the visible one', () => { + const { sink, calls } = makeSink(); + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + rememberHomeWidgetData({ + snapshot: snapshotFor([{ status: 'permission' }], NOW - 1000), + details: { + primaryTitle: 'Migrate billing', + waitingAgents: [{ title: 'Migrate billing', kind: 'permission' }], + scheduledAgents: [], + approvalKey: KEY, + }, + }); + publisher.handleSessions([{ status: 'permission', title: 'Rotate the signing key' }], PUB_CTX); + const published = lastSnapshot(calls, 'startOrUpdate'); + expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBeNull(); + publisher.dispose(); + }); + + it('drops the key once no permission is waiting', () => { + const { sink, calls } = makeSink(); + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + rememberHomeWidgetData({ + snapshot: snapshotFor([{ status: 'permission' }], NOW - 1000), + details: { + primaryTitle: 'Migrate billing', + waitingAgents: [{ title: 'Migrate billing', kind: 'permission' }], + scheduledAgents: [], + approvalKey: KEY, + }, + }); + publisher.handleSessions([{ status: 'busy', title: 'Migrate billing' }], PUB_CTX); + const published = lastSnapshot(calls, 'startOrUpdate'); + expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBeNull(); + publisher.dispose(); + }); +}); diff --git a/apps/mobile/src/lib/glanceable/publisher.ts b/apps/mobile/src/lib/glanceable/publisher.ts index 96d34464d5..e8865f75b2 100644 --- a/apps/mobile/src/lib/glanceable/publisher.ts +++ b/apps/mobile/src/lib/glanceable/publisher.ts @@ -16,7 +16,11 @@ import { } from '@kilocode/app-shared/home-widget'; import { isGlanceableFixtureHeld } from './fixture-hold'; -import { hasSameHomeWidgetDetails, setHomeWidgetDetails } from './home-widget-data'; +import { + getLastHomeWidgetData, + hasSameHomeWidgetDetails, + setHomeWidgetDetails, +} from './home-widget-data'; import { type NewestSessionRow, newestSessionTitle } from './newest-session'; import { hasSameGlanceableContent, withStatus } from './snapshot-transforms'; import { @@ -195,7 +199,19 @@ export class GlanceablePublisher { } const previousTitle = getSurfaceExtras().newestSessionTitle; const nextTitle = newestSessionTitle(sessions); - const nextHomeDetails = buildHomeWidgetDetails(sessions); + const derivedHomeDetails = buildHomeWidgetDetails(sessions); + // The approval key is minted server-side from the session and permission ids + // and cannot be recomputed here. Keep the key already held while the ask it + // was minted for is still the visible one; a press re-checks the digest, so a + // stale key can never approve a different request. + const heldHomeDetails = getLastHomeWidgetData()?.details ?? this.homeDetails; + const nextHomeDetails = + derivedHomeDetails.approvalKey === null && + heldHomeDetails !== null && + heldHomeDetails.approvalKey !== null && + hasSameHomeWidgetDetails(heldHomeDetails, derivedHomeDetails) + ? { ...derivedHomeDetails, approvalKey: heldHomeDetails.approvalKey } + : derivedHomeDetails; const sameHomeDetails = this.homeDetails !== null && hasSameHomeWidgetDetails(this.homeDetails, nextHomeDetails); this.homeDetails = nextHomeDetails; From a8b9a78d4990463f6086ca33728fff6c1129a5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Fri, 9 Oct 2026 23:06:51 +0200 Subject: [PATCH 19/31] test(mobile): pin the approval key identity rule --- .../src/lib/glanceable/publisher.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index 219e9b5c7b..b1c0174dfe 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -1012,6 +1012,28 @@ describe('Home widget approval key retention', () => { publisher.dispose(); }); + it('keeps the key when only the session behind the same visible ask changes', () => { + // The client sees titles and kinds, not session ids, so two waiting sessions + // with the same title are indistinguishable here. Keeping the key is + // deliberate: the press re-checks the digest, so a key that no longer matches + // the visible request answers nothing and reports the stale outcome. + const { sink, calls } = makeSink(); + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + rememberHomeWidgetData({ + snapshot: snapshotFor([{ status: 'permission' }], NOW - 1000), + details: { + primaryTitle: 'Migrate billing', + waitingAgents: [{ title: 'Migrate billing', kind: 'permission' }], + scheduledAgents: [], + approvalKey: KEY, + }, + }); + publisher.handleSessions([{ status: 'permission', title: 'Migrate billing' }], PUB_CTX); + const published = lastSnapshot(calls, 'startOrUpdate'); + expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBe(KEY); + publisher.dispose(); + }); + it('drops the key once no permission is waiting', () => { const { sink, calls } = makeSink(); const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); From a883a54ee2bb6c74115b025838853eb280e7ab4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:25:19 +0200 Subject: [PATCH 20/31] feat(notifications): carry the check time in the Live Activity content state --- packages/notifications/src/push-data.ts | 10 ++++++---- .../src/lib/glanceable-delivery.test.ts | 13 ++++++++++++- .../notifications/src/lib/glanceable-delivery.ts | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/notifications/src/push-data.ts b/packages/notifications/src/push-data.ts index b16a1734b5..b8edd9c101 100644 --- a/packages/notifications/src/push-data.ts +++ b/packages/notifications/src/push-data.ts @@ -146,10 +146,11 @@ export type PushData = z.infer<typeof pushDataSchema>; /** * The raw content-state the Active Agents Live Activity renders. The server - * pushes exactly this shape (counts + status + the safe needs-input wait - * timestamp) and the widget extension renders it directly with inlined English - * copy. It must never carry a title, session id, repository name, organization - * name, generated text, or a raw account id. + * pushes exactly this shape (counts + status + the safe timestamps: when the + * counts were confirmed, the needs-input wait and the next wake) and the widget + * extension renders it directly with baked copy. It must never carry a title, + * session id, repository name, organization name, generated text, or a raw + * account id. */ export type GlanceableLiveActivityContentState = Pick< Extract<PushData, { type: 'active_agents_glanceable' }>, @@ -158,6 +159,7 @@ export type GlanceableLiveActivityContentState = Pick< | 'needsInput' | 'needsApproval' | 'idle' + | 'updatedAt' | 'needsInputSince' | 'scheduled' | 'scheduledAt' diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 4434ac88fb..46ab1ba23f 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -663,6 +663,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { needsInput: 0, needsApproval: 0, idle: 0, + updatedAt: expect.any(String), needsInputSince: null, scheduled: 0, scheduledAt: null, @@ -1037,6 +1038,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { needsInput: 1, needsApproval: 0, idle: 0, + updatedAt: expect.any(String), needsInputSince: '2026-08-27T10:00:01.000Z', scheduled: 0, scheduledAt: null, @@ -1591,6 +1593,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { needsInput: 0, needsApproval: 0, idle: 0, + updatedAt: expect.any(String), needsInputSince: null, scheduled: 0, scheduledAt: null, @@ -1987,6 +1990,7 @@ describe('toGlanceableContentState', () => { needsInput: 1, needsApproval: 1, idle: 0, + updatedAt: snapshot.updatedAt, needsInputSince: '2026-08-27T09:00:00.000Z', scheduled: 0, scheduledAt: null, @@ -2025,6 +2029,14 @@ describe('toGlanceableContentState', () => { ).toBe(0); }); + it('carries the confirmed time the card prints as "Checked <time>"', () => { + // A timestamp, like the wait and the wake: the card's header prints when + // the counts were confirmed, and an older card without it prints no time. + expect( + (JSON.parse(toGlanceableContentState(snapshot).props) as Record<string, unknown>).updatedAt + ).toBe(snapshot.updatedAt); + }); + it('never leaks snapshot bookkeeping, ids, or titles into the pushed content-state', () => { const contentState = toGlanceableContentState(snapshot); const raw = JSON.stringify(contentState); @@ -2033,7 +2045,6 @@ describe('toGlanceableContentState', () => { expect(raw).not.toContain('scopeKey'); expect(raw).not.toContain('deadbeef'); expect(raw).not.toContain('organizationBound'); - expect(raw).not.toContain('updatedAt'); expect(raw).not.toContain('expiresAt'); expect(raw).not.toContain('accountEpoch'); expect(raw).not.toContain('title'); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index 5ab51958af..f6e2e2681a 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -94,6 +94,7 @@ export function toGlanceableContentState( needsInput: snapshot.needsInput, needsApproval: snapshot.needsApproval ?? 0, idle: snapshot.idle, + updatedAt: snapshot.updatedAt, needsInputSince: snapshot.needsInputSince, scheduled: snapshot.scheduled, scheduledAt: snapshot.scheduledAt, From 10f9182e92549af9e420696b66e1d6fb9872e6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:25:20 +0200 Subject: [PATCH 21/31] feat(mobile): rebuild iOS widgets and Live Activity to the approved design --- apps/mobile/assets/images/logo-widget.png | Bin 2766 -> 0 bytes .../HomeWidgetTimelineProvider.swift | 16 +- .../active-agents-live-activity-a11y.test.ts | 101 -- .../active-agents-live-activity.test.ts | 325 ++-- .../active-agents-live-activity.tsx | 803 +++++---- .../active-agents-widget.test.ts | 754 +++++++++ .../glanceable-ios/active-agents-widget.tsx | 1463 +++++++++++------ .../src/glanceable-ios/interaction.test.ts | 238 ++- apps/mobile/src/glanceable-ios/interaction.ts | 149 +- apps/mobile/src/glanceable-ios/ios-sink.ts | 16 +- .../src/glanceable-ios/layout-copy.test.ts | 133 +- apps/mobile/src/glanceable-ios/layout-copy.ts | 33 +- .../src/glanceable-ios/register.test.ts | 1 - apps/mobile/src/glanceable-ios/register.ts | 9 +- .../src/glanceable-ios/view-props.test.ts | 2 +- apps/mobile/src/glanceable-ios/view-props.ts | 74 +- .../src/glanceable-ios/widget-actions.test.ts | 378 +---- .../src/glanceable-ios/widget-logo.test.ts | 32 - apps/mobile/src/glanceable-ios/widget-logo.ts | 93 -- 19 files changed, 2581 insertions(+), 2039 deletions(-) delete mode 100644 apps/mobile/assets/images/logo-widget.png delete mode 100644 apps/mobile/src/glanceable-ios/active-agents-live-activity-a11y.test.ts create mode 100644 apps/mobile/src/glanceable-ios/active-agents-widget.test.ts delete mode 100644 apps/mobile/src/glanceable-ios/widget-logo.test.ts delete mode 100644 apps/mobile/src/glanceable-ios/widget-logo.ts diff --git a/apps/mobile/assets/images/logo-widget.png b/apps/mobile/assets/images/logo-widget.png deleted file mode 100644 index 8e72fff48d269ce0b4458617695e5a82eba21460..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2766 zcmV;<3NiJGP)<h;3K|Lk000e1NJLTq003+N003+V1^@s6k7ifR000V=Nkl<Zc-rk< z+iw-e8UKB=v&YA`7++dS%B2CNL`ornVv{N)P$6NZeTb4!XjCBTQyz*UReh@Z7vwRJ zmsafq234WWTh&scCXEsY2so4&l9H-~Bm_*c0Rz6AvpbhQ?Ac=*pL2Hhc+bW5eA2v} z%k1p@c4ofsH{bl0yvb)Ztj!x^whJs^)+}JwEMOKeYZfqT7BCB#H4B(E3z!AWngz_7 z1<V3w%>rgEj`5@_(i%`IdTWg_0+D&WQYKTUY5V5d-gyHU=LF_PS0_Db+;NNnZrY80 zScHTSrR#KE0`d5_t9%LHS73}524GqP%pr`9`nd4PO%Q-WT^D}7eq9?{S~+wCXvBcY zNriu%yaBCIuwEiSDd3SuTF}|q044RT(Ga%p#&8xFFOGq+;Ik+T>wfgnR&;$Mjm(6A z<IwyEOeu|2Bgf$R5&Up_f8_5!Jh2TuKj=VqLKJ;!mC|TPJGlDa2|T^+{m451^hP&+ zvU?RW<KC=J4ch18z4xwS*H6#HzQ6VB+wk{ePh#1!G&17?4yT3TbTCF>ESScKW|!%o zn;H?2O3|$UhlO*>U6%~oN8p?awy#ie9Kzt>9qj1s$Iws)>1J-Urp_BLQK?|MDOJju za!Zvsj)YP=vcBm>N)65l7cShL0<DQd3T@r`dNm=YfwrUeBh3O92eeOSf>utLU;(QI zXubXT@{0*LoRu}rTfnLaS}MhG;lf?K`s!!ksp?OSd@*(SbPq7f(JAG)Fh-Dao--YB zsHU(K>lme`e#2PcUK7pk$S~$I2^B)mPbA>sDWz*hS5+Obwl)`zlR{G~%kdutyBC0i z_9ZT|SslqUdY&p7e+f@u+tx0eKDiA=shHfyu1omYo&o&(;yo}%HE}HJ0<ds&{PI_W z=;&;K6beMe0dqLR_?W<Ne!UhOH?`yLs0Ys3jNT?9D6R3t<_<_-77U<?0If7SI#TFb znl@V2TUz+SXX{Pv_**w({r<E6jN#>%S7XJhHUNI%Q7I*SPnDdegwW8wh<vD04Xo}) zRZRg4*CQiRnE@9s-oy7dy^XhyZ^7dmmf+r<EL?Y{J8e>D;B1h=H3_gAm^0TMA)<?v z8osaLIE3L556}Gg1Drp92dzsQpmo3_hK1_8j~7=QCS?HH_3K&e=<Ub(!BI4}xQTds z7O<*<mRgjc%^Rk}j<gy9#u&kI%u8|5-p`LTxrjikI$+aT43iQ)(rzQ&;zqjS;og}^ ziIa4cRDE^p)^Fb2j<&WGlvKqzC9T06!uYtvi4!+xQBu=E>+Ssj@4ovK)~soP@MKZ9 z+;Ip_D&qOnK{5$rhDk?^qp4AXT#86MN)9+)P2yE+;P=1Xi0?ht2|z?rQ(u31533(O z4&T?axI`&6T$eC(J%h6YxAEPzZSb?Q$h8S$V*=OzmkDCAQq)w6Fgd9r8JpXqJ}zIr zi_ExRJUNGgKP-E&5sm3&wqmM2!p6r1l=7pKaGDgx#zbUF0#U$DlyX+m%`V>l%QftN z@eDYph%FI<5Gvn}V*KUNqhI6AH?I~aCZ}ayz4MPPcxvl6OM_dlfQ890QOPr=$u*|A zA|X?+R8$^_HeQS4tao-ltjuZIBrnmlgH7nLq<5qj6<~A8-YOT^qye)j0yYk_W&yL7 z{2CUfHWyoH!_VVxg*Xo7-S@fVbcF4z=7ENC@3|^~g?m^gzl%d-Wil!<r^EN7>-7EN zbQh|S9+%idFjs*~tpSG<Mn<ye@4p%8%5HtS3+){NVOxTkg5w12#5?~SE*kF;5wzCm z>2Ak^-)_p~i4<xd!?mjuIDO`J{y)_mVOzFzqN}T+<ULmnurSti3=i}vUl>2-j1ioa z!6YCBd{38Ty4(~2PHmzX<%?iPf$C*Sq#TRjd5IoDRZ7DjPoPc$JWoR@uO@&cdF(cE zrvnv8+NtOES-0Mr1<V3w&FXYiTBjq)>s5Natvp~l)ah^&ck4}bIwIZr7?y5!QEo+k z#!sBtsEAI7!wJJ9S@ic`t3cp9G^fBhMq~o1fMb6h!o-9y4zMYI>V@G)&nyKof)te# zVo64)!{zw&(|g#t>vZJrPxf!c6Hlfgd{H#lU26>|#V|CK!Sg#mjI8sGKXzmHi>>fI zS=O`^=J89Z@zT!+Fg#pdytZ%eN*p@0DZd+C3D6RiE;1p1c?@9^#t7*&E0}~6t{Gu6 zPEywPfQwtV{Mcv17z6qae+6ibLw!B)vZ5lOCF*oYsUrc4OXG!vZLxYwo#ZUb=}=Rr zL*oD@CE#*G-;pcO0Q&m6;bmpwpjo#*LGy)%%L#{%T*1D514uPEAV!h8S~jPnE<pPV zfJR?mH-xVW3YXatR+l5~3?dGGE-b)IM7)yHtqRvdQ}M7;*Y-t_PJZ~v6%3rYiBw}U znjUpOB~BL{EB2i^6M?+;;*5H9@Lia*7&sj99i(EK9*ZWCln6+<Q*wEQ+<GBkd}cbE zRRviLtpUk`Gy={EZcf&(w672Z)=}I4^ZxXTY5XO!3d@%_;@sKo0Z;`cNrNE!TT9ar z*}{sUQfj1|Tpat`HT+`#$3g6NUnhoDbBFMU-)+S5RgLg7#bqg!4g@$qK6eMty>O}` z8NTywglQd!oIJd?C6d~UJkFCly>j`BiOB1&T-k*0tZoIQ0E)3DN(0Qr-BBM4UiKj_ zXxT|=Fm*<=^^QaN)1UV*{^1C5T|zdipp-y%QbwGvq1+T}0h={FCb|6+904i}SeXB4 zdb*|-auYTVgWYkqW&yKi0keQvTaaF2CG;yy-1;Qj7k(eAza@QQr>+2NZsK5!qp<}@ zBx?qM@>OD|Qkt8?_ECf^1xMOYTQSKKGgSxd+~6JfTEojou(>{ZIZo-JhnldYJvHY& zC#`{x&W>W_b{1Yn6>Xo^8gAOb=a<H?;qkVj+5<V64z#v%Xr0V{Sk)zD_r5fcc-?<K z+Jl|DRu+6&PMo$0q4C@cr;Pu%ZruYI9NZ4&mj**h4Uv_JkN}D1bOc)odd_kQh>^G{ z4M&x!+NeogVS<k15R|X;NkWyr-$Vg(`P5BB&DlCZ*#*<OTsLT+>#~yV(>mUD%h^b+ zsax+j1aDGcQ%@&e*}o?LMxc7)BE3l;J9?L4@17O#eN|WOt63aITH&>W8}Q2h)%o|7 zpiVYHgBc)5J=?oy1w=0AV$sH7VZ4!@@bTKgjR1fHuYV5CsV)>li2x}Sl+x(y>j3~9 zK5_*vC;0PB9Fr&gBu{}xIPm(Vyn33}wVxWb((tlkCTK1hsSqqkreh{(8*(G<5*&we z3$?C**q8a#Mmg{9e$aS<v;h;5v1&OQ6^+AYfkwcAgO^ZA{fl@x@Y6x-1`!20@rZwh ztQI@Y$;dtwaeaw`V>l;H-h>dk)O%{memJ6D3tWm1bT|V6IC<(;up4dIzL3FaZgKF~ z`nIZ0_Z0(ddQ6jQtSq5o^5X514^^6%eUj17$c4k!hfjNE+^<U=jJdWi?ixC8$lrR` z$Cyw09d-lL?l@bsfLXJES-`AWz^qxoEMV3wVAd>P7BFiTFl!bt3z#(vn6+5;f6bm$ U-_p|D7ytkO07*qoM6N<$f{j``EC2ui diff --git a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift index cf800c6e72..57810d1925 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift @@ -7,7 +7,21 @@ struct HomeWidgetTimelineProvider: TimelineProvider { private var base: WidgetsTimelineProvider { WidgetsTimelineProvider(name: name) } func placeholder(in context: Context) -> WidgetsTimelineEntry { base.placeholder(in: context) } func getSnapshot(in context: Context, completion: @escaping @Sendable (WidgetsTimelineEntry) -> Void) { - base.getSnapshot(in: context, completion: completion) + guard context.isPreview else { + base.getSnapshot(in: context, completion: completion) + return + } + // The gallery draws the app-written sample (`WIDGET_GALLERY_PREVIEW_NAME` + // in active-agents-widget.tsx); before the app has written it, the placed + // widget's own entry. + let base = self.base + WidgetsTimelineProvider(name: "\(name)Preview").getSnapshot(in: context) { entry in + if entry.props == nil { + base.getSnapshot(in: context, completion: completion) + } else { + completion(entry) + } + } } func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<WidgetsTimelineEntry>) -> Void) { Task { diff --git a/apps/mobile/src/glanceable-ios/active-agents-live-activity-a11y.test.ts b/apps/mobile/src/glanceable-ios/active-agents-live-activity-a11y.test.ts deleted file mode 100644 index ae7f53d271..0000000000 --- a/apps/mobile/src/glanceable-ios/active-agents-live-activity-a11y.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- the stringified widget layout is only observable as source text */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -// The `'widget'` layout is stringified by Babel and re-evaluated inside the -// widget process, so no widget transform runs under vitest and the layout never -// renders here. Its accessibility tree is pinned where it is observable: the -// source that reaches the widget process. VoiceOver reaches the Approve control -// only while no ancestor combined element swallows it, so these assertions read -// where each `combine` ends. -const source = readFileSync(join(__dirname, 'active-agents-live-activity.tsx'), 'utf8'); - -const COMBINE = "accessibilityElement('combine')"; -const APPROVE_TARGET = 'target="approve"'; - -/** One section of the returned layout object, from its key to the next key at the same indentation. */ -const section = (key: string): string => { - const pattern = new RegExp(`\\n {4}${key}: ([\\s\\S]*?)(?=\\n {4}[A-Za-z]+: |\\n {2}\\};\\n)`); - const match = source.match(pattern); - expect(match).not.toBeNull(); - return match?.[1] ?? ''; -}; - -/** The `markAndRows` block, which the phone banner and the expanded island share. */ -const markAndRowsSource = (): string => { - const start = source.indexOf('const markAndRows'); - const end = source.indexOf('\n return {', start); - expect(start).toBeGreaterThanOrEqual(0); - expect(end).toBeGreaterThan(start); - return source.slice(start, end); -}; - -/** - * The element one `combine` scopes: the `<HStack>` that opens at or before it, - * through its matching close. The layout's self-closing tags are `Image`, - * `Button`, and `Spacer`, so counting `<HStack` against `</HStack>` finds the - * close without parsing JSX. - */ -const combinedElement = (text: string, at: number): { after: number; body: string } => { - const open = text.lastIndexOf('<HStack', at); - expect(open).toBeGreaterThanOrEqual(0); - const tags = /<\/?HStack/g; - tags.lastIndex = open; - let depth = 0; - let match = tags.exec(text); - while (match !== null) { - depth += match[0] === '</HStack' ? -1 : 1; - if (depth === 0) { - return { after: match.index, body: text.slice(open, match.index) }; - } - match = tags.exec(text); - } - throw new Error('unbalanced HStack in the layout source'); -}; - -describe('glanceable live activity accessibility scoping', () => { - it('combines the count block alone and leaves the Approve control outside it', () => { - // The Lock Screen banner and the expanded island both draw `markAndRows`. - // A `combine` on that whole row merged the Approve Button into one element - // whose spoken label named the counts and "Open agents", so VoiceOver had - // no focusable Approve action. The block must combine only the counts, and - // the control must follow the combined element as a sibling. - const block = markAndRowsSource(); - const combined = combinedElement(block, block.indexOf(COMBINE)); - - expect(combined.body).toContain('countRow('); - expect(combined.body).toContain('accessibilityLabel(accessibility)'); - expect(combined.body).not.toContain('<Button'); - expect(combined.body).not.toContain(APPROVE_TARGET); - expect(block.slice(combined.after)).toContain(APPROVE_TARGET); - }); - - it('leaves the phone banner wrapper outside any combined element', () => { - const banner = section('banner'); - expect(banner).toContain('markAndRows'); - expect(banner).not.toContain(COMBINE); - expect(banner).not.toContain('accessibilityLabel(accessibility)'); - }); - - it('leaves the expanded island wrapper outside any combined element', () => { - // The island draws the same block, so a label on the island wrapper would - // re-group the Approve control the block keeps separate. - const expanded = section('expandedBottom'); - expect(expanded).toContain('markAndRows'); - expect(expanded).not.toContain(COMBINE); - expect(expanded).not.toContain('accessibilityLabel(accessibility)'); - }); - - it('keeps the watch bannerSmall count row combined and its control a sibling', () => { - // The watch pattern the phone block now mirrors: the combined label rides - // the count row alone, so the wrist can still focus and activate Approve. - const small = section('bannerSmall'); - const combined = combinedElement(small, small.indexOf(COMBINE)); - - expect(combined.body).toContain('countRow'); - expect(combined.body).not.toContain('<Button'); - expect(small.slice(combined.after)).toContain(APPROVE_TARGET); - }); -}); diff --git a/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts b/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts index de44d2b1b8..ed23364113 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts +++ b/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts @@ -1,187 +1,219 @@ -/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- the buttons' targets and copy are stringified into the widget process, so their literals are observable only in the layout source, which this suite reads from disk */ +/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- the press handler loads native modules, so its target literal is read from disk */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { buildGlanceableSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { type LiveActivityEnvironment } from 'expo-widgets'; + +import { activeAgentsLiveActivityLayout } from './active-agents-live-activity'; import { buildGlanceableLiveActivityContentState } from './view-props'; -const LAYOUT_FILE = 'active-agents-live-activity.tsx'; -const INTERACTION_FILE = 'interaction.ts'; +function mockComponent(kind: string) { + return Object.assign((props: Record<string, unknown>) => ({ kind, props }), { kind }); +} -const read = (file: string) => readFileSync(join(__dirname, file), 'utf8'); +function mockModifier(name: string) { + return (args?: unknown) => ({ $type: name, args }); +} -/** The source between two markers, so a region can be asserted on its own. */ -function region(source: string, start: string, end: string): string { - const from = source.indexOf(start); - const to = source.indexOf(end, from + start.length); - expect(from).toBeGreaterThan(-1); - expect(to).toBeGreaterThan(from); - return source.slice(from, to); +vi.mock('expo-widgets', () => ({ createLiveActivity: () => ({}) })); +vi.mock('@expo/ui/swift-ui', () => ({ + Button: mockComponent('Button'), + Circle: mockComponent('Circle'), + HStack: mockComponent('HStack'), + RoundedRectangle: mockComponent('RoundedRectangle'), + Spacer: mockComponent('Spacer'), + Text: mockComponent('Text'), + VStack: mockComponent('VStack'), +})); +vi.mock('@expo/ui/swift-ui/modifiers', () => ({ + accessibilityElement: mockModifier('accessibilityElement'), + accessibilityLabel: mockModifier('accessibilityLabel'), + activityBackgroundTint: mockModifier('activityBackgroundTint'), + background: (style: unknown) => ({ $type: 'background', args: style }), + buttonStyle: mockModifier('buttonStyle'), + environment: mockModifier('environment'), + fixedSize: mockModifier('fixedSize'), + font: mockModifier('font'), + foregroundStyle: mockModifier('foregroundStyle'), + frame: mockModifier('frame'), + lineLimit: mockModifier('lineLimit'), + monospacedDigit: mockModifier('monospacedDigit'), + padding: mockModifier('padding'), + shapes: { capsule: () => ({}), roundedRectangle: () => ({}) }, +})); +vi.mock('@/i18n', () => ({ i18n: { on: vi.fn(), t: (key: string) => key } })); + +type Rendered = { kind: string; props: Record<string, unknown> }; +type State = Parameters<typeof activeAgentsLiveActivityLayout>[0]; + +function element(node: unknown): Rendered | null { + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + if (node === null || typeof node !== 'object' || !('type' in node) || !('props' in node)) { + return null; + } + const { type, props } = node; + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + if (typeof type !== 'function' || !('kind' in type) || typeof type.kind !== 'string') { + return null; + } + // A JSX element's props object; every reader below narrows the field it uses. + const record = props as Record<string, unknown>; + return { kind: type.kind, props: record }; } -const targetsIn = (source: string): string[] => - [...source.matchAll(/target="([^"]+)"/g)].map(match => match[1] ?? '').toSorted(); - -/** - * The `'widget'` layout is stringified by Babel and re-evaluated inside the - * widget extension, where an imported binding is an undefined global and a - * non-literal target could not be sent back by the native intent. Nothing here - * runs the widget transform, so the source on disk is the only place these - * literals can be checked. - */ -describe('Active Agents Live Activity actions', () => { - const source = read(LAYOUT_FILE); - - it('declares the two stable targets', () => { - // Two surfaces draw Approve — the phone block's `actions` and the - // watch/CarPlay `bannerSmall` control beside it — so the literal repeats. - // The contract is the set of targets the handler routes, and no surface - // declares one outside it. - expect([...new Set(targetsIn(source))]).toEqual(['approve', 'open']); - }); +function collect(node: unknown): Rendered[] { + const root = element(node); + if (root === null) { + return Array.isArray(node) ? node.flatMap(item => collect(item)) : []; + } + const raw = root.props.children; + const kids = Array.isArray(raw) ? raw.flat(Infinity) : [raw]; + return [root, ...kids.flatMap(child => collect(child))]; +} - it('reads both labels from the baked copy, never through an import', () => { - expect(source).toContain('COPY.approve'); - expect(source).toContain('COPY.open'); - // The layout cannot call i18n: the widget process would throw on the - // undefined global and blank the whole surface. - expect(source).not.toMatch(/\bi18n\./); - }); +const texts = (node: unknown) => + collect(node) + .filter(item => item.kind === 'Text' && typeof item.props.children === 'string') + .map(item => item.props.children as string); +const buttons = (node: unknown) => collect(node).filter(item => item.kind === 'Button'); - /** - * The gate expression as the widget process evaluates it. The layout is - * stringified, so no import runs it under vitest; the literal in the source is - * the shipped gate, and evaluating it here is the only way to run that gate - * against a content state. - */ - const gate = (): ((props: Record<string, number | boolean | undefined>) => boolean) => { - const match = /const canApprove =([\s\S]*?);\n/.exec(source); - if (match === null) { - throw new Error('the layout declares no canApprove gate'); - } - // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func -- the shipped gate is a source literal, so it cannot be imported and called - return new Function('props', `return (${match[1] ?? ''});`) as ( - props: Record<string, number | boolean | undefined> - ) => boolean; - }; +const UPDATED_AT = new Date().toISOString(); +const ENVIRONMENT: LiveActivityEnvironment = { colorScheme: 'light' }; - it('offers Approve only while an approvable ask waits', () => { - const canApprove = gate(); - // The app's own state: the recorded-ask flag decides, and the wait count - // withholds the control from the expired frame `withStatus` leaves the - // approvable count on. - expect(canApprove({ needsInput: 1, needsApproval: 1, canApprove: true })).toBe(true); - expect(canApprove({ needsInput: 1, needsApproval: 1, canApprove: false })).toBe(false); - expect(canApprove({ needsInput: 0, needsApproval: 1, canApprove: true })).toBe(false); - expect(source).toMatch(/canApprove \? \([\s\S]*?target="approve"[\s\S]*?\) : null/); +function render(state: State, environment: LiveActivityEnvironment = ENVIRONMENT) { + return activeAgentsLiveActivityLayout(state, environment); +} + +const PERMISSION: State = { + status: 'happy', + needsInput: 2, + needsApproval: 1, + running: 3, + scheduled: 1, + idle: 0, + updatedAt: UPDATED_AT, + canApprove: true, +}; + +describe('Live Activity Lock Screen card (round 7)', () => { + it('draws the header, the status row, Approve, and the other counts', () => { + const { banner } = render(PERMISSION); + expect(texts(banner)).toEqual([ + 'Kilo', + 'Checked', + '2', + 'Needs input', + 'Approve', + '3 Working · 1 Scheduled', + ]); + expect(buttons(banner).map(button => button.props.target)).toEqual(['approve']); + expect(collect(banner).some(item => item.props.dateStyle === 'time')).toBe(true); }); - it('withholds Approve from a server-written question-only state', () => { - // A state that arrived over APNs carries no `canApprove`, and its - // `needsApproval` counts the `permission` rows alone. A question-only wait - // must not draw the control `runGlanceableApprove` answers with `none`, - // while a permission wait keeps the tap the app-closed press answers. - const canApprove = gate(); - expect(canApprove({ needsInput: 1, needsApproval: 0 })).toBe(false); - expect(canApprove({ needsInput: 1, needsApproval: 0, canApprove: true })).toBe(false); - expect(canApprove({ needsInput: 1, needsApproval: 1 })).toBe(true); - expect(source).toContain('props.canApprove !== false'); - expect(source).not.toMatch(/props\.canApprove === true/); + it('swaps Approve for Approving… while the press is in flight', () => { + const { banner, expandedBottom } = render({ ...PERMISSION, approving: true }); + for (const surface of [banner, expandedBottom]) { + expect(buttons(surface)).toEqual([]); + expect(texts(surface)).toContain('Approving…'); + } }); - it('always offers Open', () => { - const guardEnd = source.indexOf(') : null', source.indexOf('canApprove ? (')); - expect(guardEnd).toBeGreaterThan(-1); - // The Open button is outside the needs-input guard: it is the only way to - // the session the card names. - expect(source.indexOf('target="open"')).toBeGreaterThan(guardEnd); + it('keeps Approve and prints the failure line instead of the other counts', () => { + const notice = "Couldn't approve. Tap Approve to try again."; + const { banner } = render({ ...PERMISSION, notice }); + expect(texts(banner)).toContain(notice); + expect(texts(banner)).not.toContain('3 Working · 1 Scheduled'); + expect(buttons(banner)).toHaveLength(1); }); - it('draws the actions on the banner and in the expanded island', () => { - expect(region(source, 'banner: (', 'compactLeading:')).toContain('{actions}'); - expect(region(source, 'expandedBottom: (', '\n };\n};')).toContain('{actions}'); + it('offers Approve only while an approvable ask waits', () => { + const offered = (state: State) => buttons(render(state).banner).length > 0; + expect(offered(PERMISSION)).toBe(true); + expect(offered({ ...PERMISSION, canApprove: false })).toBe(false); + // A server-written question-only state carries no flag and no approvable count. + expect(offered({ ...PERMISSION, needsApproval: 0, canApprove: undefined })).toBe(false); + expect(offered({ ...PERMISSION, needsApproval: 1, canApprove: undefined })).toBe(true); + // The expired frame zeroes the wait but leaves the approvable count. + expect(offered({ ...PERMISSION, needsInput: 0 })).toBe(false); }); - it('draws a failed Approve on the surfaces the button lives on', () => { - // The press can arrive with the app closed, so the failure line the app - // puts in the content state has to be drawn on the card itself. - expect(source).toContain('const notice = props.notice ?? null;'); - expect(region(source, 'banner: (', 'bannerSmall:')).toContain('{noticeLine}'); - expect(region(source, 'bannerSmall: (', 'compactLeading:')).toContain('{noticeLine}'); - expect(region(source, 'expandedBottom: (', '\n };\n};')).toContain('{noticeLine}'); - // The compact presentations carry the count alone: the notice goes where - // the buttons and the labelled counts are. - expect(region(source, 'compactLeading:', 'expandedBottom:')).not.toContain('{noticeLine}'); + it('drops the bottom line for a lone question and prints Next run for scheduled only', () => { + const question = render({ status: 'happy', needsInput: 1, updatedAt: UPDATED_AT }).banner; + expect(texts(question)).toEqual(['Kilo', 'Checked', '1', 'Needs input']); + const scheduled = render({ + status: 'happy', + scheduled: 2, + scheduledAt: new Date(Date.now() + 3_600_000).toISOString(), + updatedAt: UPDATED_AT, + }).banner; + expect(texts(scheduled)).toContain('Next run'); }); - it('reserves the small banner notice row even before an Approve failure', () => { - const small = region(source, 'bannerSmall: (', 'compactLeading:'); - expect(small).toMatch( - /<VStack modifiers=\{\[frame\(\{ height: 18 \}\)\]\}>\s*\{noticeLine\}\s*<\/VStack>/ + it('says Last known on a stale card and prints no time without updatedAt', () => { + expect(texts(render({ ...PERMISSION, status: 'stale' }).banner)).toContain('Last known ·'); + expect(texts(render(PERMISSION, { colorScheme: 'dark', isStale: true }).banner)).toContain( + 'Last known ·' ); + const legacy = render({ ...PERMISSION, updatedAt: undefined }).banner; + expect(texts(legacy)).not.toContain('Checked'); }); - it('keeps every compact presentation free of buttons', () => { - const compact = region(source, 'compactLeading:', 'expandedBottom:'); - expect(compact).not.toContain('<Button'); - expect(compact).not.toContain('{actions}'); + it('keeps the spoken count block apart from the Approve control', () => { + const combined = collect(render(PERMISSION).banner).filter(item => + (Array.isArray(item.props.modifiers) ? item.props.modifiers : []).some( + (modifier: { $type?: string; args?: unknown }) => + modifier.$type === 'accessibilityElement' && modifier.args === 'combine' + ) + ); + expect(combined).toHaveLength(1); + expect(buttons(combined[0])).toEqual([]); }); - it('bakes both action slots from the reviewed keys', () => { - const copy = read('layout-copy.ts'); - expect(copy).toContain("approve: i18n.t('common.approve')"); - expect(copy).toContain("open: i18n.t('glanceable.openSession')"); + it('paints the card background from the colour scheme', () => { + const tint = (scheme: 'light' | 'dark') => + collect(render(PERMISSION, { colorScheme: scheme }).banner) + .flatMap(item => (Array.isArray(item.props.modifiers) ? item.props.modifiers : [])) + .find((modifier: { $type?: string }) => modifier.$type === 'activityBackgroundTint'); + expect(tint('light')).toEqual({ $type: 'activityBackgroundTint', args: '#FBFAF5' }); + expect(tint('dark')).toEqual({ $type: 'activityBackgroundTint', args: '#17171A' }); }); +}); - it('routes exactly the targets the layout declares', () => { - // The layout cannot import the target constants — they would be undefined - // globals in the widget process — so the two files are compared instead. - const interactionTargets = [ - ...read(INTERACTION_FILE).matchAll(/GLANCEABLE(?:_APPROVE|_OPEN)_TARGET = '([^']+)'/g), - ] - .map(match => match[1] ?? '') - .toSorted(); - expect(interactionTargets).toEqual(['approve', 'open']); - expect([...new Set(targetsIn(source))]).toEqual(interactionTargets); +describe('Live Activity Dynamic Island (round 7)', () => { + it('draws the dot and count in compact and minimal, capping minimal at 99+', () => { + const layout = render({ ...PERMISSION, needsInput: 120 }); + expect(texts(layout.compactTrailing)).toEqual(['120']); + expect(texts(layout.minimal)).toEqual(['99+']); + for (const surface of [layout.compactLeading, layout.compactTrailing, layout.minimal]) { + expect(buttons(surface)).toEqual([]); + } }); - it('foregrounds the app for Open, and only for Open', () => { - // A Live Activity button performs in the app's process without bringing it - // forward, so Open asks for the foreground: the destination the press - // records is consumed by the app's own listener, which the user has to be - // looking at. Approve must not ask for it — an Approve that needed the app - // up would not answer the Lock Screen with the app closed. - expect(source).toContain('const openButtonProps = { openAppWhenRun: true };'); - expect(source.match(/\.\.\.openButtonProps/g)).toHaveLength(1); - // The one spread is the Open button's: the prop travels beside its target. - expect(region(source, '{...openButtonProps}', 'modifiers=')).toContain('target="open"'); + it('repeats the card with Approve in the expanded island', () => { + const { expandedBottom } = render(PERMISSION); + expect(texts(expandedBottom)).toContain('3 Working · 1 Scheduled'); + expect(buttons(expandedBottom).map(button => button.props.target)).toEqual(['approve']); }); +}); - it('names the foregrounding prop the patched expo-widgets button view reads', () => { - // The prop name is the JS-to-native contract: the layout writes - // `openAppWhenRun` on the Open button, and expo-widgets' patched ButtonProps - // reads that name and presses through the intent that asks the system for - // the foreground. The two sides are literals in different languages, so the - // patch is read here to hold them equal — a rename on one side alone would - // put Open back in the background with no other test to catch it. - const patch = read('../../../../patches/expo-widgets@57.0.22.patch'); - expect(patch).toContain('@Field var openAppWhenRun: Bool?'); - expect(patch).toContain('static var openAppWhenRun: Bool = true'); - expect(patch).toContain('intent: LiveActivityOpenInteraction('); +describe('Live Activity press target', () => { + it('declares only the target interaction.ts routes', () => { + const declared = /export const GLANCEABLE_APPROVE_TARGET = '([^']+)'/.exec( + readFileSync(join(__dirname, 'interaction.ts'), 'utf8') + )?.[1]; + const layout = render(PERMISSION); + const targets = Object.values(layout).flatMap(surface => + buttons(surface).map(button => button.props.target) + ); + expect(new Set(targets)).toEqual(new Set([declared])); }); }); -/** - * The scheduled row reaches the card over two paths: the app builds the content - * state from a snapshot, and the notifications Worker pushes the same raw shape - * for a card that was never woken by the app. Both carry the count and the ISO - * wake, and the layout draws the wake as a clock time beside the row — never - * when a scheduled row had no wake to report. - */ -describe('Active Agents Live Activity scheduled row', () => { - it('carries the scheduled count and its ISO wake in the built content state', () => { +describe('Live Activity content state', () => { + it('carries the counts, the checked time, and the ISO wake', () => { const snapshot = buildGlanceableSnapshot({ sessions: [{ status: 'scheduled', scheduledAt: '2026-09-24T09:00:00.000Z' }], userId: 'u1', @@ -191,5 +223,6 @@ describe('Active Agents Live Activity scheduled row', () => { const contentState = buildGlanceableLiveActivityContentState(snapshot); expect(contentState.scheduled).toBe(1); expect(contentState.scheduledAt).toBe('2026-09-24T09:00:00.000Z'); + expect(contentState.updatedAt).toBe('2026-01-02T00:00:00.000Z'); }); }); diff --git a/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx b/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx index 1881f2d034..e25005a93c 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx @@ -1,504 +1,500 @@ -import { Button, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +/* eslint-disable max-lines -- the 'widget' layout function is stringified whole, so its helpers cannot be extracted to module scope; the surface stays in one function */ +import { Button, Circle, HStack, RoundedRectangle, Spacer, Text, VStack } from '@expo/ui/swift-ui'; import { accessibilityElement, accessibilityLabel, - controlSize, - cornerRadius, + activityBackgroundTint, + background, + buttonStyle, environment, fixedSize, font, foregroundStyle, frame, - layoutPriority, lineLimit, - minimumScaleFactor, monospacedDigit, padding, - resizable, - tint, + shapes, } from '@expo/ui/swift-ui/modifiers'; import { createLiveActivity, type LiveActivityComponent } from 'expo-widgets'; -import { PlatformColor } from 'react-native'; import { type GlanceableLiveActivityContentState } from '@kilocode/notifications'; import { withGlanceableCopy } from './layout-copy'; -import { withWidgetLogo } from './widget-logo'; - -/* eslint-disable new-cap -- PlatformColor is a React Native factory function, not a constructor */ -/* eslint-disable max-lines -- the 'widget' layout function is stringified whole, so its helpers cannot be extracted to module scope; the surface stays in one function */ // The layout function below is marked with the `'widget'` directive, so Babel -// stringifies it and the watcher extension re-evaluates the source. Everything -// it references must be a watcher global (`Text`, `VStack`, `Button`, the -// modifiers, `PlatformColor`) or a built-in. Do not call `@/` helpers or i18n -// from here. +// stringifies it and the widget extension re-evaluates the source. Everything +// it references must be a widget global (`Text`, `VStack`, `Button`, the +// modifiers) or a built-in. Do not call `@/` helpers or i18n from here. // -// Two values are resolved after stringification, both from literals below: -// `withWidgetLogo` swaps `__KILO_WIDGET_LOGO_URI__` for the app-group path of -// the mark, and `withGlanceableCopy` swaps `__KILO_GLANCEABLE_COPY__` for the -// translated copy. The copy is baked in rather than passed through the content -// state because the notifications Worker pushes the same raw shape and knows -// no locale. +// `withGlanceableCopy` swaps `__KILO_GLANCEABLE_COPY__` for the translated copy +// after stringification. The copy is baked in rather than passed through the +// content state because the notifications Worker pushes the same raw shape and +// knows no locale. -// The pushed content state plus the two facts the widget extension cannot -// derive: whether the recorded ask is one Approve can answer, and the failure -// line a retryable Approve left on the card. They stay local fields rather than -// imports from `./view-props`, because Babel stringifies this function's source -// and every imported binding would be an undefined global in the widget -// process. +// The pushed content state plus the facts only the app can add: whether the +// recorded ask is one Approve can answer, the failure line a retryable Approve +// left on the card, and whether an Approve press is in flight. They stay local +// fields rather than imports from `./view-props`, because Babel stringifies +// this function's source and every imported binding would be an undefined +// global in the widget process. `updatedAt` is optional: a card started by an +// older server build carries no checked time and draws none. type ContentState = Partial<GlanceableLiveActivityContentState> & { canApprove?: boolean; notice?: string; + approving?: boolean; }; +/** + * The approved Live Activity design (round 7). The Lock Screen card is 364pt + * wide: the header at 16pt (tile, Kilo, and the checked time trailing), the + * status row centred at 74pt (count 36, dot, label 16, Approve pill 96x32), and + * an optional muted line at 118pt — the card is 140pt tall with it and 108pt + * without. The expanded Dynamic Island repeats it on black at count 32 and + * label 15; the compact and minimal presentations carry the dot and the count. + */ // Babel replaces the annotated arrow with its source string, so `layout` is a // string at runtime while TypeScript still checks it as a component — the same // shape `expo-widgets` casts internally. -const layout: LiveActivityComponent<ContentState> = props => { +const layout: LiveActivityComponent<ContentState> = (props, activityEnvironment) => { 'widget'; - // The literal, not the imported constant: the widget transform stringifies + // The literal, not an imported constant: the widget transform stringifies // this function's source, so an imported binding would be an undefined // global in the widget process. `withGlanceableCopy` replaces the token, // quotes included, with the translated copy as a JSON source literal. // eslint-disable-next-line typescript-eslint/no-inferrable-types -- see above const copySource: string = '__KILO_GLANCEABLE_COPY__'; - const COPY = JSON.parse(copySource) as Record<string, string>; - // The tag SwiftUI formats the relative wait with; English when the bake is - // somehow missing it, which is what the widget process would have used anyway. + const COPY = JSON.parse(copySource.startsWith('{') ? copySource : '{}') as Record<string, string>; const locale = COPY.locale ?? 'en'; // The counts are stringified here, not formatted: a pushed content state // carries raw numbers and this process has no formatter. `COPY.digits` is the - // language's own ten, empty when it writes them the way `String` already - // does, so an Arabic count reads "١" beside the "٢٦ د" SwiftUI formats. + // language's own ten (empty when `String` already writes them) and + // `COPY.group` its thousands separator. const digits = COPY.digits ?? ''; - const count = (value: number) => - digits.length === 10 + const groupSeparator = COPY.group ?? ','; + const count = (value: number) => { + const plain = String(value); + let grouped = ''; + for (let index = 0; index < plain.length; index += 1) { + if (index > 0 && (plain.length - index) % 3 === 0) { + grouped += groupSeparator; + } + grouped += plain[index] ?? ''; + } + return digits.length === 10 ? // eslint-disable-next-line unicorn/prefer-spread -- `replaceAll` and a spread both failed in the widget process; this form is the one verified on device - String(value) + grouped .split('') - .map(character => digits[Number(character)] ?? character) + .map(character => (/[0-9]/.test(character) ? digits[Number(character)] : character)) .join('') - : String(value); + : grouped; + }; const status = props.status ?? 'empty'; - const statusLine = status === 'happy' ? null : COPY[status]; + const statusLine = status === 'happy' ? null : (COPY[status] ?? null); + const dark = activityEnvironment.colorScheme === 'dark'; + const palette = dark + ? { + bg: '#17171A', + fg: '#F2F0EB', + muted: '#8A8680', + warn: '#F2B05F', + good: '#5FCB8E', + info: '#60A5FA', + idle: '#56544F', + primary: '#E8F27A', + primaryFg: '#1A1A10', + secondary: '#26262B', + } + : { + bg: '#FBFAF5', + fg: '#14130F', + muted: '#6F6A61', + warn: '#956011', + good: '#24784A', + info: '#2260EB', + idle: '#A9A39A', + primary: '#4F5A10', + primaryFg: '#FFFFFF', + secondary: '#F0EEE6', + }; // Rank order: what the user must act on, then what is making progress, then - // what is only connected. The Dynamic Island shows one number, so this - // ranking decides what a glance says. The glyphs differ in shape as well as - // color (exclamation / filled / hollow) so the state reads without color. + // what will wake, then what is only connected. One number leads every + // presentation, so this ranking decides what a glance says. const countLines = [ - { - kind: 'needsInput', - label: COPY.needsInput, - count: props.needsInput ?? 0, - icon: 'exclamationmark.circle.fill', - color: PlatformColor('systemOrange'), - }, - { - kind: 'running', - label: COPY.running, - count: props.running ?? 0, - icon: 'circle.fill', - color: PlatformColor('systemGreen'), - }, - { - kind: 'scheduled', - label: COPY.scheduled, - count: props.scheduled ?? 0, - icon: 'clock', - color: PlatformColor('label'), - }, - { - kind: 'idle', - label: COPY.idle, - count: props.idle ?? 0, - icon: 'circle', - color: PlatformColor('label'), - }, - // `as const` keeps each `icon` an SF Symbol literal, which the Image prop - // type requires. + { kind: 'needsInput', label: COPY.needsInput ?? 'Needs input', count: props.needsInput ?? 0 }, + { kind: 'running', label: COPY.running ?? 'Working', count: props.running ?? 0 }, + { kind: 'scheduled', label: COPY.scheduled ?? 'Scheduled', count: props.scheduled ?? 0 }, + { kind: 'idle', label: COPY.idle ?? 'Idle', count: props.idle ?? 0 }, ] as const; - // A zero row still draws, so the rows never reflow as work changes state. - // `primary` skips the zeros: one number on the Dynamic Island must be a - // number worth showing. const primary = countLines.find(line => line.count > 0) ?? null; - const hasCounts = primary !== null; - const primaryCount = count(primary === null ? 0 : primary.count); - // Only the needs-input row carries a duration, and only the oldest wait: a - // blocked agent is the one interval the user can act on. Working and idle - // durations tell the user nothing they can use. - const needsInputSince = (props.needsInput ?? 0) > 0 ? (props.needsInputSince ?? null) : null; - // The soonest wake, drawn only beside a non-zero scheduled row: a scheduled - // count with no wake time is representable, and the row then carries no time. - const scheduledAt = (props.scheduled ?? 0) > 0 ? (props.scheduledAt ?? null) : null; + const others = countLines + .filter(line => line.count > 0 && line !== primary) + .map(line => `${count(line.count)} ${line.label}`) + .join(' · '); + type Scheme = { + fg: string; + muted: string; + warn: string; + tile: string; + inner: string; + primary: string; + primaryFg: string; + secondary: string; + dots: Record<(typeof countLines)[number]['kind'], string>; + countSize: number; + labelSize: number; + }; + const card: Scheme = { + ...palette, + tile: palette.fg, + inner: palette.bg, + dots: { + needsInput: palette.warn, + running: palette.good, + scheduled: palette.info, + idle: palette.idle, + }, + countSize: 36, + labelSize: 16, + }; + // The Dynamic Island is always black: the dark palette with white text. + const island: Scheme = { + fg: '#FFFFFF', + muted: '#8A8680', + warn: '#F2B05F', + tile: '#F2F0EB', + inner: '#17171A', + primary: '#E8F27A', + primaryFg: '#1A1A10', + secondary: '#26262B', + dots: { needsInput: '#F2B05F', running: '#5FCB8E', scheduled: '#60A5FA', idle: '#56544F' }, + countSize: 32, + labelSize: 15, + }; // Approval is offered only while an ask actually waits and the app recorded - // one Approve can answer: an Approve that cannot answer anything is a dead - // control, and a card whose ask was just answered elsewhere would keep - // offering the tap that answered it. `needsInput` counts questions and - // retried asks too, so the wait count alone must not offer a control that - // cannot act; the pushed `needsApproval` — the `permission` rows, the one - // wait the user can clear without choosing an option — narrows it. That - // narrower count is also what stands in when the app wrote no flag, which is - // every state that arrived over APNs: a question-only server state then - // offers Open alone instead of a tap the press answers with `none`, while a - // `permission` server state still offers the tap the app-closed press - // answers. `withStatus` zeroes the wait count on expiry but leaves - // `needsApproval` standing, so the wait term is what keeps the retained - // expired frame gateless. The phone `actions` and the watch `bannerSmall` - // control share this one gate, so each offers exactly the tap its own press - // can answer. + // one Approve can answer. `needsInput` counts questions and retried asks too, + // so the wait count alone must not offer a control that cannot act; the + // pushed `needsApproval` — the `permission` rows — narrows it. That narrower + // count is also what stands in when the app wrote no flag, which is every + // state that arrived over APNs: a question-only server state then offers no + // tap, while a `permission` server state still offers the tap the app-closed + // press answers. `withStatus` zeroes the wait count on expiry but leaves + // `needsApproval` standing, so the wait term keeps the expired frame gateless. const canApprove = (props.needsInput ?? 0) > 0 && (props.needsApproval ?? 0) > 0 && props.canApprove !== false; - - // The failure line a retryable Approve left on the card. The app sets it in - // the content state, because this process cannot translate; a server-written - // state and a card whose ask changed carry none, and the line then draws - // nothing at all. - const notice = props.notice ?? null; + // The press in flight replaces Approve with a muted Approving… pill; a + // failure brings Approve back with its line under the counts. + const approving = props.approving === true && (props.needsInput ?? 0) > 0; + // A retry in flight hides the failure line it answers. + const notice = approving ? null : (props.notice ?? null); + const scheduledAt = + primary?.kind === 'scheduled' && others.length === 0 && props.scheduledAt != null + ? new Date(props.scheduledAt) + : null; + const updatedAt = props.updatedAt == null ? null : new Date(props.updatedAt); + const checkedAt = updatedAt !== null && Number.isFinite(updatedAt.getTime()) ? updatedAt : null; + const lastKnown = + status === 'stale' || status === 'expired' || activityEnvironment.isStale === true; // Spoken label: status word, numeric counts, then Open agents. The whole // surface deep-links to the agents list, so "Open agents" stays in the // spoken label even though no line draws it. - const spokenParts = [ - ...(statusLine !== null ? [statusLine] : []), + const accessibility = [ + ...(statusLine === null ? [] : [statusLine]), ...countLines.map(line => `${line.count} ${line.label}`), - COPY.openAgents, - ]; - const accessibility = spokenParts.join(', '); + COPY.openAgents ?? 'Open agents', + ].join(', '); - const primaryForeground = foregroundStyle(PlatformColor('label')); - // `secondaryLabel` in both appearances: `tertiaryLabel` on the light widget - // background left the ranked-down rows too faint to read. - const mutedForeground = foregroundStyle(PlatformColor('secondaryLabel')); + // eslint-disable-next-line max-params -- a text run is its copy, size, colour and weight, named as the design names them + const text = ( + value: string, + size: number, + color: string, + weight: 'regular' | 'semibold' = 'regular' + ) => ( + <Text modifiers={[font({ size, weight }), lineLimit(1), foregroundStyle(color)]}>{value}</Text> + ); + const dot = (diameter: number, color: string) => ( + <Circle modifiers={[frame({ width: diameter, height: diameter }), foregroundStyle(color)]} /> + ); + // The Kilo tile: a filled square with a smaller square of the background in it. + const tile = (size: number, fill: string, inner: string) => ( + <RoundedRectangle + cornerRadius={1} + modifiers={[ + foregroundStyle(inner), + frame({ width: size * 0.44, height: size * 0.44 }), + frame({ width: size, height: size }), + background(fill, shapes.roundedRectangle({ cornerRadius: size * 0.22 })), + ]} + /> + ); + // The clock time alone today; another day adds its weekday. + const clock = (date: Date, color: string) => { + if (date.toDateString() === new Date().toDateString()) { + return ( + <Text + date={date} + dateStyle="time" + modifiers={[font({ size: 11 }), lineLimit(1), monospacedDigit(), foregroundStyle(color)]} + /> + ); + } + try { + // eslint-disable-next-line no-restricted-globals -- the widget process cannot import @/lib/intl-cache; failure falls back to SwiftUI's date text + const formatted = new Intl.DateTimeFormat(locale.split('_').join('-'), { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + }).format(date); + return text(formatted, 11, color); + } catch { + return ( + <Text + date={date} + dateStyle="time" + modifiers={[font({ size: 11 }), lineLimit(1), monospacedDigit(), foregroundStyle(color)]} + /> + ); + } + }; - // The failure line, drawn on its own full-width row under the counts so the - // whole banner width carries it: inside the count block it would have to fit - // between the mark and the buttons, which shrinks a reviewed sentence past - // reading. Orange, the color of the Approve button the notice asks the user - // to tap again. Nothing to draw when no press failed. - const noticeLine = - notice === null ? null : ( - <Text - modifiers={[ - font({ textStyle: 'footnote', weight: 'semibold' }), - // The line stays one row: the banner grows once for the notice and - // never reflows again as the copy changes language. In the widget - // renderer a scaling line always draws at its minimum, so 0.85 is - // the size it draws at: one step below footnote, still readable. - lineLimit(1), - minimumScaleFactor(0.85), - foregroundStyle(PlatformColor('systemOrange')), - ]} - > - {notice} - </Text> - ); + // Header: the tile, Kilo, and the checked time at the trailing edge. + const header = (scheme: Scheme) => ( + <HStack alignment="center" spacing={6} modifiers={[frame({ height: 18 })]}> + {tile(18, scheme.tile, scheme.inner)} + {text('Kilo', 13, scheme.fg, 'semibold')} + <Spacer minLength={8} /> + {checkedAt === null ? null : ( + <HStack spacing={3}> + {text( + lastKnown ? `${COPY.lastKnown ?? 'Last known'} ·` : (COPY.checked ?? 'Checked'), + 11, + scheme.muted + )} + {clock(checkedAt, scheme.muted)} + </HStack> + )} + </HStack> + ); - // The literal, not the imported constant: the widget transform stringifies - // this function's source, so an imported binding would be an undefined global - // in the widget process. It must stay equal to `WIDGET_LOGO_PLACEHOLDER`, which - // `withWidgetLogo` replaces with the app-group path. - // The annotation widens the literal: the token is replaced after this file is - // stringified, so the empty-path branch below is reachable at runtime. - // eslint-disable-next-line typescript-eslint/no-inferrable-types -- see above - const logoUri: string = '__KILO_WIDGET_LOGO_URI__'; - const logo = (size: number) => - logoUri.length === 0 ? null : ( - <Image - uiImage={logoUri} - modifiers={[resizable(), frame({ width: size, height: size }), cornerRadius(size * 0.24)]} - /> + // The Approve pill, or the muted Approving… pill while the press is in + // flight. The target literal must stay equal to `GLANCEABLE_APPROVE_TARGET` + // in `interaction.ts`, which `active-agents-live-activity.test.ts` holds it to. + const pill = (label: string, width: number, tone: { fill: string; color: string }) => ( + <Text + modifiers={[ + font({ size: 13, weight: 'semibold' }), + lineLimit(1), + foregroundStyle(tone.color), + fixedSize({ horizontal: true, vertical: false }), + padding({ horizontal: 14 }), + frame({ minWidth: width, height: 32 }), + background(tone.fill, shapes.capsule()), + ]} + > + {label} + </Text> + ); + const approveControl = (scheme: Scheme) => { + if (approving) { + return pill(COPY.approving ?? 'Approving…', 104, { + fill: scheme.secondary, + color: scheme.muted, + }); + } + if (!canApprove) { + return null; + } + return ( + <Button + target="approve" + modifiers={[buttonStyle('plain'), accessibilityLabel(COPY.approve ?? 'Approve')]} + > + {pill(COPY.approve ?? 'Approve', 96, { fill: scheme.primary, color: scheme.primaryFg })} + </Button> ); + }; - // One row per state: a colored glyph carries the state (readable without - // color), a fixed-width count, then the label. Every row shares one type - // size so the counts line up on a grid; only the label dims to rank them. - // `showTimes` draws the needs-input wait and the scheduled wake beside - // their rows. A time that cannot fit is dropped whole, never truncated: the - // expanded island and the watch row never draw one, and the banner drops a - // row's time when its label is longer than the 13 characters that leave the - // time room beside the buttons (a character count is the measure this - // process has). - const TIME_LABEL_BUDGET = 13; - // Past 16 characters a label no longer fits beside the mark and the buttons - // even with its time dropped (German "Eingabe erforderlich"), so the mark - // gives its width up next: the last thing a narrow surface drops before a - // label truncates. - const markFits = countLines.every(line => (line.label ?? '').length <= 16); - // `elapsed` is the repo-local @expo/ui style (patches/@expo+ui): the wait in - // its one largest unit, seconds only under a minute ("31 minutes", "20 - // seconds"); before iOS 18 it is the relative style. - const waitStyle = 'elapsed'; - const timeModifiers = [ - font({ textStyle: 'footnote' }), - monospacedDigit(), - lineLimit(1), - mutedForeground, - ]; - // A zero row keeps its place in the grid but sinks below every non-zero one - // and draws fully muted: glyph, number and label in the secondary colour, - // the number without its emphasis. A stable sort keeps the kind order. - const ledgerRows = [ - ...countLines.filter(line => line.count > 0), - ...countLines.filter(line => line.count === 0), - ]; - const mutedColor = PlatformColor('secondaryLabel'); - const countRow = (line: (typeof countLines)[number], isPrimary: boolean, showTimes: boolean) => { - const zero = line.count === 0; - const timeFits = showTimes && (line.label ?? '').length <= TIME_LABEL_BUDGET; - return ( - <HStack key={line.label} alignment="center" spacing={7}> - <Image systemName={line.icon} color={zero ? mutedColor : line.color} size={13} /> + // The status row: count, dot, label; or the status line when nothing counts. + // The spoken label sits on this block alone, so the Approve control after it + // stays its own focusable element. + const statusBlock = (scheme: Scheme) => ( + <HStack + alignment="center" + spacing={6} + modifiers={[accessibilityElement('combine'), accessibilityLabel(accessibility)]} + > + {primary === null ? ( + text( + statusLine ?? COPY.openAgents ?? 'Open agents', + scheme.labelSize, + scheme.fg, + 'semibold' + ) + ) : ( <Text modifiers={[ - font({ textStyle: 'subheadline', weight: zero ? 'regular' : 'semibold' }), + font({ size: scheme.countSize, weight: 'bold' }), monospacedDigit(), - // The number is the whole point of the row, so it takes its space - // first: a long label truncates before the count does. - layoutPriority(1), - zero ? mutedForeground : primaryForeground, - ]} - > - {count(line.count)} - </Text> - <Text - modifiers={[ - font({ textStyle: 'subheadline' }), - // One line at the row's own size. Do not add `minimumScaleFactor`: - // in the widget renderer it always drew the label at its minimum - // scale, room or not, far smaller than the count beside it. lineLimit(1), - // A live time reserves more width than it draws, so without the - // priority it took the label's room and the label truncated - // beside a time that fit. - layoutPriority(1), - isPrimary && !zero ? primaryForeground : mutedForeground, + fixedSize({ horizontal: true, vertical: false }), + foregroundStyle(scheme.fg), ]} > - {line.label} + {count(primary.count)} </Text> - {timeFits && line.kind === 'needsInput' && needsInputSince !== null ? ( - <Text date={new Date(needsInputSince)} dateStyle={waitStyle} modifiers={timeModifiers} /> - ) : null} - {timeFits && line.kind === 'scheduled' && scheduledAt !== null ? ( - // A wait counts a duration ("31 minutes"); a wake is the moment the - // user asked for, so it reads as a clock time ("9:00 AM"), the way - // the session list shows it. - <Text date={new Date(scheduledAt)} dateStyle="time" modifiers={timeModifiers} /> - ) : null} - </HStack> - ); - }; - - // The mark, then the rows. The Lock Screen banner and the expanded Dynamic - // Island draw the same block, so one glance teaches both surfaces. The spoken - // label is combined onto this block rather than the whole surface, because - // the action buttons after it are their own elements: a combined container - // would swallow the two taps into the count label. The Apple Watch and CarPlay - // draw the `bannerSmall` section instead: their activity family is `.small`, - // so they get one compact row plus the watch control rather than this block. - const markAndRows = (markSize: number, showTimes: boolean) => ( - <HStack - alignment="center" - spacing={12} - modifiers={[accessibilityElement('combine'), accessibilityLabel(accessibility)]} - > - {markFits ? logo(markSize) : null} - {/* The combined label sits on the counts/status block alone, the way the - watch `bannerSmall` scopes it to its count row, so the action buttons - after this block stay separate, focusable elements. `combine` on the - whole row merged the control into one element whose spoken label named - only the counts and "Open agents", which is how the Lock Screen banner - lost the action for VoiceOver. */} - <HStack - alignment="center" - spacing={7} - modifiers={[accessibilityElement('combine'), accessibilityLabel(accessibility)]} - > - {hasCounts ? ( - // The emphasised row is the ranked primary, not the first row: with - // zeros drawn the first row is often a 0. - <VStack alignment="leading" spacing={5}> - {ledgerRows.map(line => countRow(line, line === primary, showTimes))} - </VStack> - ) : ( - <Text modifiers={[font({ textStyle: 'subheadline' }), mutedForeground]}> - {statusLine} - </Text> - )} - </HStack> - <Spacer /> + )} + {primary === null ? null : dot(scheme.countSize > 32 ? 9 : 8, scheme.dots[primary.kind])} + {primary === null ? null : text(primary.label, scheme.labelSize, scheme.fg, 'semibold')} </HStack> ); - // The two action buttons, on the surfaces with room: the Lock Screen banner - // and the expanded Dynamic Island. The compact presentations keep the count - // alone — two tappable controls in the leading/trailing slots would crowd - // the one number a glance reads — so the buttons sit exactly where the counts - // are drawn in full. - // - // `target` is the stable id the press reports back; the app's listener routes - // it. The literals must stay equal to the targets in `interaction.ts`, which - // `active-agents-live-activity.test.ts` holds them to. - // - // A text `Button`, not an icon child: this process has no React context and - // no SVG renderer, so the theme tokens reach it as `PlatformColor` — the - // same values the count rows use. Approve carries the needs-input orange of - // the row it answers; Open takes the label color. The two stack in one - // trailing column at the small control size and their natural width - // (`fixedSize`): side by side, or with icons, they squeezed the rows until - // the labels and the wait truncated and the button titles wrapped. - // - // The Open tap has to show the user the session, and a Live Activity button's - // intent performs in the app's process without foregrounding it: unattended, - // the tap records a destination on a surface nobody is looking at. - // `openAppWhenRun` selects the foregrounding intent in the patched - // expo-widgets button view, so the app is up to consume that destination. It - // is a prop of that view, not of `@expo/ui`'s `Button`, so it travels as the - // plain extra prop the widget process serialises with the rest. - const openButtonProps = { openAppWhenRun: true }; - const actions = ( - <VStack alignment="trailing" spacing={8}> - {canApprove ? ( - <Button - target="approve" - label={COPY.approve} - modifiers={[fixedSize(), controlSize('small'), tint(PlatformColor('systemOrange'))]} - /> - ) : null} - <Button - {...openButtonProps} - target="open" - label={COPY.open} - modifiers={[fixedSize(), controlSize('small'), tint(PlatformColor('label'))]} - /> - </VStack> - ); + // The line under the counts: a failed Approve's retry line, else the other + // counts, else the next run of a scheduled-only card. Nothing shortens the card. + const bottomLine = (scheme: Scheme) => { + if (notice !== null) { + return text(notice, 13, scheme.warn, 'semibold'); + } + if (others.length > 0) { + return text(others, 13, scheme.muted); + } + if (scheduledAt !== null && Number.isFinite(scheduledAt.getTime())) { + return ( + <HStack spacing={3}> + {text(COPY.nextRun ?? 'Next run', 13, scheme.muted)} + <Text + date={scheduledAt} + dateStyle="time" + modifiers={[ + font({ size: 13 }), + lineLimit(1), + monospacedDigit(), + foregroundStyle(scheme.muted), + ]} + /> + </HStack> + ); + } + return null; + }; + const cardLine = bottomLine(card); + const islandLine = bottomLine(island); return { + // Lock Screen: header @16, status row centred @74, line @118 (13pt). banner: ( <VStack - spacing={6} + alignment="leading" + spacing={0} modifiers={[ - // The banner draws to its own rounded edge, so without an inset the - // top-left corner clips the leading content. - padding({ all: 'default' }), + padding({ top: 16, leading: 16, trailing: 16, bottom: cardLine === null ? 12.4 : 18.9 }), + activityBackgroundTint(palette.bg), // The widget process takes its locale from the device language, so - // without this the relative wait would be formatted in a different - // language than the baked labels. + // without this the times would be formatted in a different language + // than the baked labels. environment({ key: 'locale', value: locale }), ]} > - <HStack> - {markAndRows(26, true)} - {actions} + {header(card)} + <HStack alignment="center" spacing={0} modifiers={[padding({ top: 18.7 })]}> + {statusBlock(card)} + <Spacer minLength={10} /> + {approveControl(card)} </HStack> - {noticeLine} + {cardLine === null ? null : ( + <VStack alignment="leading" spacing={0} modifiers={[padding({ top: 9.9 })]}> + {cardLine} + </VStack> + )} </VStack> ), // The Apple Watch and CarPlay small family draws this section, not the - // phone `banner`: expo-widgets' banner view prefers the `bannerSmall` node - // whenever the activity family is `.small`, falling back to `banner` - // otherwise, so the phone Lock Screen and Dynamic Island never read it. One - // compact row plus the Approve control, so the wait count and the wrist - // control fit the small region instead of the phone's stacked block. The - // ranked primary row only — the small family draws one line — and no - // relative wait: the medium banner and the accessory rectangle carry it. The - // trailing Spacer pins the row to the leading edge, so the count keeps its - // place when the control appears and disappears, the way the phone block is - // spaced; the row draws unconditionally, and only the control is gated, by - // the same `canApprove` the phone block uses. The notice has a reserved row - // below, so a failed press cannot move the count or its retry control. + // phone `banner`: one compact status row plus the Approve control, and the + // failure line in a reserved row below so a failed press cannot move the + // count or its retry control. bannerSmall: ( <VStack alignment="leading" spacing={6}> <HStack alignment="center" spacing={10}> - <HStack - alignment="center" - spacing={7} - modifiers={[ - // The combined label sits on the count row alone, so VoiceOver on - // the watch can still focus and activate the Approve button - // separately. - accessibilityElement('combine'), - accessibilityLabel(accessibility), - ]} - > - {hasCounts ? ( - countRow(primary, true, false) - ) : ( - <Text modifiers={[font({ textStyle: 'subheadline' }), mutedForeground]}> - {statusLine} - </Text> - )} - </HStack> - {canApprove ? ( - // The target is the literal, not the imported `APPROVE_TARGET`: this - // function's source is stringified and re-evaluated in the widget - // process, where an imported binding is an undefined global. - // `layout-copy.test.ts` keeps it equal to the constant the interaction - // handler matches. - <Button label={COPY.approve} target="approve" /> - ) : null} + {statusBlock({ ...island, countSize: 20 })} + {canApprove && !approving ? <Button label={COPY.approve} target="approve" /> : null} <Spacer /> </HStack> - <VStack modifiers={[frame({ height: 18 })]}>{noticeLine}</VStack> + <VStack modifiers={[frame({ height: 18 })]}> + {notice === null ? null : text(notice, 13, island.warn, 'semibold')} + </VStack> </VStack> ), - // The Dynamic Island's leading slot is the app-identity slot, so it holds - // the Kilo mark; the trailing slot carries the ranked count. - compactLeading: <HStack modifiers={[accessibilityLabel(accessibility)]}>{logo(18)}</HStack>, - // One number, colored by the state it counts: orange needs input, green - // working, white idle. - compactTrailing: ( - <Text - modifiers={[ - font({ textStyle: 'title3', weight: 'bold' }), - monospacedDigit(), - primary === null ? mutedForeground : foregroundStyle(primary.color), - accessibilityLabel(accessibility), - ]} - > - {hasCounts ? primaryCount : ''} - </Text> - ), - minimal: ( - <Text - modifiers={[ - font({ textStyle: 'headline', weight: 'bold' }), - monospacedDigit(), - primary === null ? mutedForeground : foregroundStyle(primary.color), - accessibilityLabel(accessibility), - ]} - > - {hasCounts ? primaryCount : ''} - </Text> - ), - // The whole expanded island is the bottom region: it is the only one wide - // enough for a labelled row, and it clears the rounded corners that clip - // the flanking regions. The leading and trailing regions stay empty and - // take no height. The row carries its own combined label on the count block - // and none sits here, so the Approve control inside it stays focusable - // instead of being merged into the island's spoken label. + // Compact: the tile leading, the dot and count trailing. + compactLeading: tile(18, island.tile, island.inner), + compactTrailing: + primary === null ? null : ( + <HStack + alignment="center" + spacing={6} + modifiers={[accessibilityElement('combine'), accessibilityLabel(accessibility)]} + > + {dot(8, island.dots[primary.kind])} + <Text + modifiers={[ + font({ size: 15, weight: 'bold' }), + monospacedDigit(), + lineLimit(1), + foregroundStyle(island.fg), + ]} + > + {count(primary.count)} + </Text> + </HStack> + ), + // Minimal: the dot and the count, capped at 99+. + minimal: + primary === null ? null : ( + <HStack + alignment="center" + spacing={7} + modifiers={[accessibilityElement('combine'), accessibilityLabel(accessibility)]} + > + {dot(7, island.dots[primary.kind])} + <Text + modifiers={[ + font({ size: 14, weight: 'bold' }), + monospacedDigit(), + lineLimit(1), + foregroundStyle(island.fg), + ]} + > + {primary.count > 99 ? `${count(99)}+` : count(primary.count)} + </Text> + </HStack> + ), + // Expanded: the Lock Screen card on black at count 32 and label 15. The + // island's rounded corner cuts into the leading edge, so the content keeps + // an inset on both sides. expandedBottom: ( <VStack - spacing={6} + alignment="leading" + spacing={0} modifiers={[ - // The island's rounded corner cuts into the leading edge, so the - // mark needs an inset the banner gets from its own padding. The - // trailing edge needs the same inset now that the buttons end there. - padding({ vertical: 2, leading: 14, trailing: 14 }), + padding({ leading: 14, trailing: 14, bottom: islandLine === null ? 4 : 10 }), environment({ key: 'locale', value: locale }), ]} > - <HStack> - {markAndRows(24, false)} - {actions} + {header(island)} + <HStack alignment="center" spacing={0} modifiers={[padding({ top: 15.5 })]}> + {statusBlock(island)} + <Spacer minLength={10} /> + {approveControl(island)} </HStack> - {noticeLine} + {islandLine === null ? null : ( + <VStack alignment="leading" spacing={0} modifiers={[padding({ top: 13.9 })]}> + {islandLine} + </VStack> + )} </VStack> ), }; @@ -519,12 +515,9 @@ export const LIVE_ACTIVITY_NAME = 'ActiveAgentsLiveActivity'; */ export const OPEN_AGENTS_URL = 'kiloapp:///cloud/sessions'; +export const activeAgentsLiveActivityLayout = layout; const registerLayout = () => - createLiveActivity<ContentState>( - LIVE_ACTIVITY_NAME, - withGlanceableCopy(withWidgetLogo(layout)), - OPEN_AGENTS_URL - ); + createLiveActivity<ContentState>(LIVE_ACTIVITY_NAME, withGlanceableCopy(layout), OPEN_AGENTS_URL); export const ActiveAgentsLiveActivity = registerLayout(); diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.test.ts b/apps/mobile/src/glanceable-ios/active-agents-widget.test.ts new file mode 100644 index 0000000000..f9597cab5f --- /dev/null +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.test.ts @@ -0,0 +1,754 @@ +/* eslint-disable max-lines -- one suite per Home family and state, sharing the swift-ui recording harness and the vertical layout model */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildGlanceableSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; + +import { type GlanceableActionFeedback, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; + +import { type WidgetFamily } from 'expo-widgets'; + +import { activeAgentsWidgetLayout } from './active-agents-widget'; +import { buildGlanceableViewProps, type GlanceableWidgetProps } from './view-props'; + +/** A swift-ui primitive stand-in: the kind tag rides on the function itself. */ +function mockComponent(kind: string) { + return Object.assign((props: Record<string, unknown>) => ({ kind, props }), { kind }); +} + +/** A recording swift-ui modifier stub. */ +function mockModifier(name: string) { + return (args?: unknown) => ({ $type: name, args }); +} + +vi.mock('expo-widgets', () => ({ createWidget: () => ({}) })); +vi.mock('@expo/ui/swift-ui', () => ({ + AccessoryWidgetBackground: mockComponent('AccessoryWidgetBackground'), + Button: mockComponent('Button'), + Circle: mockComponent('Circle'), + HStack: mockComponent('HStack'), + Image: mockComponent('Image'), + Rectangle: mockComponent('Rectangle'), + RoundedRectangle: mockComponent('RoundedRectangle'), + Spacer: mockComponent('Spacer'), + Text: mockComponent('Text'), + VStack: mockComponent('VStack'), + ZStack: mockComponent('ZStack'), +})); +vi.mock('@expo/ui/swift-ui/modifiers', () => ({ + accessibilityElement: mockModifier('accessibilityElement'), + accessibilityLabel: mockModifier('accessibilityLabel'), + aspectRatio: mockModifier('aspectRatio'), + background: (style: unknown, shape?: unknown) => ({ + $type: 'background', + args: { style, shape }, + }), + buttonStyle: mockModifier('buttonStyle'), + containerBackground: (style: unknown) => ({ $type: 'containerBackground', args: style }), + environment: mockModifier('environment'), + fixedSize: mockModifier('fixedSize'), + font: mockModifier('font'), + foregroundStyle: mockModifier('foregroundStyle'), + frame: mockModifier('frame'), + layoutPriority: mockModifier('layoutPriority'), + lineLimit: mockModifier('lineLimit'), + monospacedDigit: mockModifier('monospacedDigit'), + multilineTextAlignment: mockModifier('multilineTextAlignment'), + opacity: mockModifier('opacity'), + padding: mockModifier('padding'), + resizable: mockModifier('resizable'), + shapes: { + capsule: () => ({ shape: 'capsule' }), + circle: () => ({ shape: 'circle' }), + roundedRectangle: (params: object) => ({ shape: 'roundedRectangle', ...params }), + }, + widgetURL: mockModifier('widgetURL'), +})); +vi.mock('@/i18n', () => ({ i18n: { on: vi.fn(), t: (key: string) => key } })); + +afterEach(() => { + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); +}); + +const NOW = 1_750_000_000_000; +const MINUTE = 60_000; +const APPROVAL_KEY = 'a'.repeat(64); +const ENGLISH: Record<string, string> = { + 'glanceable.needsInput': 'Needs input', + 'common.working': 'Working', + 'common.scheduled': 'Scheduled', + 'common.idle': 'Idle', +}; +const translate = (key: string) => ENGLISH[key] ?? key; + +type Options = { + feedback?: GlanceableActionFeedback; + status?: 'waiting' | 'empty' | 'signed_out' | 'privacy' | 'stale'; + now?: number; +}; + +function propsFor(sessions: HomeWidgetSessionRow[], options: Options = {}): GlanceableWidgetProps { + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: options.feedback ?? null }); + const input = { + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + ...(options.status === undefined ? {} : { status: options.status }), + }; + const snapshot = buildGlanceableSnapshot(input); + const flags = options.status === 'signed_out' ? { signedOut: true } : {}; + return buildGlanceableViewProps( + snapshot, + flags, + translate, + buildHomeWidgetData(input), + options.now ?? NOW + ); +} + +type Rendered = { kind: string; props: Record<string, unknown> }; +type Modifier = { $type: string; args: Record<string, unknown> | undefined }; + +function render( + props: GlanceableWidgetProps, + family: WidgetFamily, + { colorScheme = 'light', at = NOW }: { colorScheme?: 'light' | 'dark'; at?: number } = {} +): Rendered { + return activeAgentsWidgetLayout(props, { + widgetFamily: family, + date: new Date(at), + colorScheme, + configuration: undefined, + }) as unknown as Rendered; +} + +function element(node: unknown): Rendered | null { + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + if (node === null || typeof node !== 'object' || !('type' in node) || !('props' in node)) { + return null; + } + const { type, props } = node; + if ( + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + typeof type !== 'function' || + !('kind' in type) || + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + typeof type.kind !== 'string' || + props === null || + // eslint-disable-next-line anti-slop/no-runtime-typeof -- walks the untyped JSX element tree + typeof props !== 'object' + ) { + return null; + } + // A JSX element's props object; every reader below narrows the field it uses. + const record = props as Record<string, unknown>; + return { kind: type.kind, props: record }; +} + +function children(node: Rendered | undefined): Rendered[] { + const raw = node?.props.children; + return (Array.isArray(raw) ? raw.flat(Infinity) : [raw]) + .map(child => element(child)) + .filter((child): child is Rendered => child !== null); +} + +function collect(node: Rendered): Rendered[] { + return [node, ...children(node).flatMap(child => collect(child))]; +} + +function texts(tree: Rendered): string[] { + return collect(tree) + .filter(node => node.kind === 'Text' && typeof node.props.children === 'string') + .map(node => node.props.children as string); +} + +function modifiers(node: Rendered | undefined): Modifier[] { + return Array.isArray(node?.props.modifiers) ? (node.props.modifiers as Modifier[]) : []; +} + +function modifier(node: Rendered | undefined, type: string): Record<string, unknown> | undefined { + return modifiers(node).find(entry => entry.$type === type)?.args; +} + +function textNode(tree: Rendered, value: string): Rendered | undefined { + return collect(tree).find(node => node.kind === 'Text' && node.props.children === value); +} + +function buttons(tree: Rendered): Record<string, unknown>[] { + return collect(tree) + .filter(node => node.kind === 'Button' && typeof node.props.onPress === 'function') + .map(node => (node.props.onPress as () => Record<string, unknown>)()); +} + +// ── a vertical layout model ───────────────────────────────────────────────── +// SwiftUI's line box for SF Pro is 1.193em with the baseline 0.952em down. The +// model stacks the rendered tree the way the stacks lay it out at the design's +// reference heights, so each test can read where a baseline lands. + +const SIZE: Record<string, [number, number]> = { + systemSmall: [170, 170], + systemMedium: [364, 170], + systemLarge: [364, 382], +}; + +type Baseline = { text: string; baseline: number }; + +function fontSize(node: Rendered | undefined): number { + return (modifier(node, 'font')?.size as number | undefined) ?? 17; +} + +function fixedHeight(node: Rendered): number | undefined { + const frames = modifiers(node).filter(entry => entry.$type === 'frame'); + for (const frame of frames.toReversed()) { + if (typeof frame.args?.height === 'number') { + return frame.args.height; + } + } + return undefined; +} + +function isFill(node: Rendered): boolean { + return node.kind === 'Spacer' && modifiers(node).every(entry => entry.$type !== 'frame'); +} + +function place(node: Rendered, top: number, out: Baseline[]): number { + const offset = (modifier(node, 'padding')?.top as number | undefined) ?? 0; + const start = top + offset; + const fixed = fixedHeight(node); + let height = 0; + if (node.kind === 'Text') { + const size = fontSize(node); + height = 1.193 * size; + const label = + typeof node.props.children === 'string' + ? node.props.children + : `<${String(node.props.dateStyle)}>`; + out.push({ text: label, baseline: start + ((fixed ?? height) - height) / 2 + 0.952 * size }); + } else if (node.kind === 'Spacer') { + height = (modifier(node, 'frame')?.maxHeight as number | undefined) ?? 0; + } else if (node.kind === 'VStack') { + const spacing = typeof node.props.spacing === 'number' ? node.props.spacing : 0; + let y = start; + for (const [index, child] of children(node).entries()) { + const before = index === 0 ? 0 : spacing; + y += before + place(child, y + before, out); + } + height = y - start; + } else if (node.kind === 'HStack' || node.kind === 'ZStack' || node.kind === 'Button') { + const parts = children(node); + const heights = parts.map(child => place(child, 0, [])); + height = Math.max(0, ...heights); + const box = fixed ?? height; + for (const [index, child] of parts.entries()) { + const aligned = node.props.alignment === 'top' ? 0 : (box - (heights[index] ?? 0)) / 2; + place(child, start + aligned, out); + } + } + return offset + (fixed ?? height); +} + +/** Baselines of every text in a Home card at the design's reference size. */ +function baselines(tree: Rendered, family: WidgetFamily): Baseline[] { + const [, height] = SIZE[family] ?? [0, 0]; + const top = 12; + const bottom = height - 15; + const segments: Rendered[][] = [[]]; + for (const child of children(tree)) { + if (isFill(child)) { + segments.push([]); + } else { + segments.at(-1)?.push(child); + } + } + const out: Baseline[] = []; + const measure = (nodes: Rendered[]) => nodes.reduce((sum, node) => sum + place(node, 0, []), 0); + const stack = (nodes: Rendered[], from: number) => + nodes.reduce((y, node) => y + place(node, y, out), from); + const head = segments[0] ?? []; + const headEnd = stack(head, top); + if (segments.length === 1) { + return out; + } + const tail = segments.at(-1) ?? []; + const tailStart = bottom - measure(tail); + stack(tail, tailStart); + for (const middle of segments.slice(1, -1)) { + stack(middle, headEnd + (tailStart - headEnd - measure(middle)) / 2); + } + return out; +} + +const TOLERANCE = 0.75; + +function expectBaselines(tree: Rendered, family: WidgetFamily, expected: [string, number][]) { + const placed = baselines(tree, family); + for (const [text, baseline] of expected) { + const match = placed.find(entry => entry.text === text); + expect(match, `${family}: "${text}" placed`).toBeDefined(); + expect( + Math.abs((match?.baseline ?? 0) - baseline), + `${family}: "${text}" at ${match?.baseline} vs ${baseline}` + ).toBeLessThanOrEqual(TOLERANCE); + } +} + +const PERMISSION: HomeWidgetSessionRow = { + status: 'permission', + title: 'Migrate the billing webhooks', + approvalKey: APPROVAL_KEY, + statusUpdatedAt: new Date(NOW - 10 * MINUTE).toISOString(), +}; +const QUESTION: HomeWidgetSessionRow = { + status: 'question', + title: 'Pick a color for the badge', + statusUpdatedAt: new Date(NOW - 5 * MINUTE).toISOString(), +}; +const RETRY: HomeWidgetSessionRow = { + status: 'retry', + title: 'Recover the interrupted build', + statusUpdatedAt: new Date(NOW - 4 * MINUTE).toISOString(), +}; +const BUSY: HomeWidgetSessionRow = { status: 'busy', title: 'Fix the flaky login test' }; +const IDLE: HomeWidgetSessionRow = { status: 'idle', title: 'Review the onboarding copy' }; +const scheduled = (title: string, at: number | null): HomeWidgetSessionRow => ({ + status: 'scheduled', + title, + ...(at === null ? {} : { scheduledAt: new Date(at).toISOString() }), +}); +const HOME_FAMILIES: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge']; +const ACCESSORY_FAMILIES: WidgetFamily[] = [ + 'accessoryCircular', + 'accessoryInline', + 'accessoryRectangular', +]; + +describe('Home header', () => { + it.each(HOME_FAMILIES)('draws the tile, Kilo, and a fixed trailing + slot in %s', family => { + for (const props of [ + propsFor([PERMISSION, BUSY]), + propsFor([], { status: 'empty' }), + propsFor([], { status: 'waiting' }), + propsFor([], { status: 'privacy' }), + ]) { + const header = children(render(props, family))[0]; + if (header === undefined) { + throw new Error('no header'); + } + expect(fixedHeight(header)).toBe(24); + const parts = children(header); + expect(parts[0]?.kind).toBe('RoundedRectangle'); + expect(parts[1]?.props.children).toBe('Kilo'); + // `+` always owns the last 24pt slot, so a hidden action never moves it. + expect(modifier(parts.at(-1), 'frame')).toEqual({ width: 24, height: 24 }); + } + }); + + it('paints the approved light and dark palettes', () => { + const background = (scheme: 'light' | 'dark') => + modifier( + render(propsFor([BUSY]), 'systemSmall', { colorScheme: scheme }), + 'containerBackground' + ); + expect(background('light')).toBe('#FBFAF5'); + expect(background('dark')).toBe('#17171A'); + const dot = (rows: HomeWidgetSessionRow[]) => + collect(render(propsFor(rows), 'systemSmall')).find(node => node.kind === 'Circle'); + expect(modifier(dot([PERMISSION]), 'foregroundStyle')).toBe('#956011'); + expect(modifier(dot([BUSY]), 'foregroundStyle')).toBe('#24784A'); + expect(modifier(dot([scheduled('Usage report', null)]), 'foregroundStyle')).toBe('#2260EB'); + expect(modifier(dot([IDLE]), 'foregroundStyle')).toBe('#A9A39A'); + }); +}); + +describe('needs input', () => { + it('places the small card per round 2', () => { + const tree = render(propsFor([PERMISSION, QUESTION]), 'systemSmall'); + expectBaselines(tree, 'systemSmall', [ + ['2', 78], + ['Needs input', 98], + ['Migrate the billing webhooks', 117], + ['Checked', 152], + ]); + expect(buttons(tree)).toContainEqual( + expect.objectContaining({ pendingAction: 'approve', pendingApprovalKey: APPROVAL_KEY }) + ); + }); + + it('places the medium card with two waiting rows', () => { + const tree = render(propsFor([PERMISSION, QUESTION, RETRY]), 'systemMedium'); + expectBaselines(tree, 'systemMedium', [ + ['3', 92], + ['Needs input', 112], + ['Migrate the billing webhooks', 74], + ['Permission required', 90], + ['Pick a color for the badge', 116], + ['Answer needed', 132], + ['Checked', 152], + ]); + expect(texts(tree)).not.toContain('Recover the interrupted build'); + expect(texts(tree)).toContain('Approve'); + }); + + it('places the large card with counts, Waiting for you, and three rows', () => { + const tree = render( + propsFor([PERMISSION, QUESTION, RETRY, BUSY, IDLE, scheduled('Usage report', null)]), + 'systemLarge' + ); + expectBaselines(tree, 'systemLarge', [ + ['3', 106], + ['Needs input', 131], + ['1 Working', 74], + ['1 Scheduled', 96], + ['1 Idle', 118], + ['Waiting for you', 176], + ['Migrate the billing webhooks', 202], + ['Permission required', 219], + ['Pick a color for the badge', 248], + ['Recover the interrupted build', 294], + ['Waiting to retry', 311], + ['Checked', 364], + ]); + }); + + it.each(HOME_FAMILIES)('offers no Approve for a question or retry in %s', family => { + const tree = render(propsFor([QUESTION, RETRY]), family); + expect(buttons(tree).some(press => press.pendingAction === 'approve')).toBe(false); + expect(buttons(tree)).toContainEqual(expect.objectContaining({ pendingAction: 'new-agent' })); + }); + + it.each(HOME_FAMILIES)('shows Approving… in the Approve slot without a button in %s', family => { + const tree = render(propsFor([PERMISSION], { feedback: 'approving' }), family); + expect(buttons(tree).some(press => press.pendingAction === 'approve')).toBe(false); + if (family === 'systemSmall') { + expect(collect(tree).some(node => node.props.systemName === 'ellipsis')).toBe(true); + } else { + expect(texts(tree)).toContain('Approving…'); + } + }); + + it.each(HOME_FAMILIES)('keeps Approve and says it failed in %s', family => { + const tree = render(propsFor([PERMISSION], { feedback: 'couldNotApprove' }), family); + expect(buttons(tree)).toContainEqual(expect.objectContaining({ pendingAction: 'approve' })); + if (family === 'systemSmall') { + // The failure replaces the detail line; the footer keeps the checked time. + expectBaselines(tree, family, [ + ['Could not approve', 117], + ['Checked', 152], + ]); + } else { + // The failure replaces the footer. + const footer = "Couldn't approve. Tap Approve to try again."; + expectBaselines(tree, family, [[footer, family === 'systemLarge' ? 364 : 152]]); + expect(texts(tree)).not.toContain('Checked'); + expect(modifier(textNode(tree, footer), 'foregroundStyle')).toBe('#956011'); + } + }); +}); + +describe('working, idle, mixed', () => { + it('names the newest working agent under the count in small', () => { + expectBaselines(render(propsFor([BUSY]), 'systemSmall'), 'systemSmall', [ + ['1', 78], + ['Working', 98], + ['Fix the flaky login test', 117], + ]); + }); + + it('prints Recent and the title in the medium column when nothing else counts', () => { + expectBaselines(render(propsFor([IDLE]), 'systemMedium'), 'systemMedium', [ + ['1', 92], + ['Idle', 112], + ['Recent', 88], + ['Review the onboarding copy', 106], + ]); + }); + + it('lists the other counts in the medium column when mixed', () => { + const tree = render(propsFor([BUSY, IDLE, scheduled('Usage report', null)]), 'systemMedium'); + expectBaselines(tree, 'systemMedium', [ + ['1 Scheduled', 78], + ['1 Idle', 102], + ]); + expect(texts(tree)).not.toContain('Recent'); + }); + + it('sizes the one-kind large count to 88pt with Recent below the rule', () => { + const tree = render(propsFor([BUSY]), 'systemLarge'); + expectBaselines(tree, 'systemLarge', [ + ['1', 168], + ['Working', 203], + ['Recent', 268], + ['Fix the flaky login test', 292], + ['Checked', 364], + ]); + expect(fontSize(textNode(tree, '1'))).toBe(88); + }); + + it('places the mixed large card with Next scheduled', () => { + const tree = render( + propsFor([BUSY, IDLE, scheduled('Usage report', NOW + 60 * MINUTE)]), + 'systemLarge' + ); + expectBaselines(tree, 'systemLarge', [ + ['1', 120], + ['Working', 145], + ['1 Scheduled', 92], + ['1 Idle', 116], + ['Recent', 210], + ['Fix the flaky login test', 234], + ['Next scheduled', 284], + ['Usage report', 308], + ['<time>', 326], + ]); + }); +}); + +describe('scheduled', () => { + const later = (days: number) => NOW + days * 24 * 60 * MINUTE; + + it('prints Next run and today’s clock time in small and the rows in medium', () => { + const rows = [ + scheduled('Usage report', NOW + 90 * MINUTE), + scheduled('Audit', NOW + 120 * MINUTE), + ]; + const small = render(propsFor(rows), 'systemSmall'); + expectBaselines(small, 'systemSmall', [ + ['Scheduled', 98], + ['Next run', 117], + ]); + expect(collect(small).some(node => node.props.dateStyle === 'time')).toBe(true); + expectBaselines(render(propsFor(rows), 'systemMedium'), 'systemMedium', [ + ['Usage report', 74], + ['Audit', 116], + ]); + }); + + it('adds a locale date on a later day, with no Today or Tomorrow word', () => { + const tree = render(propsFor([scheduled('Dependency audit', later(3))]), 'systemMedium'); + const line = texts(tree).find(text => /\d/.test(text) && text !== '1'); + expect(line).toMatch(/\d{1,2}:\d{2}/); + expect(texts(tree).join(' ')).not.toMatch(/Today|Tomorrow|ago/); + }); + + it('marks a passed wake Awaiting update in muted copy', () => { + const props = propsFor([scheduled('Dependency audit', NOW + MINUTE)], { + now: NOW + 2 * MINUTE, + }); + for (const family of HOME_FAMILIES) { + const tree = render(props, family, { at: NOW + 2 * MINUTE }); + const awaiting = textNode(tree, 'Awaiting update'); + expect(awaiting, family).toBeDefined(); + expect(modifier(awaiting, 'foregroundStyle')).toBe('#6F6A61'); + expect(texts(tree)).not.toContain('Working'); + } + }); + + it('shows nothing for an unknown time: the title in small, no detail in the rows', () => { + const rows = [scheduled('Dependency audit', null), scheduled('Usage report', null)]; + expectBaselines(render(propsFor(rows), 'systemSmall'), 'systemSmall', [ + ['Dependency audit', 117], + ]); + const medium = render(propsFor(rows), 'systemMedium'); + // The rows keep their pitch with an empty detail slot. + expectBaselines(medium, 'systemMedium', [ + ['Dependency audit', 74], + ['Usage report', 116], + ]); + expect(texts(medium)).not.toContain('Awaiting update'); + }); + + it('places the large scheduled card per round 4', () => { + const tree = render( + propsFor([ + scheduled('Usage report', NOW + 60 * MINUTE), + scheduled('Dependency audit', NOW + 120 * MINUTE), + scheduled('Weekly digest', later(2)), + ]), + 'systemLarge' + ); + expectBaselines(tree, 'systemLarge', [ + ['3', 128], + ['Scheduled', 158], + ['Next run', 182], + ['Next scheduled', 232], + ['Usage report', 258], + ['Dependency audit', 292], + ['Weekly digest', 326], + ]); + expect(fontSize(textNode(tree, '3'))).toBe(80); + }); +}); + +describe('nothing running, updating, last known', () => { + it('keeps + on the small card and moves create into a pill on medium and large', () => { + const props = propsFor([], { status: 'empty' }); + expectBaselines(render(props, 'systemSmall'), 'systemSmall', [ + ['Nothing running right now', 124], + ['Checked', 152], + ]); + expectBaselines(render(props, 'systemMedium'), 'systemMedium', [ + ['Nothing running right now', 92], + ['New agent', 125], + ['Checked', 152], + ]); + expectBaselines(render(props, 'systemLarge'), 'systemLarge', [ + ['Nothing running right now', 176], + ['New agent', 221], + ]); + for (const family of HOME_FAMILIES) { + const presses = buttons(render(props, family)); + // One create control per card. + expect(presses).toEqual([expect.objectContaining({ pendingAction: 'new-agent' })]); + const header = children(render(props, family))[0]; + expect(children(children(header).at(-1))).toHaveLength(family === 'systemSmall' ? 1 : 0); + } + }); + + it.each(HOME_FAMILIES)('draws placeholder bars and no actions while updating in %s', family => { + const tree = render(propsFor([], { status: 'waiting' }), family); + expect(buttons(tree)).toEqual([]); + expect(texts(tree)).toEqual(['Kilo', 'Updating agents']); + expect(collect(tree).filter(node => node.kind === 'RoundedRectangle').length).toBeGreaterThan( + 2 + ); + expectBaselines(tree, family, [['Updating agents', family === 'systemLarge' ? 364 : 152]]); + }); + + it.each(HOME_FAMILIES)( + 'keeps last known work with its time and no relative age in %s', + family => { + const tree = render(propsFor([PERMISSION], { now: NOW + 24 * 60 * MINUTE }), family); + expect(texts(tree)).toContain('Last known ·'); + expect(texts(tree)).toContain('1'); + expect(collect(tree).some(node => node.props.dateStyle === 'ago')).toBe(false); + } + ); +}); + +describe('locked', () => { + it.each([ + ['privacy', 'Open Kilo to', 'see agents'], + ['signed_out', 'Sign in to', 'see agents'], + ] as const)('centres the lock and a balanced message for %s', (status, first, second) => { + const props = propsFor([PERMISSION], { status }); + const small = render(props, 'systemSmall'); + expect(texts(small)).toEqual(['Kilo', first, second]); + expectBaselines(small, 'systemSmall', [ + [first, 108], + [second, 125], + ]); + const full = `${first} ${second}`; + expectBaselines(render(props, 'systemMedium'), 'systemMedium', [[full, 118]]); + expectBaselines(render(props, 'systemLarge'), 'systemLarge', [[full, 236]]); + for (const family of HOME_FAMILIES) { + const tree = render(props, family); + expect(buttons(tree)).toEqual([]); + expect(texts(tree)).not.toContain('Checked'); + expect(collect(tree).some(node => node.props.systemName === 'lock.fill')).toBe(true); + } + }); +}); + +describe('stress', () => { + it('groups a four-digit count, keeps it whole, and truncates long titles to one line', () => { + const title = 'Untangle the payment reconciliation job that keeps timing out'; + const props = propsFor(Array.from({ length: 1234 }, () => ({ ...PERMISSION, title }))); + for (const family of HOME_FAMILIES) { + const tree = render(props, family); + const countNode = textNode(tree, '1,234'); + expect(countNode, family).toBeDefined(); + expect(modifier(countNode, 'fixedSize')).toEqual({ + horizontal: true, + vertical: false, + }); + expect(modifier(textNode(tree, title), 'lineLimit')).toBe(1); + expect( + collect(tree).some(node => + modifiers(node).some(entry => entry.$type === 'minimumScaleFactor') + ) + ).toBe(false); + } + }); + + it.each(HOME_FAMILIES)('names an untitled agent in %s', family => { + const tree = render(propsFor([{ status: 'question', title: '' }]), family); + expect(texts(tree)).toContain('Agent'); + }); + + it('contains malformed persisted props without throwing in any family', () => { + const corrupt = { + countLines: [null, { kind: 'unknown', count: 'bad', label: {} }], + primaryKind: 'unknown', + primaryLabel: {}, + primaryCount: Number.NaN, + actionFeedback: 7, + home: { + status: 'unknown', + secondaryCounts: [null, { kind: 'unknown' }], + waitingAgents: [null], + scheduledAgents: {}, + checkedAt: 'bad', + scheduledAt: 'bad', + }, + } as unknown as GlanceableWidgetProps; + for (const family of [...HOME_FAMILIES, ...ACCESSORY_FAMILIES]) { + expect(() => render(corrupt, family)).not.toThrow(); + } + }); +}); + +describe('Lock Screen accessories', () => { + it('stays privacy-minimal and monochrome with no buttons', () => { + const props = propsFor([PERMISSION, BUSY, scheduled('Usage report', null)]); + for (const family of ACCESSORY_FAMILIES) { + const tree = render(props, family); + expect(buttons(tree)).toEqual([]); + expect(texts(tree)).not.toContain('Migrate the billing webhooks'); + const colors = collect(tree) + .flatMap(node => modifiers(node)) + .filter(entry => entry.$type === 'foregroundStyle') + .map(entry => entry.args as unknown as string); + expect(colors.every(color => color === '#FFFFFF' || color === '#000000')).toBe(true); + } + }); + + it('prints the primary count and the others in the rectangular', () => { + const tree = render( + propsFor([PERMISSION, BUSY, scheduled('Usage report', null)]), + 'accessoryRectangular' + ); + expect(texts(tree)).toEqual(['Kilo', '1 Needs input', '1 Working · 1 Scheduled']); + const others = textNode(tree, '1 Working · 1 Scheduled'); + expect(modifier(others, 'opacity')).toBe(0.62); + }); + + it('draws the symbol over the count in the circular and symbol + count in the inline', () => { + const props = propsFor([PERMISSION]); + const circular = render(props, 'accessoryCircular'); + expect(collect(circular).map(node => node.kind)).toContain('AccessoryWidgetBackground'); + expect(collect(circular).find(node => node.kind === 'Image')?.props.systemName).toBe( + 'exclamationmark.circle' + ); + expect(texts(circular)).toEqual(['1']); + expect(texts(render(props, 'accessoryInline'))).toEqual(['1 Needs input']); + }); + + it('draws a state glyph, never a placeholder dash, when nothing counts', () => { + const expected = { + waiting: 'arrow.triangle.2.circlepath', + empty: 'checkmark.circle.fill', + privacy: 'lock.fill', + signed_out: 'person.fill', + } as const; + for (const status of Object.keys(expected) as (keyof typeof expected)[]) { + const tree = render(propsFor([], { status }), 'accessoryCircular'); + expect( + collect(tree) + .filter(node => node.kind === 'Image') + .map(node => node.props.systemName) + ).toEqual([expected[status]]); + expect(texts(tree)).not.toContain('—'); + } + }); +}); diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index 0f4b38517e..742ea212d5 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -1,30 +1,51 @@ /* eslint-disable max-lines -- the 'widget' layout function is stringified whole, so its helpers cannot be extracted to module scope; every family stays in one function */ -import { Button, type ButtonProps, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { + AccessoryWidgetBackground, + Button, + type ButtonProps, + Circle, + HStack, + Image, + type ImageProps, + Rectangle, + RoundedRectangle, + Spacer, + Text, + VStack, + ZStack, +} from '@expo/ui/swift-ui'; import { accessibilityElement, accessibilityLabel, + aspectRatio, + background, buttonStyle, containerBackground, - controlSize, - cornerRadius, environment, + fixedSize, font, foregroundStyle, frame, layoutPriority, lineLimit, monospacedDigit, + multilineTextAlignment, + opacity, + padding, resizable, + shapes, widgetURL, } from '@expo/ui/swift-ui/modifiers'; import { createWidget, type WidgetEnvironment } from 'expo-widgets'; -import { PlatformColor } from 'react-native'; -import { withGlanceableCopy } from './layout-copy'; -import { type GlanceableWidgetAction, type GlanceableWidgetProps } from './view-props'; -import { withWidgetLogo } from './widget-logo'; +import { i18n } from '@/i18n'; -/* eslint-disable new-cap -- PlatformColor is a React Native factory */ +import { withGlanceableCopy } from './layout-copy'; +import { + buildGalleryPreviewProps, + type GlanceableWidgetAction, + type GlanceableWidgetProps, +} from './view-props'; export type WidgetProps = GlanceableWidgetProps; type WidgetPressPatch = { @@ -40,28 +61,47 @@ type WidgetPressPatch = { }; type WidgetButtonProps = Omit<ButtonProps, 'onPress'> & { onPress?: () => WidgetPressPatch; + /** expo-widgets runs the press intent in the foreground app when set. */ + openAppWhenRun?: boolean; }; +type SystemSymbol = NonNullable<ImageProps['systemName']>; +type Weight = 'regular' | 'semibold' | 'bold'; +/** + * The approved Home widget design (direction A). Every number below is a point + * position or size from the design generators, measured from the widget's top + * left on the 170pt-tall small and medium and the 382pt-tall large card; gaps + * between text lines are derived from the design baselines with SF Pro's line + * metrics so the rendered baselines land where the design put them. + */ // Everything this stringified function uses must be a widget global or a builtin. const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React.JSX.Element => { 'widget'; - // Both literals are replaced after Babel stringifies this function. + // The literal is replaced after Babel stringifies this function. // eslint-disable-next-line typescript-eslint/no-inferrable-types -- replaced source literal const copySource: string = '__KILO_GLANCEABLE_COPY__'; const COPY = JSON.parse(copySource.startsWith('{') ? copySource : '{}') as Record<string, string>; - // eslint-disable-next-line typescript-eslint/no-inferrable-types -- replaced source literal - const logoUri: string = '__KILO_WIDGET_LOGO_URI__'; const digits = COPY.digits ?? ''; + const groupSeparator = COPY.group ?? ','; const count = (value: number) => { - const text = String(value); + // Grouped by hand: the widget process has no number formatter, and a + // global-regex replace is one of the forms that failed there. + const plain = String(value); + let grouped = ''; + for (let index = 0; index < plain.length; index += 1) { + if (index > 0 && (plain.length - index) % 3 === 0) { + grouped += groupSeparator; + } + grouped += plain[index] ?? ''; + } return digits.length === 10 ? // eslint-disable-next-line unicorn/prefer-spread -- `replaceAll` and a spread both failed in the widget process; this form is the one verified on device - text + grouped .split('') .map(character => (/[0-9]/.test(character) ? digits[Number(character)] : character)) .join('') - : text; + : grouped; }; const labels = { needsInput: COPY.needsInput ?? 'Needs input', @@ -69,11 +109,12 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React scheduled: COPY.scheduled ?? 'Scheduled', idle: COPY.idle ?? 'Idle', }; + // Lock Screen glyphs: the accessories are monochrome, so the symbol tells the states apart. const glyphs = { - needsInput: { icon: 'exclamationmark.circle.fill', color: PlatformColor('systemOrange') }, - running: { icon: 'circle.fill', color: PlatformColor('systemGreen') }, - scheduled: { icon: 'clock', color: PlatformColor('label') }, - idle: { icon: 'circle', color: PlatformColor('label') }, + needsInput: 'exclamationmark.circle', + running: 'circle.fill', + scheduled: 'clock', + idle: 'circle', } as const; type Kind = keyof typeof labels; // The widget process reads raw app-group JSON, so these parse each field at that boundary. @@ -99,8 +140,12 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React const date = new Date(value); return Number.isFinite(date.getTime()) ? date : null; }; - const primaryForeground = foregroundStyle(PlatformColor('label')); - const mutedForeground = foregroundStyle(PlatformColor('secondaryLabel')); + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const records = (value: unknown): Record<string, unknown>[] => + (Array.isArray(value) ? (value as unknown[]) : []).filter( + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders + (row): row is Record<string, unknown> => row !== null && typeof row === 'object' + ); const localeModifier = environment({ key: 'locale', value: COPY.locale ?? 'en' }); const bodyURL = widgetURL('kiloapp:///cloud/sessions'); const genericA11y = [ @@ -109,6 +154,10 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React accessibilityLabel(safeText(props.accessibilityLabel) ?? ''), ]; const family = widgetEnvironment.widgetFamily; + // SwiftUI's default line box for SF Pro: ascent 0.952em over descent 0.241em. + // eslint-disable-next-line unicorn/consistent-function-scoping, max-params -- the stringified 'widget' layout cannot hoist helpers to module scope; the gap is between two baselines of two sizes + const gap = (baseline: number, size: number, nextBaseline: number, nextSize: number) => + nextBaseline - 0.952 * nextSize - (baseline + 0.241 * size); // A placed widget can carry props from an older app. This compatibility path // only displays its existing values; freshness policy belongs to the shared builder. @@ -133,38 +182,65 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React ? statusGlyphs[homeStatus as HomeStatusGlyph] : 'circle.fill'; + // ── Lock Screen accessories: monochrome (white and 62% white), no buttons ── // Accessories deliberately ignore Home titles, feedback and retained work. const genericKind = kindOf(props.primaryKind); const genericCount = safeCount(props.primaryCount); const genericStatus = safeText(props.statusLine) ?? COPY.signed_out ?? 'Sign in to see agents'; const genericLabel = safeText(props.primaryLabel) ?? (genericKind === null ? '' : labels[genericKind]); + const white = foregroundStyle('#FFFFFF'); + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const logoTile = (size: number, fill: string, inner: string) => ( + <RoundedRectangle + cornerRadius={1} + modifiers={[ + foregroundStyle(inner), + frame({ width: size * 0.44, height: size * 0.44 }), + frame({ width: size, height: size }), + background(fill, shapes.roundedRectangle({ cornerRadius: size * 0.22 })), + ]} + /> + ); if (family === 'accessoryCircular') { + const value = count(genericCount); + let countSize = 30; + if (value.length > 4) { + countSize = 20; + } else if (value.length > 3) { + countSize = 22; + } else if (value.length > 2) { + countSize = 26; + } return ( - <VStack alignment="center" spacing={0} modifiers={[bodyURL, ...genericA11y]}> - {genericKind === null ? ( - <Image systemName={statusGlyph} size={24} /> - ) : ( - <Image systemName={glyphs[genericKind].icon} size={15} /> - )} - {genericKind === null ? null : ( - <Text - modifiers={[ - font({ textStyle: 'title2', weight: 'bold' }), - monospacedDigit(), - lineLimit(1), - ]} - > - {count(genericCount)} - </Text> - )} - </VStack> + <ZStack modifiers={[bodyURL, ...genericA11y]}> + <AccessoryWidgetBackground /> + <VStack alignment="center" spacing={2}> + {genericKind === null ? ( + <Image systemName={statusGlyph} size={24} color="#FFFFFF" /> + ) : ( + <Image systemName={glyphs[genericKind]} size={13} color="#FFFFFF" /> + )} + {genericKind === null ? null : ( + <Text + modifiers={[ + font({ size: countSize, weight: 'bold' }), + monospacedDigit(), + lineLimit(1), + white, + ]} + > + {value} + </Text> + )} + </VStack> + </ZStack> ); } if (family === 'accessoryInline') { return ( <HStack spacing={4} modifiers={[bodyURL, ...genericA11y]}> - {genericKind === null ? null : <Image systemName={glyphs[genericKind].icon} size={12} />} + {genericKind === null ? null : <Image systemName={glyphs[genericKind]} size={12} />} <Text modifiers={[lineLimit(1)]}> {genericKind === null ? genericStatus : `${count(genericCount)} ${genericLabel}`} </Text> @@ -172,83 +248,142 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React ); } if (family === 'accessoryRectangular') { - const rows = (Array.isArray(props.countLines) ? props.countLines : []) - .filter( + const others = records(props.countLines) + .filter(row => { + const kind = kindOf(row.kind); + return kind !== null && kind !== genericKind && safeCount(row.count) > 0; + }) + .slice(0, 3) + .map( row => - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - row !== null && - // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders - typeof row === 'object' && - kindOf(row.kind) !== null && - safeCount(row.count) > 0 - ) - .slice(0, 2); + `${count(safeCount(row.count))} ${safeText(row.label) ?? labels[kindOf(row.kind) ?? 'idle']}` + ); + // 160x72: the Kilo row (12pt, baseline 16.5), the primary count (15pt bold, + // baseline 40) and the other counts (12pt at 62%, baseline 62), 6pt in. return ( - <VStack alignment="leading" spacing={3} modifiers={[bodyURL, ...genericA11y]}> - <Text modifiers={[font({ textStyle: 'caption', weight: 'semibold' })]}>Kilo</Text> - {rows.length === 0 ? ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(2)]}>{genericStatus}</Text> - ) : ( - rows.map(row => ( - <HStack key={row.kind} spacing={5}> - <Image systemName={glyphs[kindOf(row.kind) ?? 'idle'].icon} size={11} /> - <Text - modifiers={[ - font({ textStyle: 'caption', weight: 'semibold' }), - monospacedDigit(), - layoutPriority(1), - ]} - > - {count(safeCount(row.count))} - </Text> - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1)]}> - {safeText(row.label) ?? labels[kindOf(row.kind) ?? 'idle']} - </Text> - </HStack> - )) + <VStack + alignment="leading" + spacing={0} + modifiers={[ + bodyURL, + ...genericA11y, + padding({ top: 16.5 - 0.952 * 12 - 1.25, leading: 6, trailing: 6 }), + frame({ maxWidth: 10_000, maxHeight: 10_000, alignment: 'topLeading' }), + ]} + > + <HStack alignment="center" spacing={5}> + {logoTile(11, '#FFFFFF', '#000000')} + <Text modifiers={[font({ size: 12, weight: 'semibold' }), lineLimit(1), white]}> + Kilo + </Text> + </HStack> + <HStack + alignment="center" + spacing={4.5} + modifiers={[padding({ top: gap(16.5, 12, 40, 15) })]} + > + <Image + systemName={genericKind === null ? statusGlyph : glyphs[genericKind]} + size={11} + color="#FFFFFF" + /> + <Text + modifiers={[ + font({ size: 15, weight: 'bold' }), + monospacedDigit(), + lineLimit(genericKind === null ? 2 : 1), + white, + ]} + > + {genericKind === null ? genericStatus : `${count(genericCount)} ${genericLabel}`} + </Text> + </HStack> + {others.length === 0 ? null : ( + <Text + modifiers={[ + font({ size: 12 }), + monospacedDigit(), + lineLimit(1), + white, + opacity(0.62), + padding({ top: gap(40, 15, 62, 12) }), + ]} + > + {others.join(' · ')} + </Text> )} </VStack> ); } + // ── Home Screen: systemSmall, systemMedium, systemLarge ── + const palette = + widgetEnvironment.colorScheme === 'dark' + ? { + bg: '#17171A', + fg: '#F2F0EB', + muted: '#8A8680', + warn: '#F2B05F', + good: '#5FCB8E', + info: '#60A5FA', + idle: '#56544F', + primary: '#E8F27A', + primaryFg: '#1A1A10', + secondary: '#26262B', + divider: '#2A2A30', + } + : { + bg: '#FBFAF5', + fg: '#14130F', + muted: '#6F6A61', + warn: '#956011', + good: '#24784A', + info: '#2260EB', + idle: '#A9A39A', + primary: '#4F5A10', + primaryFg: '#FFFFFF', + secondary: '#F0EEE6', + divider: '#E6E3DA', + }; + const dotColors = { + needsInput: palette.warn, + running: palette.good, + scheduled: palette.info, + idle: palette.idle, + }; + const primaryKind = home === undefined ? genericKind : kindOf(home.primaryKind); const primaryCount = home === undefined ? genericCount : safeCount(home.primaryCount); const status = home?.status ?? (genericKind === null ? 'signed_out' : 'content'); const content = status === 'content' && primaryKind !== null; const stale = home?.stale === true; const primaryLabel = primaryKind === null ? '' : labels[primaryKind]; - let statusLabel = COPY.signed_out ?? genericStatus; - if (status === 'empty') { - statusLabel = COPY.homeEmpty ?? 'Nothing running right now'; - } else if (status === 'waiting') { - statusLabel = COPY.waiting ?? 'Checking agents'; - } else if (status === 'privacy' || status === 'unavailable') { - statusLabel = COPY.privacy ?? 'Open Kilo to see agents'; - } const scheduledAt = validDate(home?.scheduledAt ?? props.scheduledAt); const checkedAt = validDate(home?.checkedAt); const awaitingUpdate = home?.awaitingUpdate === true; - const actionLine = content ? safeText(props.actionLine) : null; - const title = filledText(home === undefined ? props.newestTitle : home.primaryTitle); - let secondarySource: readonly { kind: string; count: number }[] = []; - if (Array.isArray(home?.secondaryCounts)) { - secondarySource = home.secondaryCounts; - } else if (home === undefined && Array.isArray(props.countLines)) { - secondarySource = props.countLines; + const feedback = content ? safeText(props.actionFeedback) : null; + const failed = feedback === 'couldNotApprove'; + // An untitled agent is still named, so its row never reads as blank. + const rawTitle = home === undefined ? null : safeText(home.primaryTitle); + const title = rawTitle === null ? null : (filledText(rawTitle) ?? COPY.agent ?? 'Agent'); + const agentTitle = (value: unknown) => filledText(value) ?? COPY.agent ?? 'Agent'; + const secondaryRows = ( + home === undefined ? records(props.countLines) : records(home.secondaryCounts) + ) + .filter(row => { + const kind = kindOf(row.kind); + return content && kind !== null && kind !== primaryKind && safeCount(row.count) > 0; + }) + .slice(0, 3) + .map(row => ({ kind: kindOf(row.kind) ?? 'idle', count: safeCount(row.count) })); + const waitingAgents = content ? records(home?.waitingAgents).slice(0, 3) : []; + const scheduledAgents = content ? records(home?.scheduledAgents).slice(0, 3) : []; + let detailRows: Record<string, unknown>[] = []; + if (primaryKind === 'needsInput') { + detailRows = waitingAgents; + } else if (primaryKind === 'scheduled') { + detailRows = scheduledAgents; } - const secondaryRows = secondarySource - .filter( - row => - content && - // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - row !== null && - // eslint-disable-next-line anti-slop/no-runtime-typeof -- the widget process reads raw app-group JSON, not the typed props vitest renders - typeof row === 'object' && - kindOf(row.kind) !== null && - safeCount(row.count) > 0 && - row.kind !== primaryKind - ) - .slice(0, 3); const canCreate = home === undefined ? props.actions?.newAgent === true @@ -258,514 +393,786 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React const canApprove = content && home?.canApprove === true && approvalKey !== null; const large = family === 'systemLarge'; const wide = family === 'systemMedium'; - const square = !large && !wide; - const waitingAgents = (Array.isArray(home?.waitingAgents) ? home.waitingAgents : []) - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - .filter(row => row !== null && typeof row === 'object') - .slice(0, 3); - const scheduledAgents = (Array.isArray(home?.scheduledAgents) ? home.scheduledAgents : []) - // eslint-disable-next-line anti-slop/no-runtime-typeof, typescript-eslint/no-unnecessary-condition -- the widget process reads raw app-group JSON, not the typed props vitest renders - .filter(row => row !== null && typeof row === 'object') - .slice(0, 3); - const detailSource = waitingAgents.length > 0 ? waitingAgents : scheduledAgents; - const detailRows = content ? detailSource : []; - // Medium prints up to two entry rows in its right column; large prints up to - // three below the count band. Both draw the same title + wait-kind row. - const mediumRows = wide ? detailRows.slice(0, 2) : []; const WidgetButton = Button as (props: WidgetButtonProps) => React.JSX.Element; - const foregroundIntent = { openAppWhenRun: true }; - const modifiers = [ + const today = widgetEnvironment.date instanceof Date ? widgetEnvironment.date : new Date(); + const intlLocale = (COPY.locale ?? 'en').split('_').join('-'); + + // System content margins vary by device and OS, so the card pads the + // difference to the design's 16pt sides, 12pt above the header controls and + // 15pt below the footer line. + const margins = widgetEnvironment.widgetContentMargins; + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const inset = (design: number, system: unknown) => + // eslint-disable-next-line anti-slop/no-runtime-typeof -- the environment is read from the native host as raw JSON + design - (typeof system === 'number' && Number.isFinite(system) ? system : 0); + const rootModifiers = [ bodyURL, - containerBackground(PlatformColor('systemBackground'), 'widget'), + containerBackground(palette.bg, 'widget'), localeModifier, + frame({ maxWidth: 10_000, maxHeight: 10_000, alignment: 'topLeading' }), + padding({ + top: inset(12, margins?.top), + bottom: inset(15, margins?.bottom), + leading: inset(16, margins?.leading), + trailing: inset(16, margins?.trailing), + }), ]; - const today = widgetEnvironment.date instanceof Date ? widgetEnvironment.date : new Date(); - const wakeTime = (date: Date, stacked = false) => { - const day = - date.toDateString() === today.toDateString() ? null : ( + + // ── shared pieces ── + // eslint-disable-next-line max-params -- a text run is its copy, size, colour and weight, named as the design names them + const text = (value: string, size: number, color: string, weight: Weight = 'regular') => ( + <Text modifiers={[font({ size, weight }), lineLimit(1), foregroundStyle(color)]}>{value}</Text> + ); + // A child moved down (or, for a negative offset, up into the line box above + // it) so its baseline sits where the design puts it. + // eslint-disable-next-line unicorn/consistent-function-scoping -- the stringified 'widget' layout cannot hoist helpers to module scope + const shifted = (offset: number, child: React.JSX.Element | null, leading = 0) => + child === null ? null : ( + <VStack alignment="leading" spacing={0} modifiers={[padding({ top: offset, leading })]}> + {child} + </VStack> + ); + const dot = (diameter: number, color: string) => ( + <Circle modifiers={[frame({ width: diameter, height: diameter }), foregroundStyle(color)]} /> + ); + const bar = (width: number, height: number, offset: number) => + shifted( + offset, + <RoundedRectangle + cornerRadius={height / 2} + modifiers={[frame({ width, height }), foregroundStyle(palette.secondary)]} + /> + ); + const rule = (offset: number) => + shifted( + offset, + <Rectangle + modifiers={[frame({ maxWidth: 10_000, height: 1 }), foregroundStyle(palette.divider)]} + /> + ); + const formatDate = (date: Date, options: Intl.DateTimeFormatOptions): string | null => { + try { + // eslint-disable-next-line no-restricted-globals -- the widget process cannot import @/lib/intl-cache; failure falls back to SwiftUI's date text + return new Intl.DateTimeFormat(intlLocale, options).format(date); + } catch { + return null; + } + }; + // Today is the clock time alone; another day adds a locale-formatted date. + // There is no "Today"/"Tomorrow" word and no relative "ago" anywhere. + // eslint-disable-next-line max-params -- the time, its text size and colour, and how much date to add on another day + const when = (date: Date, size: number, color: string, style: 'short' | 'long') => { + if (date.toDateString() === today.toDateString()) { + return ( <Text date={date} - dateStyle="date" - modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]} + dateStyle="time" + modifiers={[font({ size }), lineLimit(1), monospacedDigit(), foregroundStyle(color)]} /> ); - const time = ( + } + const time = { hour: 'numeric', minute: '2-digit' } as const; + let options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', ...time }; + if (style === 'long') { + options = { weekday: 'short', month: 'short', day: 'numeric', ...time }; + } else if (Math.abs(date.getTime() - today.getTime()) < 6 * 24 * 60 * 60 * 1000) { + options = { weekday: 'short', ...time }; + } + const formatted = formatDate(date, options); + if (formatted !== null) { + return text(formatted, size, color); + } + return ( + <HStack spacing={3}> + <Text + date={date} + dateStyle="date" + modifiers={[font({ size }), lineLimit(1), foregroundStyle(color)]} + /> + <Text + date={date} + dateStyle="time" + modifiers={[font({ size }), lineLimit(1), monospacedDigit(), foregroundStyle(color)]} + /> + </HStack> + ); + }; + // Header: the Kilo tile and name, then the Approve slot and the `+` slot at + // the trailing edge. A hidden action leaves its slot empty, so `+` never moves. + // eslint-disable-next-line max-params -- the symbol, its size, and the circle's fill and glyph colours + const circleGlyph = (symbol: SystemSymbol, size: number, fill: string, color: string) => ( + <Image + systemName={symbol} + color={color} + modifiers={[ + font({ size, weight: 'semibold' }), + frame({ width: 24, height: 24 }), + background(fill, shapes.circle()), + ]} + /> + ); + const pill = (label: string, fill: string, color: string) => ( + <Text + modifiers={[ + font({ size: 12, weight: 'semibold' }), + lineLimit(1), + foregroundStyle(color), + fixedSize({ horizontal: true, vertical: false }), + padding({ horizontal: 14 }), + frame({ minWidth: 86, height: 24 }), + background(fill, shapes.capsule()), + ]} + > + {label} + </Text> + ); + const approveButton = (child: React.JSX.Element) => ( + <WidgetButton + modifiers={[buttonStyle('plain'), accessibilityLabel(COPY.approve ?? 'Approve')]} + onPress={() => ({ + pendingAction: 'approve', + pendingApprovalKey: approvalKey ?? undefined, + pendingActionAt: Date.now(), + })} + > + {child} + </WidgetButton> + ); + let approveControl: React.JSX.Element | null = null; + if (feedback === 'approving') { + approveControl = + wide || large + ? pill(COPY.approving ?? 'Approving…', palette.secondary, palette.muted) + : circleGlyph('ellipsis', 13, palette.secondary, palette.muted); + } else if (canApprove) { + approveControl = approveButton( + wide || large + ? pill(COPY.approve ?? 'Approve', palette.primary, palette.primaryFg) + : circleGlyph('checkmark', 12, palette.primary, palette.primaryFg) + ); + } + const createButton = (child: React.JSX.Element) => ( + <WidgetButton + openAppWhenRun + modifiers={[buttonStyle('plain'), accessibilityLabel(COPY.newAgent ?? 'New agent')]} + onPress={() => ({ pendingAction: 'new-agent', pendingActionAt: Date.now() })} + > + {child} + </WidgetButton> + ); + // The empty Medium and Large cards offer New agent as a pill in the body, so + // the header `+` hides there and the card has a single create control. + const headerCreate = canCreate && (content || (status === 'empty' && !wide && !large)); + const header = ( + <HStack alignment="center" spacing={0} modifiers={[frame({ height: 24 })]}> + {logoTile(18, palette.fg, palette.bg)} <Text - date={date} - dateStyle="time" modifiers={[ - font({ textStyle: 'caption2' }), + font({ size: 13, weight: 'semibold' }), lineLimit(1), - monospacedDigit(), - mutedForeground, + foregroundStyle(palette.fg), + padding({ leading: 6 }), ]} - /> + > + Kilo + </Text> + <Spacer minLength={8} /> + <HStack + spacing={0} + modifiers={[ + frame(wide || large ? { minWidth: 86, height: 24 } : { width: 24, height: 24 }), + padding({ trailing: wide || large ? 8 : 4 }), + ]} + > + {approveControl} + </HStack> + <HStack spacing={0} modifiers={[frame({ width: 24, height: 24 })]}> + {headerCreate ? createButton(circleGlyph('plus', 14, palette.secondary, palette.fg)) : null} + </HStack> + </HStack> + ); + + // Footer: "Checked <time>" or "Last known · <time>"; a failed Approve on the + // Medium and Large cards says so here instead. + let footer: React.JSX.Element | null = null; + if (failed && (wide || large)) { + footer = text( + COPY.approveFailed ?? "Couldn't approve. Tap Approve to try again.", + 11, + palette.warn, + 'semibold' ); - return stacked ? ( - <VStack alignment="leading" spacing={0}> - {day} - {time} - </VStack> - ) : ( - <HStack spacing={4}> - {day} - {time} + } else if (status === 'waiting') { + footer = text(COPY.waiting ?? 'Updating agents', 11, palette.muted); + } else if (checkedAt !== null) { + footer = ( + <HStack spacing={3}> + {text( + stale ? `${COPY.lastKnown ?? 'Last known'} ·` : (COPY.checked ?? 'Checked'), + 11, + palette.muted + )} + {when(checkedAt, 11, palette.muted, 'short')} </HStack> ); - }; - const supportLine = () => { - if (actionLine !== null) { - return ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), primaryForeground]}> - {actionLine} - </Text> - ); - } - if (content && primaryKind === 'scheduled') { - if (awaitingUpdate || scheduledAt === null) { - return ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]}> - {COPY.awaitingUpdate ?? 'Awaiting update'} - </Text> - ); + } + const fill = <Spacer minLength={0} />; + // The slack above the body: the design's gap first, so a shorter device + // shrinks it before the body meets the footer. + const lead = (height: number) => ( + <Spacer minLength={0} modifiers={[frame({ maxHeight: height }), layoutPriority(1)]} /> + ); + + // ── locked: privacy, unavailable, signed out ── + if (!content && status !== 'empty' && status !== 'waiting') { + const message = + status === 'signed_out' + ? (COPY.signed_out ?? 'Sign in to see agents') + : (COPY.privacy ?? 'Open Kilo to see agents'); + // The small card breaks the message at the word that balances its two lines. + const words = message.split(' '); + let lines = [message]; + for (let index = 1; index < words.length; index += 1) { + const candidate = [words.slice(0, index).join(' '), words.slice(index).join(' ')]; + if ( + Math.max(candidate[0]?.length ?? 0, candidate[1]?.length ?? 0) < + Math.max(...lines.map(line => line.length)) + ) { + lines = candidate; } - return ( - <HStack spacing={4}> - {large ? ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]}> - {COPY.nextRun ?? 'Next run'} - </Text> - ) : null} - {wakeTime(scheduledAt, !large)} - </HStack> - ); } - if (content && large && primaryKind === 'needsInput') { - return ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]}> - {COPY.waitingForYou ?? 'Waiting for you'} - </Text> - ); + // Lock glyph sizes, the gap to the message, and the room the design keeps + // under the centred block: 26/6.6/1.7 small, 28/6.7/2.4 medium, 40/29/17 large. + let lockSize = 26; + let lockGap = 6.6; + let messageSize = 13; + let reserve = 1.7; + if (wide) { + lockSize = 28; + lockGap = 6.7; + messageSize = 15; + reserve = 2.4; + } else if (large) { + lockSize = 40; + lockGap = 29; + messageSize = 20; + reserve = 17; } - if (title !== null && content) { - return ( - <Text - modifiers={[font({ textStyle: 'caption' }), lineLimit(square ? 2 : 1), mutedForeground]} + const messageText = (line: string) => ( + <Text + modifiers={[ + font({ size: messageSize, weight: 'semibold' }), + lineLimit(1), + multilineTextAlignment('center'), + foregroundStyle(palette.fg), + ]} + > + {line} + </Text> + ); + return ( + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {fill} + <VStack + alignment="center" + spacing={0} + modifiers={[frame({ maxWidth: 10_000, alignment: 'center' })]} > - {title} - </Text> + <Image + systemName="lock.fill" + color={palette.fg} + modifiers={[ + resizable(), + aspectRatio({ contentMode: 'fit' }), + frame({ width: lockSize * 0.86, height: lockSize * 1.071 }), + ]} + /> + {shifted(lockGap, messageText(wide || large ? message : (lines[0] ?? message)))} + {wide || large || lines[1] === undefined + ? null + : shifted(gap(108, 13, 125, 13), messageText(lines[1]))} + </VStack> + {fill} + <Spacer minLength={0} modifiers={[frame({ height: reserve })]} /> + </VStack> + ); + } + + // ── updating (first load): placeholder bars, no actions ── + if (status === 'waiting') { + let body = ( + <VStack alignment="leading" spacing={0}> + {bar(44, 30, 26)} + {bar(96, 12, 10)} + </VStack> + ); + if (wide) { + body = ( + <HStack alignment="top" spacing={0}> + <VStack + alignment="leading" + spacing={0} + modifiers={[frame({ width: 160, alignment: 'leading' })]} + > + {bar(44, 34, 26)} + {bar(96, 12, 8)} + </VStack> + <VStack alignment="leading" spacing={0}> + {bar(150, 12, 30)} + {bar(100, 10, 8)} + {bar(140, 12, 12)} + {bar(90, 10, 8)} + </VStack> + </HStack> + ); + } else if (large) { + body = ( + <VStack alignment="leading" spacing={0}> + {bar(70, 60, 44)} + {bar(140, 16, 12)} + {bar(300, 14, 42)} + {bar(180, 11, 8)} + {bar(260, 14, 19)} + {bar(160, 11, 8)} + </VStack> ); } - if (secondaryRows[0] !== undefined && square) { + return ( + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {body} + {fill} + {footer} + </VStack> + ); + } + + // ── nothing running ── + if (!content) { + const message = COPY.homeEmpty ?? 'Nothing running right now'; + // New agent pill: 28pt with a 12pt plus on medium, 36pt with a 14pt plus on large. + const newAgentPill = canCreate + ? createButton( + <HStack + alignment="center" + spacing={large ? 9 : 8} + modifiers={[ + padding({ leading: large ? 11 : 8, trailing: large ? 16 : 12 }), + frame({ height: large ? 36 : 28 }), + background(palette.secondary, shapes.capsule()), + ]} + > + <Image + systemName="plus" + color={palette.fg} + modifiers={[font({ size: large ? 16 : 14, weight: 'semibold' })]} + /> + <Text + modifiers={[ + font({ size: large ? 14 : 13, weight: 'semibold' }), + lineLimit(1), + foregroundStyle(palette.fg), + ]} + > + {COPY.newAgent ?? 'New agent'} + </Text> + </HStack> + ) + : null; + let headlineSize = 17; + if (large) { + headlineSize = 22; + } else if (wide) { + headlineSize = 20; + } + const headline = ( + <Text + modifiers={[ + font({ size: headlineSize, weight: 'semibold' }), + lineLimit(2), + multilineTextAlignment(large ? 'center' : 'leading'), + foregroundStyle(palette.fg), + ]} + > + {message} + </Text> + ); + if (large) { return ( - <Text - modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]} - >{`${count(safeCount(secondaryRows[0].count))} ${labels[kindOf(secondaryRows[0].kind) ?? 'idle']}`}</Text> + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {fill} + <VStack + alignment="center" + spacing={0} + modifiers={[frame({ maxWidth: 10_000, alignment: 'center' })]} + > + {headline} + {shifted(198 - (176 + 0.241 * 22), newAgentPill)} + </VStack> + {fill} + {footer} + </VStack> ); } - let fallbackLine = COPY.openAgents ?? 'Open agents'; - if (!content && status === 'empty') { - fallbackLine = COPY.newAgent ?? 'New agent'; - } else if (!content && status === 'waiting') { - fallbackLine = COPY.awaitingUpdate ?? 'Awaiting update'; - } return ( - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), mutedForeground]}> - {fallbackLine} - </Text> + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {fill} + {headline} + {wide ? shifted(106 - (92 + 0.241 * 20), newAgentPill) : null} + {shifted(wide ? 141.53 - 134 : gap(124, 17, 152, 11), footer)} + </VStack> ); - }; - const supportSlot = ( - <VStack - alignment="leading" - spacing={0} - modifiers={[frame({ minHeight: large ? 20 : 26, maxWidth: 10_000, alignment: 'leading' })]} - > - {supportLine()} - </VStack> - ); - const heroDigits = String(primaryCount).length; - // The rounded digit glyph is about 0.65em wide, so this keeps the widest count - // inside the 122pt hero column of the width-limited families. - const heroCap = Math.max(24, Math.floor(120 / (0.65 * heroDigits))); - let heroSize = 40; - if (heroDigits > 9) { - heroSize = 16; - } else if (heroDigits > 6) { - heroSize = 22; - } else if (heroDigits > 4) { - heroSize = 26; - } else if (large) { - heroSize = 48; - } else if (square) { - heroSize = 26; - } - // A large card owes its body a whole composition rather than a full top block and - // a hole above the footer. It prints four blocks — the header, one hero block (the - // count, its label and the support line), the entry rows and the footer — with a - // spacer of the same height between each. Two flexible spacers split their - // remainder by their own interior rules, which is what left ~22pt more above the - // footer than below the header, so the height is arithmetic here instead. - const entryCount = large ? detailRows.length : 0; - // Three entry rows share one band and step up a smaller slot so the count keeps - // its size; a lone row has no pitch to read and can afford the room. - let entryHeight = 40; - if (large && entryCount === 1) { - entryHeight = 44; - } else if (large && entryCount === 3) { - entryHeight = 34; - } - // A scheduled agent with no usable wake time draws a single line where a waiting - // agent draws the title and its wait, so those rows must be counted shorter or - // their frames pad the block into gaps. - const shortRows = large - ? detailRows.filter(row => !('scheduledAt' in row) && !('kind' in row)).length - : 0; - const rowsHeight = - entryCount === 0 - ? 0 - : (entryCount - shortRows) * entryHeight + shortRows * 18 + (entryCount - 1) * 10; - const rowMinHeight = (row: (typeof detailRows)[number]) => - 'scheduledAt' in row || 'kind' in row ? entryHeight : 18; - // Only a stale card's footer is two lines: "Last known" over "Checked". - // A card with no checked time still anchors its bottom — the waiting card prints - // the surface's own affordance there — while a locked card keeps its copy in the - // hero alone, because repeating "Open agents" under it read as a duplicate. - const lockedState = status === 'privacy' || status === 'unavailable' || status === 'signed_out'; - const footerNote = checkedAt === null && !lockedState ? (COPY.openAgents ?? 'Open agents') : null; - let footerHeight = stale ? 28 : 24; - if (checkedAt === null) { - footerHeight = footerNote === null ? 0 : 24; } - // With no entry rows there are two interior gaps, not three, so they take the - // thirds the rows would have split. - const gapCount = entryCount === 0 ? 2 : 3; - const gapBase = 14; - // What is left of the large card's ~328pt body once the fixed parts are placed: - // the 28pt header, the label's single 21pt line, the 20pt support slot, the rows, - // the footer and the gaps. The count's 1.2em line box fills the rest. - const countBudget = 328 - 28 - 21 - 20 - rowsHeight - footerHeight - gapCount * gapBase; - if (large) { - heroSize = Math.max(18, Math.min(heroCap, Math.floor(countBudget / 1.2))); - } - const secondaryHeight = - secondaryRows.length === 0 ? 0 : secondaryRows.length * 15 + (secondaryRows.length - 1) * 7; - // Equal gaps; a count capped for a wide number hands its leftover to them rather - // than leaving one hole. - const largeGap = large - ? gapBase + - Math.floor(Math.max(0, countBudget - Math.max(heroSize * 1.2, secondaryHeight)) / gapCount) - : gapBase; - let heroMinHeight = 61; - if (large) { - heroMinHeight = 80; - } else if (square) { - heroMinHeight = 44; - } - let heroValue = '—'; - if (content) { - heroValue = count(primaryCount); - } else if (status === 'empty') { - heroValue = '0'; - } - const heroNumber = ( + // ── content ── + const countText = (size: number) => ( <Text modifiers={[ - font({ size: heroSize, weight: 'bold', design: 'rounded' }), + font({ size, weight: 'bold' }), monospacedDigit(), lineLimit(1), - primaryForeground, - accessibilityLabel(content ? `${count(primaryCount)} ${primaryLabel}` : statusLabel), + fixedSize({ horizontal: true, vertical: false }), + foregroundStyle(palette.fg), + accessibilityLabel(`${count(primaryCount)} ${primaryLabel}`), ]} > - {heroValue} + {count(primaryCount)} </Text> ); - // The large card prints the label across the full card width, so it is always one - // known 21pt line and the count above it can be sized to fill the body exactly. - const heroLabel = ( - <Text - modifiers={[ - font({ textStyle: large ? 'headline' : 'caption', weight: 'semibold' }), - lineLimit(square || large ? 1 : 2), - primaryForeground, - ]} + // The dot sits a hair inside the count's edge, 6pt before the label. + const statusRow = (diameter: number, labelSize: number) => ( + <HStack + alignment="center" + spacing={6} + modifiers={[padding({ leading: diameter > 8 ? 1.5 : 1 })]} > - {content ? primaryLabel : statusLabel} - </Text> + {dot(diameter, dotColors[primaryKind])} + <Text + modifiers={[ + font({ size: labelSize, weight: 'semibold' }), + lineLimit(1), + foregroundStyle(palette.fg), + ]} + > + {primaryLabel} + </Text> + </HStack> ); - const hero = ( - <VStack - alignment="leading" - spacing={0} - modifiers={[ - frame({ - minHeight: heroMinHeight, - maxWidth: wide || large ? 122 : 10_000, - alignment: 'leading', - }), - ]} - > - {heroNumber} - {heroLabel} - </VStack> + // One row's second line: why the agent waits, or when it wakes. + const rowDetail = (row: Record<string, unknown>, size: number, style: 'short' | 'long') => { + if (primaryKind === 'needsInput') { + let reason = COPY.waitingToRetry ?? 'Waiting to retry'; + if (row.kind === 'permission') { + reason = COPY.permissionRequired ?? 'Permission required'; + } else if (row.kind === 'question') { + reason = COPY.answerNeeded ?? 'Answer needed'; + } + return text(reason, size, palette.muted); + } + const date = validDate(row.scheduledAt); + if (date === null) { + return null; + } + if (date.getTime() <= today.getTime()) { + return text(COPY.awaitingUpdate ?? 'Awaiting update', size, palette.muted); + } + return when(date, size, palette.muted, style); + }; + const rowColor = primaryKind === 'scheduled' ? palette.info : palette.warn; + const titleRow = (row: Record<string, unknown>, size: number) => ( + <HStack alignment="center" spacing={8}> + {dot(8, rowColor)} + {text(agentTitle(row.title), size, palette.fg)} + </HStack> ); - const secondary = ( - <VStack alignment="leading" spacing={large ? 7 : 1}> + const countRows = (spacing: number) => ( + <VStack alignment="leading" spacing={spacing}> {secondaryRows.map(row => ( - <HStack key={row.kind} spacing={6}> - <Image - systemName={glyphs[kindOf(row.kind) ?? 'idle'].icon} - size={12} - color={glyphs[kindOf(row.kind) ?? 'idle'].color} - /> + <HStack key={row.kind} alignment="center" spacing={8}> + {dot(8, dotColors[row.kind])} <Text modifiers={[ - font({ textStyle: large ? 'caption' : 'caption2', weight: 'semibold' }), + font({ size: 13 }), monospacedDigit(), - layoutPriority(1), - primaryForeground, - ]} - > - {count(safeCount(row.count))} - </Text> - <Text - modifiers={[ - font({ textStyle: large ? 'caption' : 'caption2' }), lineLimit(1), - mutedForeground, + foregroundStyle(palette.fg), ]} > - {labels[kindOf(row.kind) ?? 'idle']} + {`${count(row.count)} ${labels[row.kind]}`} </Text> </HStack> ))} </VStack> ); - // One entry row: the agent's title and why it waits, or when it wakes. The - // large card prints up to three below the count band; the medium card prints - // up to two in its right column, so a waiting agent is named without the - // large card's height. - const agentRow = (row: (typeof detailRows)[number], index: number, minHeight: number) => { - const date = 'scheduledAt' in row ? validDate(row.scheduledAt) : null; - let detailLine: React.JSX.Element | null = null; - if ('scheduledAt' in row) { - detailLine = - date === null || date.getTime() <= today.getTime() ? ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {COPY.awaitingUpdate ?? 'Awaiting update'} - </Text> - ) : ( - wakeTime(date) - ); - } else if ('kind' in row) { - let waitLine = COPY.waitingToRetry ?? 'Waiting to retry'; - if (row.kind === 'permission') { - waitLine = COPY.permissionRequired ?? 'Permission required'; - } else if (row.kind === 'question') { - waitLine = COPY.answerNeeded ?? 'Answer needed'; - } - detailLine = ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {waitLine} - </Text> - ); + const sectionLabel = (label: string) => text(label, 12, palette.muted, 'semibold'); + // The scheduled line under the label: the next wake, Awaiting update, or the title. + const scheduledLine = (size: number, color: string) => { + if (awaitingUpdate) { + return text(COPY.awaitingUpdate ?? 'Awaiting update', size, palette.muted); } - return ( - <VStack - key={index} - alignment="leading" - spacing={3} - modifiers={[frame({ minHeight, maxWidth: 10_000, alignment: 'leading' })]} - > - <HStack spacing={6}> - <Image systemName={'scheduledAt' in row ? 'clock' : 'exclamationmark.circle'} size={12} /> - <Text modifiers={[font({ textStyle: 'caption' }), lineLimit(1), primaryForeground]}> - {filledText(row.title) ?? filledText(COPY.agent) ?? 'Agent'} - </Text> + if (scheduledAt !== null) { + return ( + <HStack spacing={3}> + {text(COPY.nextRun ?? 'Next run', size, color)} + {when(scheduledAt, size, color, 'short')} </HStack> - {detailLine} - </VStack> - ); + ); + } + return title === null ? null : text(title, size, color); }; - // The medium's right column: the waiting/scheduled rows when there are any, - // otherwise the support counts and the support line. The layout cannot use a - // fragment (only widget globals are in scope), so the fallback is two children. - const wideRows = - wide && mediumRows.length > 0 ? mediumRows.map((row, index) => agentRow(row, index, 0)) : null; - const wideFallback = wide && mediumRows.length === 0; - const headerRow = ( - <HStack alignment="center" spacing={5} modifiers={[frame({ height: large ? 28 : 24 })]}> - {logoUri.length === 0 ? null : ( - <Image - uiImage={logoUri} - modifiers={[resizable(), frame({ width: 18, height: 18 }), cornerRadius(4)]} - /> - )} - <Text - modifiers={[ - font({ textStyle: 'caption', weight: 'bold' }), - lineLimit(1), - primaryForeground, - ]} - > - Kilo - </Text> - <Spacer /> - <HStack spacing={4} modifiers={[frame({ width: 24, height: 24 })]}> - {canApprove ? ( - <WidgetButton - modifiers={[ - buttonStyle('plain'), - controlSize('small'), - accessibilityLabel(COPY.approve ?? 'Approve'), - ]} - onPress={() => ({ - pendingAction: 'approve', - pendingApprovalKey: approvalKey, - pendingActionAt: Date.now(), - })} - > - <Image systemName="checkmark.circle" size={21} color={PlatformColor('label')} /> - </WidgetButton> - ) : null} - </HStack> - <HStack spacing={0} modifiers={[frame({ width: 24, height: 24 })]}> - {canCreate ? ( - <WidgetButton - {...foregroundIntent} - modifiers={[ - buttonStyle('plain'), - controlSize('small'), - accessibilityLabel(COPY.newAgent ?? 'New agent'), - ]} - onPress={() => ({ pendingAction: 'new-agent', pendingActionAt: Date.now() })} - > - <Image systemName="plus.circle.fill" size={23} color={PlatformColor('label')} /> - </WidgetButton> - ) : null} - </HStack> - </HStack> - ); - // The footer carries the freshness line, or — before the first snapshot, when - // there is no time to print — the surface's own affordance, so the large card is - // anchored at both ends instead of trailing off into a blank band. - const footerNoteRow = - footerNote === null ? null : ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {footerNote} - </Text> - ); - const footerChecked = - checkedAt === null ? null : ( + + if (large) { + // One kind: count 88 @168, label 20 @203, rule 238, "Recent" @268, title 15 @292. + let body = ( <VStack alignment="leading" spacing={0}> - {stale ? ( - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {COPY.lastKnown ?? 'Last known'} - </Text> - ) : null} - <HStack spacing={4}> - <Text modifiers={[font({ textStyle: 'caption2' }), lineLimit(1), mutedForeground]}> - {COPY.checked ?? 'Checked'} - </Text> - {square ? ( - // A square card has no room for a relative phrase, and "Checked 3 - // hours…" ellipsised. The clock time fits and matches Android. - wakeTime(checkedAt) - ) : ( - <Text - date={checkedAt} - dateStyle="ago" + {lead(168 - 0.952 * 88 - 36)} + {countText(88)} + {shifted(gap(168, 88, 203, 20), statusRow(10, 20))} + {title === null ? null : rule(237.5 - (203 + 0.241 * 20))} + {title === null + ? null + : shifted(268 - 0.952 * 12 - 238.5, sectionLabel(COPY.recent ?? 'Recent'))} + {title === null ? null : shifted(gap(268, 12, 292, 15), text(title, 15, palette.fg))} + </VStack> + ); + if (primaryKind === 'scheduled') { + // Count 80 @128, label 19 @158, line 13 @182, rule 206, "Next runs" @232, rows @258 + 34n. + body = ( + <VStack alignment="leading" spacing={0}> + {lead(128 - 0.952 * 80 - 36)} + {countText(80)} + {shifted(gap(128, 80, 158, 19), statusRow(10, 19))} + {shifted(gap(158, 19, 182, 13), scheduledLine(13, palette.muted))} + {rule(205.5 - (182 + 0.241 * 13))} + {shifted(232 - 0.952 * 12 - 206.5, sectionLabel(COPY.nextScheduled ?? 'Next scheduled'))} + {detailRows.map((row, index) => { + const detail = rowDetail(row, 12, 'long'); + return shifted( + index === 0 ? gap(232, 12, 258, 14) : gap(258, 14, 292, 14), + <HStack alignment="firstTextBaseline" spacing={8} key={index}> + {titleRow(row, 14)} + <Spacer minLength={0} /> + {detail} + </HStack>, + 1 + ); + })} + </VStack> + ); + } else if (primaryKind === 'needsInput') { + // Count 64 @106 with the other counts beside it; rule 152; "Waiting for + // you" @176; up to three rows @202 + 46n with their reason 17pt below. + const rows = detailRows; + body = ( + <VStack alignment="leading" spacing={0}> + {lead(106 - 0.952 * 64 - 36)} + <HStack alignment="top" spacing={0}> + <VStack + alignment="leading" + spacing={0} + modifiers={[frame({ width: 206, alignment: 'leading' })]} + > + {countText(64)} + {shifted(gap(106, 64, 131, 17), statusRow(9, 17))} + </VStack> + {shifted(74 - 0.952 * 13 - (106 - 0.952 * 64), countRows(gap(74, 13, 96, 13)))} + </HStack> + {rule(151.5 - (131 + 0.241 * 17))} + {rows.length > 0 + ? shifted( + 176 - 0.952 * 12 - 152.5, + sectionLabel(COPY.waitingForYou ?? 'Waiting for you') + ) + : null} + {rows.map((row, index) => ( + <VStack + key={index} + alignment="leading" + spacing={0} modifiers={[ - font({ textStyle: 'caption2' }), - lineLimit(1), - monospacedDigit(), - mutedForeground, + padding({ + top: index === 0 ? gap(176, 12, 202, 14) : gap(219, 12, 248, 14), + leading: 1, + }), ]} - /> + > + {titleRow(row, 14)} + {shifted(gap(202, 14, 219, 12), rowDetail(row, 12, 'long'), 16)} + </VStack> + ))} + {rows.length === 0 && title !== null + ? shifted(176 - 0.952 * 12 - 152.5, sectionLabel(COPY.recent ?? 'Recent')) + : null} + {rows.length === 0 && title !== null + ? shifted(gap(176, 12, 200, 15), text(title, 15, palette.fg)) + : null} + </VStack> + ); + } else if (secondaryRows.length > 0) { + // Mixed: count 64 @120 beside the other counts (@92 + 24n); rule 178; + // "Recent" @210 and its title @234; "Next scheduled" @284 with one row. + const next = scheduledAgents[0]; + body = ( + <VStack alignment="leading" spacing={0}> + {lead(120 - 0.952 * 64 - 36)} + <HStack alignment="top" spacing={0}> + <VStack + alignment="leading" + spacing={0} + modifiers={[frame({ width: 206, alignment: 'leading' })]} + > + {countText(64)} + {shifted(gap(120, 64, 145, 17), statusRow(9, 17))} + </VStack> + {shifted(92 - 0.952 * 13 - (120 - 0.952 * 64), countRows(gap(92, 13, 116, 13)))} + </HStack> + {rule(177.5 - (145 + 0.241 * 17))} + {title === null + ? null + : shifted(210 - 0.952 * 12 - 178.5, sectionLabel(COPY.recent ?? 'Recent'))} + {title === null ? null : shifted(gap(210, 12, 234, 15), text(title, 15, palette.fg))} + {next === undefined + ? null + : shifted( + title === null ? 210 - 0.952 * 12 - 178.5 : gap(234, 15, 284, 12), + sectionLabel(COPY.nextScheduled ?? 'Next scheduled') + )} + {next === undefined ? null : ( + <VStack + alignment="leading" + spacing={0} + modifiers={[padding({ top: gap(284, 12, 308, 14), leading: 1 })]} + > + <HStack alignment="center" spacing={8}> + {dot(8, palette.info)} + {text(agentTitle(next.title), 14, palette.fg)} + </HStack> + {shifted(gap(308, 14, 326, 12), rowDetail(next, 12, 'long'), 16)} + </VStack> )} - </HStack> + </VStack> + ); + } + return ( + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {body} + {fill} + {footer} </VStack> ); - const footerContent = checkedAt === null ? footerNoteRow : footerChecked; - const footerRow = ( - <VStack - alignment="leading" - spacing={0} - modifiers={[frame({ height: footerHeight, alignment: 'topLeading' })]} - > - {footerContent} - </VStack> - ); - if (large) { + } + + if (wide) { + // Left: count 44 @92 and the label @112. Right, from x 168: up to two + // agent rows @74 + 42n, else the other counts @78 + 24n from x 172, else + // "Recent" @88 and the title @106 from x 176. + const countTop = 92 - 0.952 * 44; + const rows = detailRows.slice(0, 2); + let column: React.JSX.Element | null = null; + if (rows.length > 0) { + column = shifted( + 74 - 0.952 * 13 - countTop, + <VStack alignment="leading" spacing={0}> + {rows.map((row, index) => ( + <VStack + key={index} + alignment="leading" + spacing={0} + modifiers={[padding({ top: index === 0 ? 0 : gap(90, 11, 116, 13) })]} + > + {titleRow(row, 13)} + {shifted( + gap(74, 13, 90, 11), + rowDetail(row, 11, 'long') ?? ( + <Spacer minLength={0} modifiers={[frame({ height: 1.193 * 11 })]} /> + ), + 16 + )} + </VStack> + ))} + </VStack> + ); + } else if (secondaryRows.length > 0) { + column = shifted(78 - 0.952 * 13 - countTop, countRows(gap(78, 13, 102, 13)), 4); + } else if (title !== null) { + column = shifted( + 88 - 0.952 * 11 - countTop, + <VStack alignment="leading" spacing={0}> + {text(COPY.recent ?? 'Recent', 11, palette.muted, 'semibold')} + {shifted(gap(88, 11, 106, 13), text(title, 13, palette.fg))} + </VStack>, + 8 + ); + } return ( - <VStack alignment="leading" spacing={0} modifiers={modifiers}> - {headerRow} - <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> - <HStack alignment="center" spacing={16}> - {heroNumber} + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {lead(countTop - 36)} + <HStack alignment="top" spacing={0}> <VStack alignment="leading" - spacing={7} - modifiers={[frame({ maxWidth: 10_000, alignment: 'leading' })]} + spacing={0} + modifiers={[frame({ width: 152, alignment: 'leading' })]} > - {secondary} + {countText(44)} + {shifted(gap(92, 44, 112, 14), statusRow(8, 14))} </VStack> - </HStack> - {heroLabel} - {supportSlot} - {entryCount === 0 ? null : ( - <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> - )} - <VStack alignment="leading" spacing={10}> - {detailRows.map((row, index) => agentRow(row, index, rowMinHeight(row)))} - </VStack> - <Spacer minLength={0} modifiers={[frame({ height: largeGap })]} /> - {footerRow} - </VStack> - ); - } - return ( - <VStack alignment="leading" spacing={2} modifiers={modifiers}> - {headerRow} - {wide ? ( - <HStack alignment="center" spacing={16}> - {hero} <VStack alignment="leading" - spacing={mediumRows.length > 0 ? 7 : 2} + spacing={0} modifiers={[frame({ maxWidth: 10_000, alignment: 'leading' })]} > - {wideRows} - {wideFallback ? secondary : null} - {wideFallback ? supportSlot : null} + {column} </VStack> </HStack> - ) : ( - // A square card has one column: the hero and its line centre in the body - // so the slack splits above and below instead of pooling in one hole. - <VStack - alignment="leading" - spacing={0} - modifiers={[frame({ maxWidth: 10_000, maxHeight: 10_000, alignment: 'leading' })]} - > - {hero} - {supportSlot} - </VStack> - )} - {wide ? <Spacer minLength={0} /> : null} - {footerRow} + {fill} + {footer} + </VStack> + ); + } + + // Small: count 44 @78 right under the header, label @98, one line @117. + let detail: React.JSX.Element | null = null; + if (failed) { + detail = text(COPY.couldNotApprove ?? 'Could not approve', 13, palette.warn, 'semibold'); + } else if (primaryKind === 'scheduled') { + detail = scheduledLine(13, palette.fg); + } else if (title !== null) { + detail = text(title, 13, palette.fg); + } + return ( + <VStack alignment="leading" spacing={0} modifiers={rootModifiers}> + {header} + {countText(44)} + {shifted(gap(78, 44, 98, 14), statusRow(8, 14))} + {shifted(gap(98, 14, 117, 13), detail)} + {fill} + {footer} </VStack> ); }; export const WIDGET_NAME = 'ActiveAgentsWidget'; export const activeAgentsWidgetLayout = layout; -const registerLayout = () => - createWidget<WidgetProps>(WIDGET_NAME, withGlanceableCopy(withWidgetLogo(layout))); +const registerLayout = () => createWidget<WidgetProps>(WIDGET_NAME, withGlanceableCopy(layout)); export const ActiveAgentsWidget = registerLayout(); +/** + * The kind the widget extension draws in the gallery: the same layout over + * sample props. `HomeWidgetTimelineProvider.swift` reads it as `<name>Preview` + * for a preview snapshot and falls back to the placed widget's entry until the + * app has written it. + */ +const WIDGET_GALLERY_PREVIEW_NAME = `${WIDGET_NAME}Preview`; + +/** Re-bake both layouts in the active language and re-write the gallery sample. */ export function refreshActiveAgentsWidgetCopy(): void { registerLayout(); + createWidget<WidgetProps>(WIDGET_GALLERY_PREVIEW_NAME, withGlanceableCopy(layout)).updateSnapshot( + buildGalleryPreviewProps(key => i18n.t(key)) + ); } diff --git a/apps/mobile/src/glanceable-ios/interaction.test.ts b/apps/mobile/src/glanceable-ios/interaction.test.ts index 9fc995c706..b1b80e5094 100644 --- a/apps/mobile/src/glanceable-ios/interaction.test.ts +++ b/apps/mobile/src/glanceable-ios/interaction.test.ts @@ -3,11 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type WaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { - GLANCEABLE_APPROVE_TARGET, - GLANCEABLE_OPEN_TARGET, - handleGlanceableInteraction, -} from './interaction'; +import { GLANCEABLE_APPROVE_TARGET, handleGlanceableInteraction } from './interaction'; const mocks = vi.hoisted(() => ({ getInstances: vi.fn(), @@ -17,10 +13,13 @@ const mocks = vi.hoisted(() => ({ readWaitingAsk: vi.fn(), recordWaitingAsk: vi.fn(), restorePersistedGlanceable: vi.fn(), - setPendingDeepLink: vi.fn(), setGlanceableActionNotice: vi.fn(), + setGlanceableActionApproving: vi.fn(), renderStoredSnapshotWithNotice: vi.fn(), changeLanguage: vi.fn(), + /** The approving flag as the sink would hold it, read at each render. */ + approving: false, + renderedApproving: [] as boolean[], language: { whenLanguagePreferenceLoaded: vi.fn<() => Promise<void>>(), getResolvedLanguage: vi.fn(() => 'de'), @@ -29,11 +28,11 @@ const mocks = vi.hoisted(() => ({ vi.mock('./active-agents-live-activity', () => ({ LIVE_ACTIVITY_NAME: 'ActiveAgentsLiveActivity', - OPEN_AGENTS_URL: 'kiloapp:///cloud/sessions', ActiveAgentsLiveActivity: { getInstances: mocks.getInstances }, })); vi.mock('./ios-sink', () => ({ setGlanceableActionNotice: mocks.setGlanceableActionNotice, + setGlanceableActionApproving: mocks.setGlanceableActionApproving, renderStoredSnapshotWithNotice: mocks.renderStoredSnapshotWithNotice, })); vi.mock('sonner-native', () => ({ toast: { error: mocks.toastError } })); @@ -59,7 +58,6 @@ vi.mock('@/lib/glanceable/waiting-ask', () => ({ vi.mock('@/lib/glanceable/persist', () => ({ restorePersistedGlanceable: mocks.restorePersistedGlanceable, })); -vi.mock('@/lib/deep-link-launch', () => ({ setPendingDeepLink: mocks.setPendingDeepLink })); /** The ActivityKit id expo-widgets renders a Live Activity's content under. */ const ACTIVITY_ID = 'activity-1'; @@ -81,7 +79,10 @@ function event(source: string, target: string): UserInteractionEvent { return { source, target, timestamp: 1_750_000_000_000, type: 'ExpoWidgetsUserInteraction' }; } -const fromCard = (target: string) => event(ACTIVITY_ID, target); +async function approveFromCard() { + const outcome = await handleGlanceableInteraction(event(ACTIVITY_ID, GLANCEABLE_APPROVE_TARGET)); + return outcome; +} /** When a mock was called, relative to every other mock; fails when never called. */ function callOrder(mock: { mock: { invocationCallOrder: number[] } }): number { @@ -95,11 +96,19 @@ function callOrder(mock: { mock: { invocationCallOrder: number[] } }): number { describe('handleGlanceableInteraction', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.approving = false; + mocks.renderedApproving = []; mocks.getInstances.mockReturnValue([{ getId: () => ACTIVITY_ID }]); mocks.readWaitingAsk.mockResolvedValue(ASK); mocks.runGlanceableApprove.mockResolvedValue({ kind: 'approved' }); mocks.refreshGlanceableSnapshot.mockResolvedValue(undefined); - mocks.renderStoredSnapshotWithNotice.mockResolvedValue(undefined); + mocks.setGlanceableActionApproving.mockImplementation((value: boolean) => { + mocks.approving = value; + }); + mocks.renderStoredSnapshotWithNotice.mockImplementation(async () => { + mocks.renderedApproving.push(mocks.approving); + await Promise.resolve(); + }); mocks.restorePersistedGlanceable.mockResolvedValue(undefined); mocks.changeLanguage.mockResolvedValue(undefined); mocks.language.whenLanguagePreferenceLoaded.mockResolvedValue(undefined); @@ -107,45 +116,31 @@ describe('handleGlanceableInteraction', () => { it('ignores a press from another layout', async () => { await expect( - handleGlanceableInteraction(event(FOREIGN_SOURCE, GLANCEABLE_OPEN_TARGET)) - ).resolves.toEqual({ - kind: 'ignored', - }); + handleGlanceableInteraction(event(FOREIGN_SOURCE, GLANCEABLE_APPROVE_TARGET)) + ).resolves.toEqual({ kind: 'ignored' }); expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); - expect(mocks.setPendingDeepLink).not.toHaveBeenCalled(); expect(mocks.toastError).not.toHaveBeenCalled(); }); it('ignores a press whose source is an activity this app is not running', async () => { await expect( - handleGlanceableInteraction(event('activity-9', GLANCEABLE_OPEN_TARGET)) - ).resolves.toEqual({ - kind: 'ignored', - }); + handleGlanceableInteraction(event('activity-9', GLANCEABLE_APPROVE_TARGET)) + ).resolves.toEqual({ kind: 'ignored' }); - expect(mocks.setPendingDeepLink).not.toHaveBeenCalled(); + expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); }); it('accepts a press from an ended-but-visible card', async () => { - // A terminal card stays on screen until ActivityKit dismisses it, and the - // layout keeps drawing Open on it for that whole window, so the press has - // to route: `getInstances()` omits ended instances unless it is asked for - // them. A dismissed card is still no press source. + // A terminal card stays on screen until ActivityKit dismisses it, so the + // press has to route: `getInstances()` omits ended instances unless it is + // asked for them. A dismissed card is still no press source. mocks.getInstances.mockImplementation((includeEnded?: boolean) => includeEnded === true ? [{ getId: () => ACTIVITY_ID }] : [] ); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_OPEN_TARGET))).resolves.toEqual({ - kind: 'opened', - href: '/(app)/agent-chat/session-7', - }); - + await expect(approveFromCard()).resolves.toEqual({ kind: 'approved' }); expect(mocks.getInstances).toHaveBeenCalledWith(true); - expect(mocks.setPendingDeepLink).toHaveBeenCalledWith( - '/(app)/agent-chat/session-7', - 'universal-link' - ); }); it('ignores a press when the running activities cannot be read', async () => { @@ -153,34 +148,30 @@ describe('handleGlanceableInteraction', () => { throw new Error('ActivityKit unavailable'); }); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_OPEN_TARGET))).resolves.toEqual({ - kind: 'ignored', - }); - - expect(mocks.setPendingDeepLink).not.toHaveBeenCalled(); + await expect(approveFromCard()).resolves.toEqual({ kind: 'ignored' }); + expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); }); it('accepts the registered name as this surface too', async () => { await expect( - handleGlanceableInteraction(event('ActiveAgentsLiveActivity', GLANCEABLE_OPEN_TARGET)) - ).resolves.toEqual({ kind: 'opened', href: '/(app)/agent-chat/session-7' }); + handleGlanceableInteraction(event('ActiveAgentsLiveActivity', GLANCEABLE_APPROVE_TARGET)) + ).resolves.toEqual({ kind: 'approved' }); }); - it('ignores a target no button declares', async () => { - await expect(handleGlanceableInteraction(fromCard('dismiss'))).resolves.toEqual({ - kind: 'unhandled', - }); + it('ignores a target no button declares, including the retired Open', async () => { + await Promise.all( + ['dismiss', 'open'].map(async target => { + await expect(handleGlanceableInteraction(event(ACTIVITY_ID, target))).resolves.toEqual({ + kind: 'unhandled', + }); + }) + ); expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); - expect(mocks.setPendingDeepLink).not.toHaveBeenCalled(); }); it('routes Approve through the shared flow and republishes the tray', async () => { - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'approved', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'approved' }); expect(mocks.runGlanceableApprove).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ @@ -193,20 +184,64 @@ describe('handleGlanceableInteraction', () => { expect(mocks.toastError).not.toHaveBeenCalled(); }); + it('shows Approving… on the card before the answer runs', async () => { + let approvingDuringAnswer = false; + mocks.runGlanceableApprove.mockImplementation(async () => { + approvingDuringAnswer = mocks.approving; + await Promise.resolve(); + return { kind: 'approved' }; + }); + + await approveFromCard(); + + expect(approvingDuringAnswer).toBe(true); + // The first render carries the pill. + expect(mocks.renderedApproving[0]).toBe(true); + }); + + it.each([ + ['approved', ASK], + ['gone', ASK], + ['none', ASK], + ['none', null], + ['retryable', ASK], + ] as const)('ends Approving… for a %s answer (ask %#)', async (kind, ask) => { + mocks.readWaitingAsk.mockResolvedValue(ask); + mocks.runGlanceableApprove.mockResolvedValue({ kind }); + + await approveFromCard(); + + expect(mocks.approving).toBe(false); + // The last thing the card was handed no longer carries the pill. + expect(mocks.renderedApproving.at(-1)).toBe(false); + }); + + it('ends Approving… when the answer throws or the republish fails', async () => { + mocks.runGlanceableApprove.mockRejectedValue(new Error('network')); + await expect(approveFromCard()).resolves.toEqual({ kind: 'retryable' }); + expect(mocks.approving).toBe(false); + expect(mocks.renderedApproving.at(-1)).toBe(false); + + mocks.renderedApproving = []; + mocks.runGlanceableApprove.mockResolvedValue({ kind: 'approved' }); + mocks.refreshGlanceableSnapshot.mockRejectedValue(new Error('offline')); + // The republish settles on its own; the pill still ends. + await approveFromCard(); + expect(mocks.approving).toBe(false); + expect(mocks.renderedApproving.at(-1)).toBe(false); + }); + it('surfaces the retryable copy and leaves Approve in place', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'retryable', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'retryable' }); expect(mocks.toastError).toHaveBeenCalledWith('glanceable.approveFailed'); // The card itself carries the failure line: the toast is only on screen // with the app up, and this press can arrive with it closed. - expect(mocks.setGlanceableActionNotice).toHaveBeenCalledWith('glanceable.approveFailed'); - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); + expect(mocks.setGlanceableActionNotice).toHaveBeenLastCalledWith('glanceable.approveFailed'); + // One render for the pill, one for the failure line. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); // The record stays, and the card is not republished as if it had changed. expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.refreshGlanceableSnapshot).not.toHaveBeenCalled(); @@ -215,7 +250,7 @@ describe('handleGlanceableInteraction', () => { it('applies the stored language before the press translates anything', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); - await handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET)); + await approveFromCard(); // A press can launch this process in the background, where nothing has // applied the stored language yet: the same wait the Android headless task @@ -233,29 +268,21 @@ describe('handleGlanceableInteraction', () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); mocks.renderStoredSnapshotWithNotice.mockRejectedValue(new Error('surface unavailable')); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'retryable', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'retryable' }); // The notice was still offered to the sink, and the record still stands for // another tap: a failed re-render is not a second failure the user sees. - expect(mocks.setGlanceableActionNotice).toHaveBeenCalledWith('glanceable.approveFailed'); + expect(mocks.setGlanceableActionNotice).toHaveBeenLastCalledWith('glanceable.approveFailed'); expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); + expect(mocks.approving).toBe(false); }); it('drops an answered-elsewhere ask and still refreshes', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'gone' }); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'gone', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'gone' }); expect(mocks.recordWaitingAsk).toHaveBeenCalledWith(null); - expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); // Gone ends the ask, so its stale tray row is skipped like an answered one. expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ userId: 'user-1', @@ -269,16 +296,10 @@ describe('handleGlanceableInteraction', () => { it('refreshes without touching the record when nothing is approvable', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'none' }); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'none', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'none' }); expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); - expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); - // `none` leaves the ask as it is, so the republish re-selects that row: the - // session Open names has to survive a press that answered nothing. + // `none` leaves the ask as it is, so the republish re-selects that row. expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ userId: 'user-1', organizationId: 'org-1', @@ -291,33 +312,14 @@ describe('handleGlanceableInteraction', () => { mocks.readWaitingAsk.mockResolvedValue(null); mocks.runGlanceableApprove.mockResolvedValue({ kind: 'none' }); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'none', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'none' }); expect(mocks.refreshGlanceableSnapshot).not.toHaveBeenCalled(); expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); }); - it.each(['session-7', 'ses_1/2 3', 'ses_1?tab=2', 'ses_1#part', 'ses_%2F'])( - 'routes Open to the entire recorded session id %s', - async kiloSessionId => { - mocks.readWaitingAsk.mockResolvedValue({ ...ASK, kiloSessionId }); - const href = `/(app)/agent-chat/${encodeURIComponent(kiloSessionId)}`; - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_OPEN_TARGET))).resolves.toEqual({ - kind: 'opened', - href, - }); - - expect(mocks.setPendingDeepLink).toHaveBeenCalledWith(href, 'universal-link'); - expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); - } - ); - it('restores the persisted scope before Approve reads the mirrored ask', async () => { - await handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET)); + await approveFromCard(); // A press can launch this process in the background, before any app root // restores the persisted glanceable — and the mirrored ask's cross-scope @@ -329,48 +331,16 @@ describe('handleGlanceableInteraction', () => { ); }); - it('restores the persisted scope before Open reads the mirrored ask', async () => { - await handleGlanceableInteraction(fromCard(GLANCEABLE_OPEN_TARGET)); - - // The same stored scope: Open names the recorded session, so a foreign ask - // must not become the deep link either. - expect(callOrder(mocks.restorePersistedGlanceable)).toBeLessThan( - callOrder(mocks.readWaitingAsk) - ); - }); - - it('lands on the Agents tab when Open has no recorded session', async () => { - mocks.readWaitingAsk.mockResolvedValue(null); - - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_OPEN_TARGET))).resolves.toEqual({ - kind: 'opened', - href: '/(app)/(tabs)/(2_agents)', - }); - - // The button carries no URL of its own: with nothing recorded there is no - // session to open, and a press that stashes nothing is a dead control. The - // Agents tab is the destination the card's body deep-links to and the one - // Android's notification already falls back to. - expect(mocks.setPendingDeepLink).toHaveBeenCalledWith( - '/(app)/(tabs)/(2_agents)', - 'universal-link' - ); - expect(mocks.runGlanceableApprove).not.toHaveBeenCalled(); - }); - it('turns an escaping failure into a retryable press', async () => { mocks.readWaitingAsk.mockRejectedValue(new Error('storage unavailable')); - await expect(handleGlanceableInteraction(fromCard(GLANCEABLE_APPROVE_TARGET))).resolves.toEqual( - { - kind: 'retryable', - } - ); + await expect(approveFromCard()).resolves.toEqual({ kind: 'retryable' }); // With no readable ask there is nothing to name and no approvable action to - // promise, so the card carries no notice either. + // promise, so the card carries no notice and never showed the pill. expect(mocks.toastError).not.toHaveBeenCalled(); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); + expect(mocks.setGlanceableActionApproving).not.toHaveBeenCalled(); expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/glanceable-ios/interaction.ts b/apps/mobile/src/glanceable-ios/interaction.ts index 20a79553a6..0c78dfc6a5 100644 --- a/apps/mobile/src/glanceable-ios/interaction.ts +++ b/apps/mobile/src/glanceable-ios/interaction.ts @@ -1,6 +1,5 @@ -import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; // `expo-widgets` is iOS-only by capability (WidgetKit/ActivityKit); the type is -// the one press envelope both Live Activity targets arrive in. +// the press envelope the Live Activity's Approve arrives in. import { type UserInteractionEvent } from 'expo-widgets'; import { toast } from 'sonner-native'; @@ -10,15 +9,13 @@ import { type GlanceableApproveResult, runGlanceableApprove } from '@/lib/glance import { restorePersistedGlanceable } from '@/lib/glanceable/persist'; import { republishAnsweredAsk } from '@/lib/glanceable/republish-ask'; import { readWaitingAsk, recordWaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { setPendingDeepLink } from '@/lib/deep-link-launch'; -import { launcherSessionUrl } from '@/lib/launcher-surfaces'; +import { ActiveAgentsLiveActivity, LIVE_ACTIVITY_NAME } from './active-agents-live-activity'; import { - ActiveAgentsLiveActivity, - LIVE_ACTIVITY_NAME, - OPEN_AGENTS_URL, -} from './active-agents-live-activity'; -import { renderStoredSnapshotWithNotice, setGlanceableActionNotice } from './ios-sink'; + renderStoredSnapshotWithNotice, + setGlanceableActionApproving, + setGlanceableActionNotice, +} from './ios-sink'; /** * What a button press on the Live Activity does. @@ -26,20 +23,14 @@ import { renderStoredSnapshotWithNotice, setGlanceableActionNotice } from './ios * The press arrives as an in-process event: expo-widgets' `LiveActivityIntent` * performs in the app's process and posts to `NotificationCenter` * (`ios/Widgets/WidgetsEvents.swift`), and the app's subscription forwards it to - * `handleGlanceableInteraction`. Approve keeps that background path — it answers - * the ask without the app. Open would perform there unseen, so its button asks - * for the foreground instead (`openAppWhenRun`, read by the patched expo-widgets - * button view) and the destination it stashes is consumed with the app up. The - * answer and the navigation are the same bodies the in-app control uses — - * `runGlanceableApprove` for Approve, `resolveIncomingUrl` + the - * pending-deep-link slot for Open — so the surface and the app cannot disagree - * about either. + * `handleGlanceableInteraction`. Approve answers the ask without the app, through + * the same `runGlanceableApprove` body the in-app control uses, so the surface + * and the app cannot disagree. The card's body opens the agents list on its own + * deep link; the card has no other control. */ /** The Approve target the layout's button carries. */ export const GLANCEABLE_APPROVE_TARGET = 'approve'; -/** The Open target the layout's button carries. */ -export const GLANCEABLE_OPEN_TARGET = 'open'; /** The one failure line the toast carries; the card keeps its Approve tap. */ const APPROVE_FAILED_KEY = 'glanceable.approveFailed'; @@ -50,10 +41,6 @@ export type GlanceableInteractionOutcome = | { kind: 'ignored' } /** A press from this surface carrying a target no button declares. */ | { kind: 'unhandled' } - /** Open landed on the recorded session, or on the Agents tab when none was recorded. */ - | { kind: 'opened'; href: string } - /** Open whose target the app's router could not resolve; nothing was stashed. */ - | { kind: 'no_session' } | GlanceableApproveResult; /** @@ -68,10 +55,10 @@ export type GlanceableInteractionOutcome = * mistaken for a foreign one. Any other source is another layout's press. * * Ended instances count: `getInstances()` omits them by default, but a terminal - * card stays on screen until ActivityKit dismisses it, and this layout draws - * Open on it for that whole window, so a press from it is a press on a control - * the user can see and must route like any other rather than drop. `includeEnded` - * still excludes dismissed cards, which no press can come from. + * card stays on screen until ActivityKit dismisses it, and a press from it is a + * press on a control the user can see and must route like any other rather + * than drop. `includeEnded` still excludes dismissed cards, which no press can + * come from. */ function isActiveAgentsLiveActivity(source: string): boolean { if (source === LIVE_ACTIVITY_NAME) { @@ -117,6 +104,20 @@ async function showApproveFailed(): Promise<void> { } } +/** + * Show the press on the card: Approve becomes a muted Approving… pill, and the + * card hides an earlier failure line while the retry it asked for runs. + * Best effort — the answer below still runs when the card cannot be updated. + */ +async function showApproving(): Promise<void> { + setGlanceableActionApproving(true); + try { + await renderStoredSnapshotWithNotice(); + } catch { + // The answer still runs; the flag is cleared and re-rendered after it. + } +} + /** * Answer the recorded ask through the shared flow and drop the action once it * is answered. The record is captured before the flow runs, because a @@ -136,56 +137,47 @@ async function approveFromCard(): Promise<GlanceableInteractionOutcome> { // signed-out account or another organization and answer it. await restorePersistedGlanceable(); const ask = await readWaitingAsk(); - const result = await approveResult(); - if (result.kind === 'retryable') { - // The record stays, so the card keeps Approve for another tap. The toast - // only reaches the user with the app up, so the card itself carries the - // failure line as well. - toast.error(i18n.t(APPROVE_FAILED_KEY)); - await showApproveFailed(); + await showApproving(); + let failureShown = false; + try { + const result = await approveResult(); + // The answer is in: the published states below no longer carry the pill. + setGlanceableActionApproving(false); + if (result.kind === 'retryable') { + // The record stays, so the card keeps Approve for another tap. The toast + // only reaches the user with the app up, so the card itself carries the + // failure line as well. + toast.error(i18n.t(APPROVE_FAILED_KEY)); + failureShown = true; + await showApproveFailed(); + return result; + } + if (result.kind === 'gone') { + // Answered elsewhere or no longer pending: dropping the record drops the + // tap, the same way the Android headless task drops it. + recordWaitingAsk(null); + } + if (ask !== null) { + // The republish skips that session's stale tray row only for an ended + // ask; a `none` outcome leaves the ask as it is, so re-selecting it + // keeps the session the card names. + await republishAnsweredAsk(ask, result.kind === 'approved' || result.kind === 'gone'); + } return result; + } finally { + // Every outcome — answered, gone, nothing to answer, failed, or a throw — + // ends the in-flight pill. The republish publishes only a changed snapshot, + // so the card is re-rendered here to drop the pill either way; a failure + // already re-rendered with its line. + setGlanceableActionApproving(false); + if (!failureShown) { + try { + await renderStoredSnapshotWithNotice(); + } catch { + // The next publish corrects the surface. + } + } } - if (result.kind === 'gone') { - // Answered elsewhere or no longer pending: dropping the record drops the - // tap, the same way the Android headless task drops it. - recordWaitingAsk(null); - } - if (ask !== null) { - // The republish skips that session's stale tray row only for an ended ask; a - // `none` outcome leaves the ask as it is, so re-selecting it keeps the - // session Open names. - await republishAnsweredAsk(ask, result.kind === 'approved' || result.kind === 'gone'); - } - return result; -} - -/** - * Land on the recorded waiting session through the app's own deep-link path. - * The id is the only thing kept beside the privacy-minimal snapshot, and it - * goes into the URL and nowhere else. The hydrated read is the same one the - * headless paths use: a press that launched the process in the background has - * no in-memory record yet, only the mirrored one. - * - * With nothing recorded there is no session to open, and the button itself - * carries no URL — so Open falls back to the Agents tab, the destination the - * card's body deep-links to and the one Android's notification already uses. - * A press that stashes nothing would be the dead control this button must not - * be; guessing a session stays the one thing it must not do. - */ -async function openRecordedSession(): Promise<GlanceableInteractionOutcome> { - // The same stored scope as the Approve press: the persisted glanceable is - // what the mirrored ask is fenced against, and this press can arrive before - // the app root restores it. - await restorePersistedGlanceable(); - const ask = await readWaitingAsk(); - const href = resolveIncomingUrl( - ask === null ? OPEN_AGENTS_URL : launcherSessionUrl(ask.kiloSessionId) - ); - if (href === null) { - return { kind: 'no_session' }; - } - setPendingDeepLink(href, 'universal-link'); - return { kind: 'opened', href }; } /** Route one button press from the Live Activity. */ @@ -199,12 +191,9 @@ export async function handleGlanceableInteraction( if (event.target === GLANCEABLE_APPROVE_TARGET) { return await approveFromCard(); } - if (event.target === GLANCEABLE_OPEN_TARGET) { - return await openRecordedSession(); - } } catch { - // An escaping throw is a failed press, not a crash: the card keeps both - // buttons, and the tap that failed is the tap the user can repeat. + // An escaping throw is a failed press, not a crash: the card keeps its + // Approve, and the tap that failed is the tap the user can repeat. return { kind: 'retryable' }; } return { kind: 'unhandled' }; diff --git a/apps/mobile/src/glanceable-ios/ios-sink.ts b/apps/mobile/src/glanceable-ios/ios-sink.ts index e7b519f53a..601358e961 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.ts @@ -104,6 +104,18 @@ function pruneActionNotice(snapshot: GlanceableAgentsSnapshot): void { } } +/** + * Whether an Approve pressed on the card is in flight. The card swaps Approve + * for a muted Approving… pill until the press resolves; the interaction clears + * it on every outcome, so it can never stick. + */ +let approving = false; + +/** Mark (or clear) the in-flight Approve the next Live Activity update draws. */ +export function setGlanceableActionApproving(value: boolean): void { + approving = value; +} + /** * The content-state one update carries: the counts, the Approve gate, and the * pending notice when there is one. Every Live Activity update goes through @@ -111,11 +123,12 @@ function pruneActionNotice(snapshot: GlanceableAgentsSnapshot): void { */ function liveActivityContentState(snapshot: GlanceableAgentsSnapshot): GlanceableLiveActivityProps { pruneActionNotice(snapshot); - return buildGlanceableLiveActivityContentState( + const contentState = buildGlanceableLiveActivityContentState( snapshot, isApprovableAskRecorded(), actionNotice ?? undefined ); + return approving ? { ...contentState, approving: true } : contentState; } /** @@ -462,6 +475,7 @@ export function _resetIosSinkForTests(): void { pendingStartAt = 0; actionNotice = null; noticeAskKey = null; + approving = false; } export const iosSink: GlanceableSink = { diff --git a/apps/mobile/src/glanceable-ios/layout-copy.test.ts b/apps/mobile/src/glanceable-ios/layout-copy.test.ts index 7257fd63c7..2e04d5bfc7 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.test.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.test.ts @@ -11,23 +11,9 @@ import { glanceableLayoutCopy, withGlanceableCopy } from './layout-copy'; const PLACEHOLDER = '__KILO_GLANCEABLE_COPY__'; const LAYOUT_FILE = 'active-agents-live-activity.tsx'; const LAYOUT_FILES = [LAYOUT_FILE, 'active-agents-widget.tsx']; -/** - * The module that routes a Live Activity press. Read, never imported: it loads - * the native widget modules a node test cannot. - */ -const INTERACTION_FILE = 'interaction.ts'; const read = (file: string) => readFileSync(join(__dirname, file), 'utf8'); -/** The literal a source file declares for its exported `name`. */ -function declaredLiteral(file: string, name: string): string { - const match = new RegExp(`export const ${name} = '([^']+)'`).exec(read(file)); - if (match === null) { - throw new Error(`${file} declares no ${name}`); - } - return match[1] ?? ''; -} - /** Stands in for an untransformed layout, which is a function, not a string. */ const untransformedLayout = () => null; @@ -50,104 +36,6 @@ describe('glanceable layout copy placeholder', () => { } }); -describe('glanceable approve target', () => { - const source = read(LAYOUT_FILE); - - /** - * Extract one section of the returned layout object, from its key to the next - * key at the same indentation. The `'widget'` layout never runs under vitest, - * so the source text is the only place its sections are observable. - */ - const section = (key: string): string => { - const pattern = new RegExp(`\\n {4}${key}: ([\\s\\S]*?)(?=\\n {4}[A-Za-z]+: |\\n {2}\\};\\n)`); - const match = source.match(pattern); - expect(match).not.toBeNull(); - return match?.[1] ?? ''; - }; - - it('is a literal in the layout, equal to the target interaction.ts routes', () => { - // The handler matches the event's `target` against - // `GLANCEABLE_APPROVE_TARGET` (interaction.ts), and the widget process reads - // the target off the layout source. A `target` written as the imported - // identifier would be an undefined global there, so the literal is the - // contract: this reads the sources because no widget transform runs under - // vitest. - const targets = [...source.matchAll(/target=(['"])([^'"]*)\1/g)].map(match => match[2]); - expect(targets).toContain(declaredLiteral(INTERACTION_FILE, 'GLANCEABLE_APPROVE_TARGET')); - expect(source).not.toMatch(/target=\{/); - }); - - it('is gated on the recorded ask and the approvable count, never the count alone', () => { - // The control has to be one its own press can answer: the press resolves - // the ask the app recorded, so the app's `canApprove` flag withholds it, - // and the approvable count alone cannot gate it because a permission row - // the control plane does not own is `needsApproval` but not approvable. - // Both count terms are still required: `needsInput` withholds the control - // from the expired frame `withStatus` (lib/glanceable/publisher) leaves - // `needsApproval` on, and `needsApproval` withholds it from a server-written - // question-only state, where `runGlanceableApprove` answers `none`. - // `view-props.test.ts` pins that expired shape. - expect(source).toContain('props.canApprove !== false'); - expect(source).toMatch(/\(props\.needsInput \?\? 0\) > 0/); - expect(source).toMatch(/\(props\.needsApproval \?\? 0\) > 0/); - expect(source).not.toMatch(/const needsApproval/); - }); - - it('declares the bannerSmall section the Apple Watch and CarPlay draw', () => { - // expo-widgets' banner view renders `nodes["bannerSmall"]` when the activity - // family is `.small`, falling back to the phone `banner` when the key is - // absent. The key itself, not a mention in a comment, is what reaches the - // widget process. - expect(source).toContain('bannerSmall:'); - expect(section('bannerSmall')).not.toBe(''); - }); - - it('carries the control in the bannerSmall block under the wait gate', () => { - // The watch draws one row and the Approve control after it. The press has to - // answer on the wrist, so the block itself must hold the literal target and - // the same gate the phone banner uses: `canApprove`, the flag that says the - // recorded ask is one the press can answer. - const block = section('bannerSmall'); - expect(block).toContain('target="approve"'); - expect(block).toMatch(/\{canApprove \? \(/); - expect(block).not.toMatch(/needsApproval/); - }); - - it('draws the watch card count row outside the Approve gate', () => { - // The wrist card must show a waiting agent's count even when nothing is - // approvable: only the control is conditional. The ranked primary row draws - // from the count payload, and the gate that hides the control must sit after - // it, so a permission-less wait still reads on the watch. - const block = section('bannerSmall'); - const gate = block.indexOf('{canApprove ? ('); - expect(gate).toBeGreaterThanOrEqual(0); - expect(block.slice(0, gate)).toContain('countRow(primary, true, false)'); - expect(block.slice(gate)).toContain('target="approve"'); - }); - - it('keeps the phone banner drawing its own control', () => { - // The Lock Screen banner and the expanded island share `markAndRows`, which - // holds the Button; the whole-source literal-target and gate assertions - // above pin that control. The phone `banner` block must still draw the - // shared block, so the small section is an addition, not a move. - expect(section('banner')).toContain('markAndRows'); - }); - - it('registers under the Live Activity name, not the source the native event reports', () => { - // The native intent reports `context.activityID` as the event's `source` - // (`WidgetLiveActivity.swift` renders the layout with - // `name: context.activityID`, and `DynamicView.swift` copies that name onto - // the button's `source`), so the handler matches the unique `approve` target - // instead of a source name. The registration name still has to be this - // value: the activity's content state carries it, and it is what makes the - // card mirror into the Apple Watch Smart Stack. - expect(declaredLiteral(LAYOUT_FILE, 'LIVE_ACTIVITY_NAME')).toBe('ActiveAgentsLiveActivity'); - expect(source).toMatch(/createLiveActivity<ContentState>\(\s*LIVE_ACTIVITY_NAME,/); - // The handler recognises a press from this surface by that same constant. - expect(read(INTERACTION_FILE)).toContain('source === LIVE_ACTIVITY_NAME'); - }); -}); - describe('withGlanceableCopy', () => { it('leaves the untransformed function alone', () => { expect(withGlanceableCopy(untransformedLayout)).toBe(untransformedLayout); @@ -191,14 +79,27 @@ describe('withGlanceableCopy', () => { expect(copy.awaitingUpdate).not.toBe('glanceable.awaitingUpdate'); }); - it('bakes both Live Activity action labels from the reviewed keys', () => { + it('bakes the Home redesign slots from reviewed keys and a one-character group separator', () => { + const copy = glanceableLayoutCopy(); + expect(copy).toMatchObject({ + approving: i18n.t('glanceable.approving'), + couldNotApprove: i18n.t('glanceable.couldNotApprove'), + approveFailed: i18n.t('glanceable.approveFailed'), + recent: i18n.t('common.recent'), + nextScheduled: i18n.t('glanceable.nextScheduled'), + group: ',', + }); + // The failure footer nests the Approve label; an unresolved nesting would leak `$t(`. + expect(copy.approveFailed).not.toContain('$t('); + expect(copy.recent).not.toBe('common.recent'); + }); + + it('bakes the Live Activity Approve label from the reviewed key', () => { // A missing key would come back as the key itself, so the copy is asserted // against the catalog and not only against the slot. const copy = glanceableLayoutCopy(); expect(copy.approve).toBe(i18n.t('common.approve')); expect(copy.approve).not.toBe('common.approve'); - expect(copy.open).toBe(i18n.t('glanceable.openSession')); - expect(copy.open).not.toBe('glanceable.openSession'); }); it('bakes the scheduled row label from the reviewed key', () => { @@ -211,6 +112,6 @@ describe('withGlanceableCopy', () => { const copy = glanceableLayoutCopy(); expect(copy.scheduled).toBe(i18n.t('common.scheduled')); expect(copy.scheduled).not.toBe('common.scheduled'); - expect(read(LAYOUT_FILE)).toContain('COPY.scheduled'); + expect(copy.scheduled).not.toBe(''); }); }); diff --git a/apps/mobile/src/glanceable-ios/layout-copy.ts b/apps/mobile/src/glanceable-ios/layout-copy.ts index 130bd9c18b..518a04c217 100644 --- a/apps/mobile/src/glanceable-ios/layout-copy.ts +++ b/apps/mobile/src/glanceable-ios/layout-copy.ts @@ -10,8 +10,7 @@ import { numberFormat } from '@/lib/intl-cache'; * from the timeline props, but the Live Activity cannot: the notifications * Worker pushes the same raw content state and knows no locale, so a * background push would draw English on a localized device. The copy is - * therefore baked into the layout source at registration, the same boundary - * `withWidgetLogo` uses for the app-group path of the mark. + * therefore baked into the layout source at registration. */ /** @@ -58,15 +57,12 @@ export function glanceableLayoutCopy() { scheduled: i18n.t('common.scheduled'), idle: i18n.t('common.idle'), openAgents: i18n.t('glanceable.openAgents'), - // The Live Activity's two buttons and the widget's in-place actions all read + // The Live Activity's button and the widget's in-place actions all read // `common.approve`: the permission card, the wrist control, and the widget // button name the same action, and `check:i18n` forbids a second key with the - // same copy. Approve answers the recorded ask; Open navigates to the recorded - // session, under the key the Android notification's Open action reads, so the - // two surfaces cannot drift. The action copy is baked, not pushed through - // props: the gallery placeholder has no props at all. + // same copy. The action copy is baked, not pushed through props: a card + // started by a server push carries no copy at all. approve: i18n.t('common.approve'), - open: i18n.t('glanceable.openSession'), newAgent: i18n.t('glanceable.newAgent'), checked: i18n.t('glanceable.checked'), lastKnown: i18n.t('glanceable.lastKnown'), @@ -80,6 +76,13 @@ export function glanceableLayoutCopy() { permissionRequired: i18n.t('agentChat.permissionCard.title'), answerNeeded: i18n.t('glanceable.answerNeeded'), waitingToRetry: i18n.t('glanceable.waitingToRetry'), + // Home widget slots added with the redesign; the Live Activity reads none of them. + approving: i18n.t('glanceable.approving'), + couldNotApprove: i18n.t('glanceable.couldNotApprove'), + approveFailed: i18n.t('glanceable.approveFailed'), + recent: i18n.t('common.recent'), + nextScheduled: i18n.t('glanceable.nextScheduled'), + group: glanceableGroupSeparator(), locale: resolveGlanceableLocale(i18n.language), digits: glanceableDigits(), }; @@ -123,10 +126,22 @@ function glanceableDigits(): string { return digits === '0123456789' ? '' : digits; } +/** + * The active language's thousands separator, or an empty string when it has + * none or writes more than one character there. The widget process has no + * formatter, so the Home layout groups its own counts with this ("1,234", + * "1.234", "١٬٢٣٤"). + */ +function glanceableGroupSeparator(): string { + const grouped = numberFormat(i18n.language, { useGrouping: true }).format(10_000); + const separator = grouped.slice(2, -3); + return separator.length === 1 ? separator : ''; +} + /** * Resolve the copy placeholder inside a stringified `'widget'` layout. * - * This is the same two-representation boundary as `withWidgetLogo`: Babel's + * This is a two-representation boundary: Babel's * widget plugin replaces a `'widget'` function with a template literal of its * source, so the layout is a string in the app while a unit test (which runs * no widget transform) still holds the real function. Only the string form diff --git a/apps/mobile/src/glanceable-ios/register.test.ts b/apps/mobile/src/glanceable-ios/register.test.ts index 97f40d3b80..70e76731df 100644 --- a/apps/mobile/src/glanceable-ios/register.test.ts +++ b/apps/mobile/src/glanceable-ios/register.test.ts @@ -50,7 +50,6 @@ vi.mock('./active-agents-widget', () => ({ vi.mock('./widget-actions', () => ({ registerWidgetActionHandling: mocks.registerWidgetActionHandling, })); -vi.mock('./widget-logo', () => ({ ensureWidgetLogo: vi.fn() })); vi.mock('@/i18n', () => ({ i18n: { on: vi.fn(), t: (key: string) => key } })); vi.mock('@/lib/glanceable/live-activity-switch', () => ({ getLiveActivityEnabled: () => true, diff --git a/apps/mobile/src/glanceable-ios/register.ts b/apps/mobile/src/glanceable-ios/register.ts index d12768268a..64d14da0c4 100644 --- a/apps/mobile/src/glanceable-ios/register.ts +++ b/apps/mobile/src/glanceable-ios/register.ts @@ -22,7 +22,6 @@ import { refreshActiveAgentsWidgetCopy } from './active-agents-widget'; import { handleGlanceableInteraction } from './interaction'; import { iosSink, sweepStrayActivities } from './ios-sink'; import { registerWidgetActionHandling } from './widget-actions'; -import { ensureWidgetLogo } from './widget-logo'; type InteractionSubscription = ReturnType<typeof addUserInteractionListener>; @@ -75,10 +74,10 @@ if (Platform.OS === 'ios') { // `approve` and `open` targets, so a press is answered by exactly one of them. registerWidgetActionHandling(); - // Copy the Kilo mark into the shared app group so the widget extension can read - // it. Fire and forget: it lands long before the first snapshot arrives, and a - // failure only costs the logo. - void ensureWidgetLogo(); + // The widget gallery shows the approved Needs input layouts with sample + // agents; this writes that preview in the language i18n holds now, and the + // language listener below re-writes it once the stored language applies. + refreshActiveAgentsWidgetCopy(); // Claim a card raised by a push-to-start before anything else runs. iOS grants // this process background run time for exactly that, and the server cannot diff --git a/apps/mobile/src/glanceable-ios/view-props.test.ts b/apps/mobile/src/glanceable-ios/view-props.test.ts index b1ddba2e08..c1dd5ce588 100644 --- a/apps/mobile/src/glanceable-ios/view-props.test.ts +++ b/apps/mobile/src/glanceable-ios/view-props.test.ts @@ -260,7 +260,7 @@ describe('Home-only presentation and timeline', () => { canCreate: true, canApprove: true, }); - expect(props.actionLine).toBe('Could not approve'); + expect(props.actionFeedback).toBe('couldNotApprove'); }); it.each([{ signedOut: true }, { orgInvalid: true }])( diff --git a/apps/mobile/src/glanceable-ios/view-props.ts b/apps/mobile/src/glanceable-ios/view-props.ts index 01a0f37c7d..389db7744f 100644 --- a/apps/mobile/src/glanceable-ios/view-props.ts +++ b/apps/mobile/src/glanceable-ios/view-props.ts @@ -1,13 +1,16 @@ import { + buildGlanceableSnapshot, GLANCEABLE_STALE_MS, type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { + buildHomeWidgetData, buildHomeWidgetPresentation, buildHomeWidgetPresentationTimeline, EMPTY_HOME_WIDGET_DETAILS, type HomeWidgetData, type HomeWidgetPresentation, + type HomeWidgetSessionRow, } from '@kilocode/app-shared/home-widget'; import { type GlanceableLiveActivityContentState } from '@kilocode/notifications'; @@ -22,7 +25,11 @@ import { primaryGlanceableCount, resolveGlanceableStatus, } from '@/lib/glanceable/presentation'; -import { getSurfaceExtras, type GlanceableSurfaceExtras } from '@/lib/glanceable/surface-extras'; +import { + getSurfaceExtras, + type GlanceableActionFeedback, + type GlanceableSurfaceExtras, +} from '@/lib/glanceable/surface-extras'; /** One translated count line. `kind` picks the glyph and the color. */ type GlanceableCount = { label: string; kind: GlanceableCountKind; count: number }; @@ -50,12 +57,11 @@ export type GlanceableViewProps = { */ newestTitle: string | null; /** - * The in-flight action's progress or failure line alone (the same copy - * `newestTitle` carries while an Approve is answered), or null. The large - * card has no reserved slot — its footer already names the newest result — - * so it draws only this line, under its header. + * The in-flight Approve's state on a content card, or null. The Home layout + * owns the copy and the placement: Approving… replaces the Approve control, + * and a failure keeps Approve and prints its line in the detail or footer. */ - actionLine: string | null; + actionFeedback: GlanceableActionFeedback; /** The two in-place actions the state offers. Disabled actions draw no button. */ actions: { approve: boolean; newAgent: boolean }; /** @@ -196,14 +202,7 @@ export function buildGlanceableViewProps( primaryKind: primary === null ? null : primary.kind, primaryCount: primary === null ? 0 : primary.count, newestTitle, - actionLine: - home.status !== 'content' || extras.actionFeedback === null - ? null - : translate( - extras.actionFeedback === 'approving' - ? 'glanceable.approving' - : 'glanceable.couldNotApprove' - ), + actionFeedback: home.status === 'content' ? extras.actionFeedback : null, actions: { approve: home.canApprove, newAgent: home.canCreate, @@ -261,6 +260,50 @@ function buildStaleWidgetProps( return toWidgetProps(buildGlanceableViewProps({ ...snapshot, status: 'stale' }, {}, translate)); } +/** + * The sample the widget gallery draws: the approved Needs input layout with + * two waiting agents and others working, scheduled and idle, checked at 9:41. + * The gallery is inside the unlocked app, so sample titles are allowed; no + * account data is read. + */ +const GALLERY_SESSIONS: HomeWidgetSessionRow[] = [ + { status: 'permission', title: 'Review the release', approvalKey: '0'.repeat(64) }, + { status: 'question', title: 'Pick a color for the badge' }, + { status: 'busy' }, + { status: 'busy' }, + { status: 'busy' }, + { status: 'scheduled' }, + { status: 'idle' }, + { status: 'idle' }, +]; + +/** Props for the gallery preview kind the widget extension draws in the picker. */ +export function buildGalleryPreviewProps( + translate: (key: string) => string, + now = Date.now() +): Partial<GlanceableViewProps> { + const checked = new Date(now); + checked.setHours(9, 41, 0, 0); + const input = { + sessions: GALLERY_SESSIONS, + userId: 'gallery', + organizationId: null, + now: checked.getTime(), + }; + const snapshot = buildGlanceableSnapshot(input); + return toWidgetProps({ + ...buildGlanceableViewProps( + snapshot, + {}, + translate, + buildHomeWidgetData(input), + checked.getTime() + ), + // An Approve in flight belongs to the placed widget, never the sample. + actionFeedback: null, + }); +} + /** * The delayed frame, when there is a claim worth retracting. Counts only: an * empty or waiting surface asserts nothing that can go out of date, and a @@ -315,6 +358,8 @@ export type GlanceableLiveActivityProps = GlanceableLiveActivityContentState & { canApprove?: boolean; /** Translated failure line for the next update; omitted when there is none. */ notice?: string; + /** An Approve pressed on the card is in flight; omitted otherwise. */ + approving?: boolean; }; /** The status a later timeline frame draws: expired at expiry, stale once the window lapses. */ @@ -405,6 +450,7 @@ export function buildGlanceableLiveActivityContentState( needsInput: snapshot.needsInput, needsApproval: snapshot.needsApproval ?? 0, idle: snapshot.idle, + updatedAt: snapshot.updatedAt, needsInputSince: snapshot.needsInputSince, scheduled: snapshot.scheduled, scheduledAt: snapshot.scheduledAt, diff --git a/apps/mobile/src/glanceable-ios/widget-actions.test.ts b/apps/mobile/src/glanceable-ios/widget-actions.test.ts index 822762a9c0..f353d003c1 100644 --- a/apps/mobile/src/glanceable-ios/widget-actions.test.ts +++ b/apps/mobile/src/glanceable-ios/widget-actions.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- one cohesive sweep + widget-layout suite sharing the expo-widgets/@expo/ui mock harness */ +/* eslint-disable max-lines -- one cohesive sweep suite sharing the expo-widgets mock harness */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -7,7 +7,6 @@ import { type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { - buildHomeWidgetPresentation, EMPTY_HOME_WIDGET_DETAILS, type HomeWidgetDetails, } from '@kilocode/app-shared/home-widget'; @@ -16,9 +15,9 @@ import { setHomeWidgetDetails } from '@/lib/glanceable/home-widget-data'; import { _setLastGlanceableSnapshotForTests } from '@/lib/glanceable/persist'; import { getSurfaceExtras, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; -import { type UserInteractionEvent, type WidgetFamily } from 'expo-widgets'; +import { type UserInteractionEvent } from 'expo-widgets'; -import { activeAgentsWidgetLayout, WIDGET_NAME } from './active-agents-widget'; +import { WIDGET_NAME } from './active-agents-widget'; import { PENDING_ACTION_TTL_MS, pendingActionForEvent, @@ -26,12 +25,12 @@ import { registerWidgetActionHandling, runPendingWidgetActions, } from './widget-actions'; -import { buildGlanceableViewProps, type GlanceableWidgetProps } from './view-props'; +import { buildGlanceableViewProps } from './view-props'; // The widget surfaces are unreachable under vitest: the swift-ui primitives are // recording stubs, react-native is stubbed, and expo-widgets' factories are -// stubs with a controllable timeline — so the sweep and the stringified layout -// are the real logic under test. +// stubs with a controllable timeline — so the sweep is the real logic under +// test. The layout has its own suite in active-agents-widget.test.ts. const widgetState = vi.hoisted(() => ({ timeline: [] as { date: Date; props: Record<string, unknown> }[], snapshots: [] as unknown[], @@ -159,64 +158,6 @@ function snapshotFor(sessions: { status: string }[], now = NOW): GlanceableAgent }); } -const SCHEDULED_WAKE = '2026-09-24T09:00:00.000Z'; - -// ── mock-element tree helpers, shared with the render suite ───────────────── - -type MockElement = { kind: string; props: Record<string, unknown> }; - -function collect(node: unknown): MockElement[] { - if (node == null || typeof node !== 'object' || Array.isArray(node)) { - return Array.isArray(node) ? node.flatMap(item => collect(item)) : []; - } - const kind = (node as { type?: { kind?: string } }).type?.kind; - const props = (node as { props?: Record<string, unknown> }).props; - if (kind === undefined || props === undefined) { - return []; - } - return [{ kind, props }, ...collect(props.children)]; -} - -function collectText(node: unknown): string[] { - return collect(node) - .filter(element => element.kind === 'Text' && typeof element.props.children === 'string') - .map(element => element.props.children as string); -} - -function collectOfKind(node: unknown, kind: string): MockElement[] { - return collect(node).filter(element => element.kind === kind); -} - -/** Every `widgetURL` modifier argument anywhere in the tree. */ -function widgetURLs(node: unknown): unknown[] { - return collect(node).flatMap(element => { - const modifiers = element.props.modifiers; - if (!Array.isArray(modifiers)) { - return []; - } - return modifiers - .filter((modifier: { $type?: string }) => modifier.$type === 'widgetURL') - .map((modifier: { args?: unknown }) => modifier.args); - }); -} - -function pressButton( - tree: unknown, - patch: { pendingAction: string; pendingApprovalKey?: string } -): MockElement | undefined { - return collect(tree).find(element => { - if (element.kind !== 'Button' || typeof element.props.onPress !== 'function') { - return false; - } - const pressed = (element.props.onPress as () => Record<string, unknown>)(); - return ( - pressed.pendingAction === patch.pendingAction && - (patch.pendingApprovalKey === undefined || - pressed.pendingApprovalKey === patch.pendingApprovalKey) - ); - }); -} - const APPROVAL_KEY = 'a'.repeat(64); const APPROVABLE: HomeWidgetDetails = { ...EMPTY_HOME_WIDGET_DETAILS, approvalKey: APPROVAL_KEY }; @@ -764,310 +705,3 @@ describe('registerWidgetActionHandling', () => { expect(widgetState.listeners).toHaveLength(1); }); }); - -// ── the stringified layout ────────────────────────────────────────────────── - -function renderWidget(props: GlanceableWidgetProps, family: WidgetFamily): MockElement { - return activeAgentsWidgetLayout(props, { - widgetFamily: family, - date: new Date(0), - configuration: undefined, - }) as unknown as MockElement; -} - -const HOME_FAMILIES: WidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge']; -const ACCESSORY_FAMILIES: WidgetFamily[] = [ - 'accessoryCircular', - 'accessoryInline', - 'accessoryRectangular', -]; - -function homeProps( - sessions: Parameters<typeof buildGlanceableSnapshot>[0]['sessions'], - details: HomeWidgetDetails = EMPTY_HOME_WIDGET_DETAILS, - now = NOW -): GlanceableWidgetProps { - const snapshot = buildGlanceableSnapshot({ - sessions, - userId: 'u1', - organizationId: null, - now: NOW, - }); - return buildGlanceableViewProps(snapshot, {}, key => key, { snapshot, details }, now); -} - -describe('activeAgentsWidgetLayout', () => { - it('keeps accessory families privacy-minimal despite Home titles and actions', () => { - const props = homeProps([{ status: 'permission' }], { - approvalKey: APPROVAL_KEY, - primaryTitle: 'Private title', - waitingAgents: [{ title: 'Private title', kind: 'permission' }], - scheduledAgents: [], - }); - for (const family of ACCESSORY_FAMILIES) { - const tree = renderWidget(props, family); - expect(collectOfKind(tree, 'Button')).toEqual([]); - expect(collectText(tree)).not.toContain('Private title'); - expect(widgetURLs(tree)).toEqual(['kiloapp:///cloud/sessions']); - } - }); - - it('draws a state glyph, never a placeholder dash, in the count-less circular', () => { - const expected = { - waiting: 'arrow.triangle.2.circlepath', - empty: 'checkmark.circle.fill', - privacy: 'lock.fill', - signed_out: 'person.fill', - } as const; - for (const status of Object.keys(expected) as (keyof typeof expected)[]) { - const snapshot = { ...snapshotFor([]), status }; - const home = buildHomeWidgetPresentation( - { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }, - NOW - ); - const tree = renderWidget({ home }, 'accessoryCircular'); - const images = collectOfKind(tree, 'Image').map(image => image.props.systemName); - expect(images).toEqual([expected[status]]); - expect(collectText(tree)).not.toContain('—'); - } - }); - - it('names up to two waiting agents in the medium column and falls back to counts', () => { - const waiting = renderWidget( - homeProps([{ status: 'permission' }], { - approvalKey: null, - primaryTitle: 'First wait', - waitingAgents: [ - { title: 'First wait', kind: 'permission' }, - { title: 'Second wait', kind: 'question' }, - { title: 'Third wait', kind: 'retry' }, - ], - scheduledAgents: [], - }), - 'systemMedium' - ); - const texts = collectText(waiting); - expect(texts).toContain('First wait'); - expect(texts).toContain('Second wait'); - expect(texts).not.toContain('Third wait'); - expect(texts).toContain('Permission required'); - // No entry rows: the column falls back to the support counts. - const counts = renderWidget( - homeProps([{ status: 'busy' }, { status: 'idle' }]), - 'systemMedium' - ); - expect(collectText(counts)).toContain('Working'); - expect(collectText(counts)).toContain('Idle'); - }); - - it.each(HOME_FAMILIES)( - 'keeps New agent navigation available alongside approval in %s', - family => { - const tree = renderWidget(homeProps([{ status: 'permission' }], APPROVABLE), family); - const newAgent = pressButton(tree, { pendingAction: 'new-agent' }); - if (newAgent === undefined) { - throw new Error(`New agent button missing in ${family}`); - } - expect(newAgent.props.openAppWhenRun).toBe(true); - // The press records its own time, so the extension carries the marker only - // while the press is younger than the TTL and a fresh press always resets it. - const patch = (newAgent.props.onPress as () => { pendingActionAt: number })(); - expect(Math.abs(patch.pendingActionAt - Date.now())).toBeLessThan(1000); - const approve = pressButton(tree, { - pendingAction: 'approve', - pendingApprovalKey: APPROVAL_KEY, - }); - expect(approve).toBeDefined(); - expect(approve?.props.openAppWhenRun).toBeUndefined(); - expect(widgetURLs(tree)).toEqual(['kiloapp:///cloud/sessions']); - } - ); - - it.each(HOME_FAMILIES)( - 'offers no blind Approve without a displayed request key in %s', - family => { - expect( - pressButton(renderWidget(homeProps([{ status: 'permission' }]), family), { - pendingAction: 'approve', - }) - ).toBeUndefined(); - } - ); - - it.each(HOME_FAMILIES)( - 'holds both action hit slots in the same header across states in %s', - family => { - const states = [ - homeProps([{ status: 'permission' }], APPROVABLE), - homeProps([]), - homeProps([{ status: 'busy' }]), - ]; - for (const props of states) { - const root = renderWidget(props, family); - const header = (root.props.children as { props: Record<string, unknown> }[])[0]; - const slots = ( - header?.props.children as { props: Record<string, unknown> }[] | undefined - )?.slice(-2); - expect(slots?.map(slot => slot.props.modifiers)).toEqual([ - [{ $type: 'frame', args: { width: 24, height: 24 } }], - [{ $type: 'frame', args: { width: 24, height: 24 } }], - ]); - } - } - ); - - it.each(HOME_FAMILIES)( - 'shows only nonzero Home counts and an honest checked footer in %s', - family => { - const props = homeProps([{ status: 'busy' }, { status: 'idle' }]); - const texts = collectText(renderWidget(props, family)); - expect(texts).toContain('Working'); - expect(texts).not.toContain('Needs input'); - expect(texts).not.toContain('Scheduled'); - expect(texts).toContain('Checked'); - // The square card has no room for a relative phrase, so it prints the - // clock time; the wide and large cards keep the relative form. - const footer = collectOfKind(renderWidget(props, family), 'Text').find( - text => text.props.dateStyle === (family === 'systemSmall' ? 'time' : 'ago') - ); - expect(footer?.props.date).toEqual(new Date(NOW)); - } - ); - - it.each(HOME_FAMILIES)('retains old counts without asserting current work in %s', family => { - const texts = collectText( - renderWidget( - homeProps([{ status: 'busy' }], EMPTY_HOME_WIDGET_DETAILS, NOW + 24 * 60 * 60 * 1000), - family - ) - ); - expect(texts).toContain('1'); - expect(texts).toContain('Last known'); - expect(texts).toContain('Checked'); - expect(texts).not.toContain('Status expired'); - }); - - it.each(HOME_FAMILIES)('renders the scheduled wake even in the small family: %s', family => { - const props = homeProps([{ status: 'scheduled', scheduledAt: SCHEDULED_WAKE }]); - const tree = renderWidget(props, family); - expect( - collectOfKind(tree, 'Text').some( - text => text.props.dateStyle === 'time' && text.props.date instanceof Date - ) - ).toBe(true); - expect(collectText(tree)).not.toContain('Idle'); - }); - - it.each(HOME_FAMILIES)('does not invent running after a missed wake in %s', family => { - const props = homeProps([ - { status: 'scheduled', scheduledAt: new Date(NOW - 1).toISOString() }, - ]); - const texts = collectText(renderWidget(props, family)); - expect(texts).toContain('Awaiting update'); - expect(texts).toContain('Scheduled'); - expect(texts).not.toContain('Working'); - }); - - it('shows bounded waiting and scheduled details in the large family', () => { - const waiting = renderWidget( - homeProps([{ status: 'question' }], { - approvalKey: null, - primaryTitle: 'First wait', - waitingAgents: [ - { title: 'First wait', kind: 'question' }, - { title: 'Second wait', kind: 'retry' }, - ], - scheduledAgents: [], - }), - 'systemLarge' - ); - expect(collectText(waiting)).toContain('Second wait'); - const scheduled = renderWidget( - homeProps([{ status: 'scheduled', scheduledAt: SCHEDULED_WAKE }], { - approvalKey: null, - primaryTitle: 'Later job', - waitingAgents: [], - scheduledAgents: [ - { title: 'Later job', scheduledAt: SCHEDULED_WAKE }, - { title: '', scheduledAt: null }, - ], - }), - 'systemLarge' - ); - expect(collectText(scheduled)).toContain('Agent'); - expect(collectText(scheduled)).toContain('Awaiting update'); - }); - - it.each(HOME_FAMILIES)( - 'keeps failure feedback visible with retained Home content in %s', - family => { - const tree = renderWidget( - { ...homeProps([{ status: 'permission' }], APPROVABLE), actionLine: 'Could not approve' }, - family - ); - expect(collectText(tree)).toContain('Could not approve'); - expect( - pressButton(tree, { pendingAction: 'approve', pendingApprovalKey: APPROVAL_KEY }) - ).toBeDefined(); - } - ); - - it.each(HOME_FAMILIES)('renders empty, waiting, auth and privacy deliberately in %s', family => { - for (const status of ['empty', 'waiting', 'signed_out', 'privacy'] as const) { - const snapshot = { ...snapshotFor([]), status }; - const home = buildHomeWidgetPresentation( - { snapshot, details: EMPTY_HOME_WIDGET_DETAILS }, - NOW - ); - const tree = renderWidget({ home }, family); - expect(collectText(tree)).toContain('Kilo'); - expect(collectText(tree).includes('Checked')).toBe(status === 'empty'); - expect(pressButton(tree, { pendingAction: 'approve' })).toBeUndefined(); - expect(pressButton(tree, { pendingAction: 'new-agent' }) !== undefined).toBe( - status === 'empty' - ); - } - }); - - it('contains malformed persisted props without throwing in any family', () => { - const corrupt = { - countLines: [null, { kind: 'unknown', count: 'bad', label: {} }], - primaryKind: 'unknown', - primaryLabel: {}, - primaryCount: Number.NaN, - home: { - status: 'unknown', - secondaryCounts: [null, { kind: 'unknown' }], - waitingAgents: [null], - scheduledAgents: {}, - checkedAt: 'bad', - scheduledAt: 'bad', - }, - } as unknown as GlanceableWidgetProps; - for (const family of [...HOME_FAMILIES, ...ACCESSORY_FAMILIES]) { - expect(() => renderWidget(corrupt, family)).not.toThrow(); - } - }); - - it('does not use broken minimum scaling for long labels and large counts', () => { - const props = homeProps([{ status: 'busy' }], { - ...EMPTY_HOME_WIDGET_DETAILS, - primaryTitle: 'A very long title '.repeat(20), - }); - expect(props.home).toBeDefined(); - props.home = props.home && { ...props.home, primaryCount: 1_234_567_890 }; - for (const family of HOME_FAMILIES) { - const tree = renderWidget(props, family); - expect(collectText(tree)).toContain('1234567890'); - expect( - collect(tree) - .flatMap(element => - Array.isArray(element.props.modifiers) - ? (element.props.modifiers as { $type?: string }[]) - : [] - ) - .some(modifier => modifier.$type === 'minimumScaleFactor') - ).toBe(false); - } - }); -}); diff --git a/apps/mobile/src/glanceable-ios/widget-logo.test.ts b/apps/mobile/src/glanceable-ios/widget-logo.test.ts deleted file mode 100644 index ea3ad05fc4..0000000000 --- a/apps/mobile/src/glanceable-ios/widget-logo.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- this test reads the layout sources from disk, which is the only place the placeholder is observable */ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -const PLACEHOLDER = '__KILO_WIDGET_LOGO_URI__'; -const LAYOUT_FILES = ['active-agents-live-activity.tsx', 'active-agents-widget.tsx']; - -const read = (file: string) => readFileSync(join(__dirname, file), 'utf8'); - -/** - * The `'widget'` layouts are stringified by Babel and re-evaluated inside the - * widget process, where an imported binding is an undefined global that throws - * and blanks the whole surface. So the placeholder must appear as a literal in - * each layout source, never as the imported `WIDGET_LOGO_PLACEHOLDER` - * identifier. These assertions read the sources because no widget transform - * runs under vitest. - */ -describe('widget logo placeholder', () => { - it('matches the token widget-logo.ts replaces', () => { - expect(read('widget-logo.ts')).toContain(`= '${PLACEHOLDER}'`); - }); - - for (const file of LAYOUT_FILES) { - it(`is a literal in ${file}`, () => { - const source = read(file); - expect(source).toContain(`= '${PLACEHOLDER}'`); - expect(source).not.toContain('logoUri = WIDGET_LOGO_PLACEHOLDER'); - }); - } -}); diff --git a/apps/mobile/src/glanceable-ios/widget-logo.ts b/apps/mobile/src/glanceable-ios/widget-logo.ts deleted file mode 100644 index 0c89debcad..0000000000 --- a/apps/mobile/src/glanceable-ios/widget-logo.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type * as ExpoFileSystem from 'expo-file-system'; -// eslint-disable-next-line no-restricted-imports -- the asset resolver, not the Image component -import { Image } from 'react-native'; -import { widgetsDirectory } from 'expo-widgets'; - -/** - * The Kilo mark the Live Activity and the widgets draw. - * - * The widget extension is a separate process: it cannot resolve a bundle asset, - * and the Live Activity content-state cannot carry the path either, because the - * notifications Worker produces the same shape and knows no device path. So the - * mark is copied into the shared app group once and its absolute path is baked - * into the stringified layouts at registration time — see `withWidgetLogo`. - */ - -const LOGO_FILE_NAME = 'kilo-logo.png'; - -/** - * The token the `'widget'` layouts carry until `withWidgetLogo` resolves it. - * - * Each layout repeats this literal inline rather than importing it: the widget - * transform stringifies the layout source, so an imported binding would be an - * undefined global in the widget process. `widget-logo.test.ts` keeps the two - * copies equal. - */ -const WIDGET_LOGO_PLACEHOLDER = '__KILO_WIDGET_LOGO_URI__'; - -// `widgetsDirectory` is typed `string`, but the iOS constant returns `String?` -// (nil without an app group) and the native module is absent on Android, so the -// value really is nullable. -const appGroupDirectory = widgetsDirectory as string | null; - -/** App-group path of the copied mark; empty when the app group is unavailable. */ -const WIDGET_LOGO_URI = appGroupDirectory === null ? '' : `${appGroupDirectory}${LOGO_FILE_NAME}`; - -/** - * Resolve the logo placeholder inside a stringified `'widget'` layout. - * - * This is the boundary between two representations of one value: Babel's - * widget plugin replaces a `'widget'` function with a template literal of its - * source, so the layout is a string in the app, while a unit test (which runs - * no widget transform) still holds the real function. Only the string form - * carries a placeholder to patch. - */ -export function withWidgetLogo<T>(layout: T): T { - // eslint-disable-next-line anti-slop/no-runtime-typeof -- the two representations are the contract; see above - if (typeof layout !== 'string') { - return layout; - } - const patched = layout.split(WIDGET_LOGO_PLACEHOLDER).join(WIDGET_LOGO_URI); - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- the layout source IS the component to expo-widgets - return patched as unknown as T; -} - -let copy: Promise<void> | null = null; - -/** - * Copy the bundled mark into the app group once per process. Idempotent and - * best effort: on failure the surfaces render without a logo, and the promise - * never rejects into a caller. - */ -export async function ensureWidgetLogo(): Promise<void> { - copy ??= copyLogo(); - await copy; -} - -async function copyLogo(): Promise<void> { - try { - if (WIDGET_LOGO_URI === '') { - return; - } - // Lazy require keeps expo-file-system's native module out of the pure test - // graph, the same reason the sink registry defers its Sentry import. - // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- lazy native load - const { File } = require('expo-file-system') as typeof ExpoFileSystem; - const target = new File(WIDGET_LOGO_URI); - if (target.exists) { - return; - } - // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- the Metro asset registry needs a static require - const assetModule = require('../../assets/images/logo-widget.png') as number; - const asset = Image.resolveAssetSource(assetModule); - if (asset.uri.startsWith('file://')) { - // Release build: the asset is a file inside the app bundle. - new File(asset.uri).copySync(target, { overwrite: true }); - return; - } - // Dev build: the asset is served by Metro over HTTP. - await File.downloadFileAsync(asset.uri, target, { idempotent: true }); - } catch { - // A missing logo is cosmetic; every surface renders without it. - } -} From 59aeb59824c9ebe5186c28f13dea724e73cc3a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:25:21 +0200 Subject: [PATCH 22/31] feat(mobile): rebuild Android widgets and ongoing card to the approved design --- .../ActiveAgentsLiveUpdateModule.kt | 135 ++-- .../src/glanceable-android/action-notice.ts | 16 +- .../active-agents-widget-actions.tsx | 214 +++++++ .../active-agents-widget-card.tsx | 578 ++++++++---------- .../active-agents-widget-glyphs.tsx | 174 ++++++ .../active-agents-widget-large-rows.tsx | 114 ++++ .../active-agents-widget-large.tsx | 269 ++++++++ .../active-agents-widget-medium.tsx | 183 ++++++ .../active-agents-widget-parts.tsx | 493 +++++++-------- .../active-agents-widget-row.tsx | 208 +++++++ .../active-agents-widget-short.tsx | 430 ++++++++----- .../active-agents-widget-stack.ts | 77 +++ .../active-agents-widget-states.test.ts | 232 +++++++ .../active-agents-widget-status.tsx | 120 ++++ .../active-agents-widget.test-fixtures.ts | 100 +++ .../active-agents-widget.test-helpers.ts | 115 ++-- .../active-agents-widget.test.ts | 401 ++++++------ .../active-agents-widget.tsx | 98 ++- .../glanceable-android/android-sink.test.ts | 351 ++++++----- .../src/glanceable-android/android-sink.ts | 78 +-- .../glanceable-android/approve-task.test.ts | 32 +- .../src/glanceable-android/approve-task.ts | 20 + .../src/glanceable-android/count-format.ts | 8 +- .../src/glanceable-android/home-copy.ts | 78 ++- .../glanceable-android/live-update.test.ts | 158 +---- .../src/glanceable-android/live-update.ts | 82 +-- .../glanceable-android/notification-card.ts | 47 ++ .../ongoing-notification.test.ts | 141 +++++ .../ongoing-notification.ts | 106 ++++ .../register.test-helpers.ts | 17 + .../src/glanceable-android/register.test.ts | 34 +- .../glanceable-android/widget-props.test.ts | 99 +-- .../src/glanceable-android/widget-props.ts | 47 -- 33 files changed, 3635 insertions(+), 1620 deletions(-) create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-large.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-row.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-stack.ts create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget-status.tsx create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts create mode 100644 apps/mobile/src/glanceable-android/notification-card.ts create mode 100644 apps/mobile/src/glanceable-android/ongoing-notification.test.ts create mode 100644 apps/mobile/src/glanceable-android/ongoing-notification.ts diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 69404a13a4..a62ba65d9b 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -5,13 +5,20 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color import android.graphics.drawable.Icon import android.net.Uri import android.os.Build +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.util.Log import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineScope @@ -24,9 +31,10 @@ import kotlinx.coroutines.asCoroutineDispatcher * its creation), the alert decision, and the revision guard; this module owns * the fixed notification id, the posted-channel mirror, the API 36.1+ promotion * gate, and the content intent plus named actions: Open deep-links into the - * recorded session (the Agents tab when nothing waits), and Approve runs the - * headless approval when a cloud-agent permission waits. Both platforms answer - * through `src/lib/glanceable/approve-ask.ts`. + * recorded session (the Agents tab when nothing waits), Approve runs the + * headless approval when a cloud-agent permission waits, and New agent opens + * the composer on a working card. Both platforms answer through + * `src/lib/glanceable/approve-ask.ts`. * * This is the Android mechanism for the one shared kind model, not a second * behaviour: `@kilocode/notifications` maps each agent surface to `needs-input` @@ -58,8 +66,8 @@ class ActiveAgentsLiveUpdateModule : Module() { notificationManager.isNotificationPolicyAccessGranted } - // Group the Open label and URL so both entry points fit Expo's eight-argument - // Function limit while preserving the action and notification-kind fields. + // The card's content and actions travel as one record so both entry points + // fit Expo's eight-argument Function limit beside the notification-kind fields. // // `start`, `update` and `end` are the durable writes whose state the JS side // never reads back in the same turn: their bodies commit to SharedPreferences @@ -68,20 +76,18 @@ class ActiveAgentsLiveUpdateModule : Module() { // thread. The JS bridge (`src/glanceable-android/live-update.ts`) declares // them `void` and never consumes the promise, which is why `durable` logs a // failure instead of letting it become an unhandled rejection. - AsyncFunction("start") { title: String, text: String, openAction: Map<String, String>, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, promotion: Boolean -> + AsyncFunction("start") { card: LiveUpdateCard, channelId: String, alerting: Boolean, promotion: Boolean -> durable { - post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, promotion, 0) + post(card, channelId, alerting, promotion, 0) } }.runOnQueue(moduleQueue) - // Expo's `Function` builder has one overload per arity and stops at eight - // arguments (expo-modules-core `ObjectDefinitionBuilder`), so `update` - // cannot carry `start`'s `promotion` flag on top of the terminal - // `timeoutMs`. The flag is redundant on this path: `post` gates promotion - // on `isPromotionCapable()` itself, which is the value the JS side passed. - AsyncFunction("update") { title: String, text: String, openAction: Map<String, String>, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, timeoutMs: Double -> + // `update` carries the terminal `timeoutMs` instead of `start`'s `promotion` + // flag. The flag is redundant on this path: `post` gates promotion on + // `isPromotionCapable()` itself, which is the value the JS side passes. + AsyncFunction("update") { card: LiveUpdateCard, channelId: String, alerting: Boolean, timeoutMs: Double -> durable { - post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, isPromotionCapable(), timeoutMs.toLong()) + post(card, channelId, alerting, isPromotionCapable(), timeoutMs.toLong()) } }.runOnQueue(moduleQueue) @@ -279,6 +285,30 @@ class ActiveAgentsLiveUpdateModule : Module() { ) } + /** + * The New agent action: the app's fixed new-agent deep link. One constant URL, + * so the record under its request code never accumulates. + */ + private fun newAgentPendingIntent(url: String): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + setPackage(context.packageName) + } + return PendingIntent.getActivity( + context, + NEW_AGENT_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** The app is drawing in its dark theme: the card's accent and retry red follow it. */ + private fun isNight(): Boolean = + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + + private fun action(label: String, intent: PendingIntent): Notification.Action = + Notification.Action.Builder(Icon.createWithResource(context, smallIconId()), label, intent).build() + /** * The channel a card still in the shade carries, or null when no card is * posted. The stored marker alone can outlive the card: a terminal timeout @@ -302,17 +332,13 @@ class ActiveAgentsLiveUpdateModule : Module() { } private fun post( - title: String, - text: String, - openLabel: String, - openUrl: String, - approveLabel: String?, - compactText: String?, + card: LiveUpdateCard, channelId: String, alerting: Boolean, promotion: Boolean, timeoutMs: Long ) { + val openUrl = card.openUrl // Read the URL the shade card carries before this call can change it: a // failed post must leave `OPEN_URL` naming the previous card's record. val previousOpenUrl = notificationState.getString(OPEN_URL, null) @@ -323,32 +349,38 @@ class ActiveAgentsLiveUpdateModule : Module() { // uses. A needs-input card takes both; a progress card keeps the status // category and the silent behaviour it had. val needsInput = channelId == NEEDS_INPUT_CHANNEL_ID + // The system template, coloured with the app's primary (olive by day, lime + // by night). The retry line after a failed approve draws in the destructive + // red; everything else is the template's own text colour. + val night = isNight() + val text: CharSequence = if (card.textIsError) { + SpannableString(card.text).apply { + setSpan( + ForegroundColorSpan(Color.parseColor(if (night) ERROR_NIGHT else ERROR_DAY)), + 0, + length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } else { + card.text + } val builder = newBuilder(channelId) .setSmallIcon(smallIconId()) - .setContentTitle(title) + .setColor(Color.parseColor(if (night) PRIMARY_NIGHT else PRIMARY_DAY)) + .setContentTitle(card.title) .setContentText(text) + .setSubText(card.subText) .setContentIntent(contentIntent) .setOngoing(true) .setCategory(if (needsInput) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_STATUS) - .addAction( - Notification.Action.Builder( - Icon.createWithResource(context, smallIconId()), - openLabel, - contentIntent - ).build() - ) - - // No recorded approvable ask: the action is omitted, not disabled. The JS - // side drops the label once the wait is answered elsewhere. - if (approveLabel != null) { - builder.addAction( - Notification.Action.Builder( - Icon.createWithResource(context, smallIconId()), - approveLabel, - approvePendingIntent() - ).build() - ) - } + + // Approve first, then Open, then New agent. An action the card does not + // offer is omitted, not disabled: the JS side drops Approve once the wait is + // answered elsewhere or while an answer is in flight. + card.approveLabel?.let { builder.addAction(action(it, approvePendingIntent())) } + builder.addAction(action(card.openLabel, contentIntent)) + card.newAgentLabel?.let { builder.addAction(action(it, newAgentPendingIntent(card.newAgentUrl))) } if (needsInput) { // Only the first entry into the kind alerts; a later update in the same @@ -363,7 +395,7 @@ class ActiveAgentsLiveUpdateModule : Module() { // setRequestPromotedOngoing does not exist; use the documented flag setter. if (promotion && isPromotionCapable()) { builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true) - builder.setShortCriticalText(compactText) + builder.setShortCriticalText(card.compactText) builder.setStyle(Notification.ProgressStyle()) } @@ -462,6 +494,13 @@ class ActiveAgentsLiveUpdateModule : Module() { /** The kind marker in the channel id the JS side creates for needs-input. */ const val NEEDS_INPUT_CHANNEL_ID = "needs-input" const val APPROVE_REQUEST_CODE = 1003 + const val NEW_AGENT_REQUEST_CODE = 1004 + + /** The app's primary and destructive colours (`global.css`), day and night. */ + const val PRIMARY_DAY = "#4F5A10" + const val PRIMARY_NIGHT = "#E8F27A" + const val ERROR_DAY = "#B0483A" + const val ERROR_NIGHT = "#F28B7A" /** * The module's one serial queue for the durable write path, shared by every @@ -491,3 +530,17 @@ class ActiveAgentsLiveUpdateModule : Module() { Executors.newSingleThreadExecutor { Thread(it, "active-agents-live-update") } } } + +/** One card's content and actions, as the JS bridge (`live-update.ts`) sends it. */ +class LiveUpdateCard : Record { + @Field val title: String = "" + @Field val text: String = "" + @Field val textIsError: Boolean = false + @Field val subText: String? = null + @Field val compactText: String? = null + @Field val openLabel: String = "" + @Field val openUrl: String = "" + @Field val approveLabel: String? = null + @Field val newAgentLabel: String? = null + @Field val newAgentUrl: String = "" +} diff --git a/apps/mobile/src/glanceable-android/action-notice.ts b/apps/mobile/src/glanceable-android/action-notice.ts index 5a50aa1c23..efffeadd25 100644 --- a/apps/mobile/src/glanceable-android/action-notice.ts +++ b/apps/mobile/src/glanceable-android/action-notice.ts @@ -14,6 +14,8 @@ import { getWaitingAsk, type WaitingAsk } from '@/lib/glanceable/waiting-ask'; */ let actionNotice: string | null = null; let noticeAskKey: string | null = null; +/** An Approve tap is being answered: the card says "Approving…" and drops Approve. */ +let approving = false; /** The recorded ask identity the notice describes; '' means "no ask". */ function askKey(ask: WaitingAsk | null): string { @@ -29,13 +31,25 @@ export function setGlanceableActionNotice(notice: string | null): void { noticeAskKey = notice === null ? null : askKey(getWaitingAsk()); } +/** Mark (or clear) the in-flight answer; it ends with the ask like the notice does. */ +export function setGlanceableActionApproving(value: boolean): void { + approving = value; +} + +export function isActionApproving(): boolean { + return approving; +} + /** The notice waiting to reach the next notification text, or null. */ export function getActionNotice(): string | null { return actionNotice; } -/** Drop the notice once nothing needs input or the recorded ask has changed. */ +/** Drop the notice (and the in-flight mark) once nothing needs input; the notice also when the ask changed. */ export function pruneActionNotice(snapshot: GlanceableAgentsSnapshot): void { + if (snapshot.needsInput === 0) { + approving = false; + } if ( actionNotice !== null && (snapshot.needsInput === 0 || askKey(getWaitingAsk()) !== noticeAskKey) diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx new file mode 100644 index 0000000000..17a7817284 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx @@ -0,0 +1,214 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { type ReactNode } from 'react'; +import { FlexWidget, OverlapWidget, TextWidget } from 'react-native-android-widget'; + +import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; + +import { glyph, plusStrokes } from './active-agents-widget-glyphs'; +import { + estimateWidth, + type Frame, + place, + type Rect, + textAlign, +} from './active-agents-widget-parts'; + +/** Every action keeps a 48dp touch target, whatever size its glyph draws at. */ +const ACTION_TARGET = 48; + +/** An invisible 48dp-or-wider touch target over an action, placed apart from its glyph. */ +function target(f: Frame, key: 'create' | 'approve', rect: Rect) { + const approvalKey = f.props.home?.approvalKey ?? null; + return ( + <FlexWidget + key={`${key}-target`} + {...(key === 'create' + ? { clickAction: 'OPEN_URI', clickActionData: { uri: LAUNCHER_NEW_AGENT_URL } } + : { clickAction: 'approve', clickActionData: { approvalKey } })} + accessibilityLabel={ + key === 'create' ? f.props.actions.newAgentLabel : f.props.actions.approveLabel + } + style={place(f, rect)} + /> + ); +} + +/** What the Approve slot shows: nothing, a ready Approve, or the in-flight state. */ +export function approveSlot(f: Frame): 'approve' | 'approving' | null { + if (f.copy.statusKind !== 'content') { + return null; + } + if (f.props.actionFeedback === 'approving') { + return 'approving'; + } + const ready = f.props.home?.canApprove === true && (f.props.home.approvalKey ?? null) !== null; + return ready ? 'approve' : null; +} + +export function canCreate(f: Frame): boolean { + const live = f.copy.statusKind === 'content' || f.copy.statusKind === 'empty'; + return live && (f.props.home?.canCreate ?? f.props.actions.newAgent); +} + +/** + * Approve then `+`, round glyphs at the trailing edge. Each owns a fixed 48dp + * target: `+` at `plusTarget`, Approve immediately before it. A hidden action + * leaves its slot empty, so `+` never moves. + */ +export function roundActions( + f: Frame, + spec: { + cy: number; + r: number; + plus: number; + approve: number; + plusTarget: number; + create: boolean; + } +) { + const y = Math.min(Math.max(0, spec.cy - ACTION_TARGET / 2), f.height - ACTION_TARGET); + const slot = approveSlot(f); + const nodes: ReactNode[] = []; + if (slot !== null) { + nodes.push(glyph(f, slot, { cx: spec.approve, cy: spec.cy, r: spec.r })); + } + if (slot === 'approve') { + const x = spec.plusTarget - ACTION_TARGET; + nodes.push(target(f, 'approve', { x, y, width: ACTION_TARGET, height: ACTION_TARGET })); + } + if (spec.create) { + nodes.push( + glyph(f, 'create', { cx: spec.plus, cy: spec.cy, r: spec.r }), + target(f, 'create', { x: spec.plusTarget, y, width: ACTION_TARGET, height: ACTION_TARGET }) + ); + } + return nodes; +} + +/** A filled pill with one centred label. */ +function pill( + f: Frame, + key: string, + spec: Rect & { value: string; fill: 'primary' | 'secondary' } +) { + const ink = spec.fill === 'primary' ? 'primaryForeground' : 'muted'; + return ( + <FlexWidget + key={key} + style={{ + ...place(f, spec), + borderRadius: spec.height / 2, + backgroundColor: f.paint.palette[spec.fill], + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 8, + }} + > + <TextWidget + text={spec.value} + maxLines={1} + truncate="END" + allowFontScaling={false} + style={{ + fontSize: 12, + fontWeight: '600', + color: f.paint.palette[ink], + textAlign: 'center', + }} + /> + </FlexWidget> + ); +} + +/** Medium/Large: the Approve pill (86x24, wider for long copy) ends where `+`'s target begins. */ +export function pillActions(f: Frame, create: boolean) { + const slot = approveSlot(f); + const nodes: ReactNode[] = []; + const end = f.width - ACTION_TARGET; + if (slot !== null) { + const value = slot === 'approve' ? f.props.actions.approveLabel : (f.copy.actionLine ?? ''); + const natural = Math.ceil(estimateWidth(value, 12, true)) + 28; + // Never into "Kilo": the brand ends at 78dp. + const width = Math.min(end - 78, Math.max(slot === 'approve' ? 86 : 96, natural)); + const x = end - width; + nodes.push( + pill(f, 'approve-pill', { + x, + y: 12, + width, + height: 24, + value, + fill: slot === 'approve' ? 'primary' : 'secondary', + }) + ); + if (slot === 'approve') { + nodes.push(target(f, 'approve', { x, y: 0, width, height: ACTION_TARGET })); + } + } + if (create) { + nodes.push( + glyph(f, 'create', { cx: f.width - 28, cy: 24, r: 12 }), + target(f, 'create', { x: end, y: 0, width: ACTION_TARGET, height: ACTION_TARGET }) + ); + } + return nodes; +} + +/** The secondary "New agent" pill that replaces the header `+` on an empty Medium/Large cell. */ +export function newAgentPill( + f: Frame, + spec: { x: number; y: number; height: number; size: number; arm: number; centred: boolean } +) { + const value = f.props.actions.newAgentLabel; + const lead = spec.height; + const natural = Math.ceil(estimateWidth(value, spec.size, true)) + lead + 12; + const width = Math.min(f.width - 32, Math.max(spec.centred ? 120 : 104, natural)); + const x = spec.centred ? (f.width - width) / 2 : spec.x; + const children = [ + <OverlapWidget key="glyph" style={{ width: lead, height: spec.height }}> + {plusStrokes('new', { + size: spec.height, + arm: spec.arm, + color: f.paint.palette.foreground, + width: 2, + })} + </OverlapWidget>, + <FlexWidget key="label" style={{ width: 0, flex: 1 }}> + <TextWidget + text={value} + maxLines={1} + truncate="END" + allowFontScaling={false} + style={{ + width: 'match_parent', + fontSize: spec.size, + fontWeight: '600', + color: f.paint.palette.foreground, + textAlign: textAlign(f, 'start'), + }} + /> + </FlexWidget>, + ]; + return [ + <FlexWidget + key="new-agent-pill" + style={{ + ...place(f, { x, y: spec.y, width, height: spec.height }), + borderRadius: spec.height / 2, + backgroundColor: f.paint.palette.secondary, + flexDirection: 'row', + alignItems: 'center', + }} + > + {f.paint.rtl ? children.toReversed() : children} + </FlexWidget>, + target(f, 'create', { + x, + y: Math.max(0, spec.y + spec.height / 2 - ACTION_TARGET / 2), + width, + height: ACTION_TARGET, + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx index 391fe985bc..ebda29abbe 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx @@ -1,343 +1,301 @@ -/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ 'use no memo'; -import { FlexWidget, type WidgetInfo } from 'react-native-android-widget'; +import { type ReactNode } from 'react'; +import { canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; import { - ACTION_TARGET, + bar, + boxTop, type Copy, - countRow, - detail, - entryRows, - footer, - header, - hero, - heroVariantFor, - type Paint, - readingOrder, - text, + dot, + dotInk, + estimateWidth, + type Frame, + type Ink, + label, + lineBox, } from './active-agents-widget-parts'; -import { type AndroidWidgetProps } from './widget-props'; +import { stackedStatus } from './active-agents-widget-status'; +import { fitRows, phaseOf, stack } from './active-agents-widget-stack'; -/** Keep the existing native renderer's height buckets. */ -export const SHORT_HEIGHT = 180; -/** Every card insets its content by this much on every side. */ -export const MARGIN = 12; -/** Footer line height: the checked stamp owns only its own line, not the leftover. */ -const FOOTER_HEIGHT = 16; -/** One line of secondary copy between the hero and the entries. */ -const DETAIL_HEIGHT = 24; -/** Copy size is a fraction of the card width so a locked portrait cell reads deliberate. */ -const COPY_RATIO = 0.078; -/** Capped so a locked cell reads as a state panel, not a headline stretched to the width. */ -const COPY_MAX = 24; -/** The lock is sized from the copy, not the cell: its full height is ~1.35x the copy's - * cap height (the host font's cap height is ~0.75 of its size) so glyph and words match. */ -const LOCK_PER_COPY = 1.35 * 0.75; -/** The host font's line box and the glyph-to-copy gap, both per unit of copy size. */ -const COPY_LINE = 1.32; -const LOCK_GAP = 0.5; +/** + * Small (2x2, 2x3), plus what Medium and Large share. Cards draw at the design + * coordinates of a 170x170 / 364x170 / 364x382 widget. A taller cell keeps the + * layout: the extra height first buys agent rows (up to 3), then splits evenly + * across the gaps; the footer stays 18dp above the bottom edge. + */ -/** Everything one layout needs to draw a placed widget in one palette. */ -export type Frame = { - props: AndroidWidgetProps; - copy: Copy; - paint: Paint; - info: WidgetInfo; -}; +/** Header ink ends at 36dp (the 24dp glyphs centred on y=24). */ +export const HEADER_BOTTOM = 36; -/** One of the three equal flexible gaps that spread the free height down the card. */ -function gap(key: string) { - return <FlexWidget key={key} style={{ width: 'match_parent', flex: 1 }} />; +export function brand(f: Frame) { + return [ + logo(f, { x: 16, y: 15, size: 18 }), + label(f, 'brand', { x: 40, baseline: 29, width: 40, value: 'Kilo', size: 13, weight: '600' }), + ]; } -/** The primary line for a band, told how much width it actually has. */ -export function rowDetail( - copy: Copy, - paint: Paint, - slot: { height: number; size: number; available: number } -) { - return detail(copy, paint, { ...slot, fallback: false }); +/** The content edges: under the header, down to the top of the footer line 18dp above the bottom. */ +export function cardEdges(f: Frame) { + return { + top: HEADER_BOTTOM, + bottom: boxTop(f.copy.footer ?? f.copy.status ?? '', 11, f.height - 18), + }; } -/** Row typography scales with the height a cell can afford a row. */ -const ENTRY_SIZES = [ - { min: 60, title: 16, reason: 13 }, - { min: 50, title: 15, reason: 12 }, -]; - -function entrySizes(rowHeight: number) { - return ( - ENTRY_SIZES.find(entry => rowHeight >= entry.min) ?? { - title: 14, - reason: 12, - } - ); +/** "Checked …" / "Last known · …", "Updating agents", or (`long`) the failed-approve sentence. */ +export function footer(f: Frame, long: boolean) { + const phase = phaseOf(f.copy); + const failed = long && f.props.actionFeedback === 'couldNotApprove' && phase === 'content'; + let value = f.copy.footer ?? ''; + if (phase === 'updating') { + value = f.copy.status ?? ''; + } else if (failed) { + value = f.copy.approveFailed; + } + if (value === '') { + return null; + } + return label(f, 'footer', { + x: 16, + baseline: f.height - 18, + width: f.width - 32, + value, + size: 11, + ...(failed ? { weight: '600' as const, ink: 'warn' as const } : { ink: 'muted' as const }), + }); } /** - * The list owns exactly the height its rows need: rows stay capped so a tall cell - * shows roomy rows rather than two blocks pushed to the edges, and the block keeps - * its rows centred. The leftover belongs to the three gaps around it. + * The locked composition: logo + Kilo, a centred lock and the copy under it; no + * actions, no footer. The copy may always wrap to a second line (an instruction + * never ellipsizes); `wrap` centres the block for two lines, as Small is drawn. */ -function entries(copy: Copy, paint: Paint, slot: { slots: number; rowHeight: number }) { - const { title: titleSize, reason: reasonSize } = entrySizes(slot.rowHeight); - return ( - <FlexWidget - style={{ - width: 'match_parent', - height: slot.slots * slot.rowHeight, - justifyContent: 'center', - }} - > - {entryRows(copy) - .slice(0, slot.slots) - .map((row, index) => ( - <FlexWidget - key={index} - style={{ - width: 'match_parent', - height: slot.rowHeight, - justifyContent: 'center', - // The host lays text out from its left edge; a row is mirrored by hand so - // an RTL catalogue keeps every line on its reading-start side. - alignItems: paint.rtl ? 'flex-end' : 'flex-start', - }} - > - {text(row.title, paint, { size: titleSize })} - {row.time === null ? null : text(row.time, paint, { size: reasonSize, muted: true })} - </FlexWidget> - ))} - </FlexWidget> - ); +export function locked( + f: Frame, + spec: { + height: number; + lockY: number; + lockSize: number; + baseline: number; + size: number; + wrap: boolean; + } +) { + const value = f.copy.status ?? ''; + const lines = spec.wrap || estimateWidth(value, spec.size, true) > f.width - 32 ? 2 : 1; + const { nodes } = stack({ + blocks: [ + { + top: spec.lockY - spec.lockSize / 2 - 2, + bottom: boxTop(value, spec.size, spec.baseline) + lineBox(value, spec.size) * lines, + shrink: 10, + draw: dy => [ + ...lock(f, { cx: f.width / 2, cy: spec.lockY + dy, size: spec.lockSize }), + label(f, 'status', { + x: 16, + baseline: spec.baseline + dy, + width: f.width - 32, + value, + size: spec.size, + weight: '600', + lines: 2, + align: 'center', + }), + ], + }, + ], + design: { top: HEADER_BOTTOM, bottom: spec.height - 15 }, + edges: { top: HEADER_BOTTOM, bottom: f.height - 15 }, + tailShrink: 10, + }); + return [...brand(f), ...nodes]; } -/** The natural hero height for a body, picked in bands so no count or label is clipped. */ -function naturalHero(body: number): number { - if (body >= 300) { - return 84; - } - if (body >= 210) { - return 68; +export type Entry = { title: string; sub: string | null; ink: 'warn' | 'info' }; + +/** The agents a card lists: earliest waits while input is needed, else the next wakes. */ +export function entries(copy: Copy): Entry[] { + if (copy.primaryKind === 'needsInput') { + return copy.waitingAgents.map(agent => ({ + title: agent.title, + sub: agent.reason, + ink: 'warn', + })); } - if (body >= 140) { - return 44; + if (copy.primaryKind === 'scheduled') { + return copy.scheduledAgents.map(agent => ({ + title: agent.title, + sub: agent.time, + ink: 'info', + })); } - return 32; + return []; } +/** Secondary counts as "3 Working" lines, each with its dot. */ +export function countLines( + f: Frame, + spec: { x: number; baseline: number; step: number; width: number } +) { + return f.copy.secondaryCounts.flatMap((line, index) => { + const baseline = spec.baseline + index * spec.step; + return [ + dot(f, `count-${line.kind}-dot`, { + cx: spec.x, + cy: baseline - 4, + r: 4, + fill: dotInk(line.kind), + }), + label(f, `count-${line.kind}`, { + x: spec.x + 12, + baseline, + width: spec.width, + value: `${line.count} ${line.label}`, + size: 13, + }), + ]; + }); +} + +type Line = { value: string; ink: Ink; weight?: '600' }; + /** - * The full card: brand header, a hero block scaled to the cell (the primary line - * and support counts travel with it), the entry rows, then the footer. Three equal - * flexible gaps spread the leftover, so the free height never pools in one place. + * The Small cell's agent lines: a failed approve first, else the scheduled wake + * or the lead title; a taller cell adds the next waiting or scheduled titles. */ -export function card({ props, copy, paint, info }: Frame) { - const wide = info.width >= 266; - const inner = info.height - MARGIN * 2; - const rows = entryRows(copy); - const hasDetail = copy.detail !== null && copy.detail !== ''; - // The primary line and the first entry are the same agent while it waits: show it once. - const firstEntry = rows[0]; - const duplicatesEntry = - copy.detail !== null && - copy.detail !== '' && - firstEntry !== undefined && - firstEntry.title !== '' && - copy.detail.includes(firstEntry.title); - // Wide cards show the support counts beside the hero, so the hero must clear them. - const besideCounts = wide ? Math.min(3, copy.secondaryCounts.length) * 26 : 0; - const heroFloor = Math.max(32, besideCounts); - // Keep as many stacked counts as the card can hold without squeezing the hero out. - let supportRows = wide ? 0 : Math.min(3, copy.secondaryCounts.length); - while (supportRows > 0 && inner - ACTION_TARGET - FOOTER_HEIGHT - supportRows * 26 < heroFloor) { - supportRows -= 1; +function smallLines(f: Frame): Line[] { + const { copy } = f; + const lines: Line[] = []; + if (f.props.actionFeedback === 'couldNotApprove') { + lines.push({ value: copy.actionLine ?? '', ink: 'warn', weight: '600' }); } - const plan = (suppressDetail: boolean) => { - const body = - inner - - ACTION_TARGET - - (suppressDetail || !hasDetail ? 0 : DETAIL_HEIGHT) - - supportRows * 26 - - FOOTER_HEIGHT; - const heroHeight = Math.max(besideCounts, Math.min(naturalHero(body), Math.max(32, body))); - const slots = Math.max(0, Math.min(3, Math.floor((body - heroHeight) / 44))); - const rowHeight = - slots === 0 ? 0 : Math.max(44, Math.min(64, Math.floor((body - heroHeight) / slots))); - return { heroHeight, slots, rowHeight }; - }; - // Suppressing the primary line is only worth it while its entry is actually listed. - let suppressed = duplicatesEntry; - let planned = plan(suppressed); - if (suppressed && planned.slots === 0) { - suppressed = false; - planned = plan(suppressed); + if (copy.primaryKind === 'needsInput' && copy.waitingAgents.length > 0) { + lines.push( + ...copy.waitingAgents.map(agent => ({ value: agent.title, ink: 'foreground' as const })) + ); + } else if (copy.primaryKind === 'scheduled') { + if (copy.wake !== null) { + lines.push({ value: copy.wake, ink: copy.wakeOverdue ? 'muted' : 'foreground' }); + } + lines.push( + ...copy.scheduledAgents.map(agent => ({ value: agent.title, ink: 'foreground' as const })) + ); + } else if (copy.title !== null) { + lines.push({ value: copy.title, ink: 'foreground' }); } - const { rowHeight } = planned; - // Never reserve height for rows the copy does not carry. - const slots = Math.min(planned.slots, rows.length); - // A short card cannot hold a hero, a detail line, the footer and the counts together: drop the detail first. - const bodyRoom = inner - ACTION_TARGET - supportRows * 26 - FOOTER_HEIGHT; - const showDetail = !(suppressed || !hasDetail) && bodyRoom - DETAIL_HEIGHT >= heroFloor; - // The hero keeps its floor and clears the counts beside it. - const free = bodyRoom - (showDetail ? DETAIL_HEIGHT : 0); - const heroHeight = slots === 0 ? Math.max(heroFloor, Math.min(free, 140)) : planned.heroHeight; - const variant = heroVariantFor(heroHeight); - // A 172dp-wide card can only fit the longest agent title at 11dp. - const detailSize = info.width >= 280 ? 12 : 11; - const avail = info.width - MARGIN * 2; - return ( - <FlexWidget style={{ width: 'match_parent', height: 'match_parent' }}> - {header(props, paint, wide)} - {gap('gap-top')} - <FlexWidget key="hero-block" style={{ width: 'match_parent' }}> - <FlexWidget - style={{ - width: 'match_parent', - height: heroHeight, - flexDirection: 'row', - alignItems: 'center', - flexGap: 12, - }} - > - {readingOrder( - [ - <FlexWidget key="hero" style={{ width: 0, flex: 1 }}> - {hero(copy, paint, variant)} - </FlexWidget>, - wide ? ( - <FlexWidget - key="support" - style={{ - width: Math.floor((info.width - 36) * 0.46), - height: heroHeight, - justifyContent: 'center', - }} - > - {copy.secondaryCounts.map(line => countRow(line, paint))} - </FlexWidget> - ) : null, - ], - paint.rtl - )} - </FlexWidget> - {showDetail - ? rowDetail(copy, paint, { - height: DETAIL_HEIGHT, - size: detailSize, - available: avail, - }) - : null} - {wide ? null : ( - <FlexWidget style={{ width: 'match_parent', height: supportRows * 26 }}> - {copy.secondaryCounts.slice(0, supportRows).map(line => countRow(line, paint))} - </FlexWidget> - )} - </FlexWidget> - {slots > 0 ? gap('gap-middle') : null} - {slots > 0 ? entries(copy, paint, { slots, rowHeight }) : null} - {gap('gap-bottom')} - {footer(copy, paint, FOOTER_HEIGHT)} - </FlexWidget> - ); + return lines.slice(0, 3); } -/** A padlock drawn from two rectangles: a rounded shackle sitting on a rounded body. */ -function lockGlyph(paint: Paint, size: number) { - const stroke = Math.max(2, Math.round(size * 0.13)); - const bodyHeight = Math.round(size * 0.6); - const shackleWidth = Math.round(size * 0.54); - return ( - <FlexWidget - key="lock" - style={{ - width: size, - height: size, - flexDirection: 'column', - alignItems: 'center', - }} - > - <FlexWidget - style={{ - width: shackleWidth, - height: size - bodyHeight, - borderWidth: stroke, - borderBottomWidth: 0, - borderColor: paint.palette.foreground, - borderTopLeftRadius: Math.round(shackleWidth / 2), - borderTopRightRadius: Math.round(shackleWidth / 2), - }} - /> - <FlexWidget - style={{ - width: size, - height: bodyHeight, - borderRadius: Math.max(2, Math.round(size * 0.16)), - backgroundColor: paint.palette.foreground, - }} - /> - </FlexWidget> - ); +function smallBody(f: Frame): ReactNode[] { + const { width: W, copy } = f; + const design = { top: HEADER_BOTTOM, bottom: 140.4 }; + const edges = cardEdges(f); + const phase = phaseOf(copy); + if (phase === 'updating') { + return stack({ + blocks: [ + { + top: 62, + bottom: 114, + shrink: 10, + draw: dy => [ + bar(f, 'bar-count', { x: 16, y: 62 + dy, width: 44, height: 30 }), + bar(f, 'bar-label', { x: 16, y: 102 + dy, width: 96, height: 12 }), + ], + }, + ], + design, + edges, + tailShrink: 10, + }).nodes; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + const status = { + x: 16, + baseline: 104, + width: W - 32, + value, + size: 17, + weight: '600', + lines: 2, + } as const; + return stack({ + blocks: [ + { + top: boxTop(value, 17, 104), + bottom: 128.5, + height: lineBox(value, 17) * 2, + shrink: 20, + draw: dy => [label(f, 'status', { ...status, baseline: 104 + dy })], + }, + ], + design, + edges, + tailShrink: 4, + }).nodes; + } + const lines = smallLines(f); + const layout = (count: number) => + stack({ + blocks: [ + { + top: 31.5, + bottom: 101.7, + shrink: 4, + draw: dy => + stackedStatus(f, { + x: 16, + countBaseline: 78 + dy, + countSize: 44, + labelBaseline: 98 + dy, + labelSize: 14, + r: 4, + width: W - 32, + }), + }, + { + top: 103.3, + bottom: 120.5, + height: 17.2 + Math.max(0, count - 1) * 19, + ...(count === 0 + ? {} + : { + draw: (dy: number) => + lines.slice(0, count).map((line, index) => + label(f, `line-${index}`, { + x: 16, + baseline: 117 + index * 19 + dy, + width: W - 32, + value: line.value, + size: 13, + ink: line.ink, + ...(line.weight === undefined ? {} : { weight: line.weight }), + }) + ), + }), + }, + ], + design, + edges, + tailShrink: 12, + }); + return layout(fitRows(lines.length, layout)).nodes; } -/** - * The locked card (privacy/signed-out): the brand header, then one state block - - * a lock and the existing privacy copy - centred in the free height. The copy is - * sized from the cell so a portrait card reads as a deliberate composition. - */ -export function locked({ copy, paint, info }: Frame) { - const band = info.height < SHORT_HEIGHT; - const brandHeight = band ? 22 : ACTION_TARGET; - let brandSize = 18; - if (band) { - brandSize = 14; - } - if (info.height < 96) { - brandSize = 12; +export function small(f: Frame): ReactNode[] { + if (phaseOf(f.copy) === 'locked') { + return locked(f, { height: 170, lockY: 76, lockSize: 26, baseline: 108, size: 13, wrap: true }); } - // The thinnest bands drop the glyph: a copy on one line beats a squeezed lock. - const showLock = info.height >= 92; - const footerHeight = copy.checked === null ? 0 : FOOTER_HEIGHT; - // Copy is capped by width; the free height then bounds it through the whole stack - // (glyph, gap and one line), so the block stays whole and the spacing carries the rest. - const byWidth = Math.min(COPY_MAX, Math.max(12, Math.round(info.width * COPY_RATIO))); - const stack = showLock ? LOCK_PER_COPY + LOCK_GAP + COPY_LINE : COPY_LINE; - const inner = info.height - (band ? 0 : MARGIN * 2) - brandHeight - footerHeight - 8; - const size = Math.max(11, Math.min(byWidth, Math.floor(inner / stack))); - const lockSize = Math.max(12, Math.round(size * LOCK_PER_COPY)); - return ( - <FlexWidget - style={{ - width: 'match_parent', - height: 'match_parent', - marginHorizontal: band ? MARGIN : 0, - }} - > - <FlexWidget - style={{ - width: 'match_parent', - height: brandHeight, - flexDirection: 'row', - alignItems: 'center', - // Brand sits on the reading-start edge: left in LTR, right in RTL. - justifyContent: paint.rtl ? 'flex-end' : 'flex-start', - }} - > - {text('Kilo', paint, { size: brandSize, bold: true })} - </FlexWidget> - <FlexWidget - key="state" - style={{ - width: 'match_parent', - flex: 1, - alignItems: 'center', - justifyContent: 'center', - flexGap: Math.round(size * LOCK_GAP), - }} - > - {showLock ? lockGlyph(paint, lockSize) : null} - {text(copy.status ?? '', paint, { size, align: 'center' })} - </FlexWidget> - {copy.checked === null ? null : footer(copy, paint, FOOTER_HEIGHT)} - </FlexWidget> - ); + const actions = roundActions(f, { + cy: 24, + r: 12, + plus: f.width - 28, + approve: f.width - 56, + plusTarget: f.width - 48, + create: canCreate(f), + }); + return [...brand(f), ...actions, ...smallBody(f), footer(f, false)]; } diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx new file mode 100644 index 0000000000..c017939638 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx @@ -0,0 +1,174 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { type ReactNode } from 'react'; +import { FlexWidget, type HexColor, OverlapWidget } from 'react-native-android-widget'; + +import { type Frame, place, shape, tenth } from './active-agents-widget-parts'; + +/** The brand tile: a foreground square (22% corners) with a background inner square. */ +export function logo(f: Frame, spec: { x: number; y: number; size: number }) { + const inner = tenth(spec.size * 0.44); + return ( + <FlexWidget + key="logo" + style={{ + ...place(f, { x: spec.x, y: spec.y, width: spec.size, height: spec.size }), + borderRadius: tenth(spec.size * 0.22), + backgroundColor: f.paint.palette.foreground, + alignItems: 'center', + justifyContent: 'center', + }} + > + <FlexWidget + style={{ + width: inner, + height: inner, + borderRadius: 1, + backgroundColor: f.paint.palette.background, + }} + /> + </FlexWidget> + ); +} + +/** A round-capped stroke inside a glyph box: centre, length and angle in degrees. */ +function stroke( + key: string, + spec: { cx: number; cy: number; length: number; angle: number; color: HexColor; width?: number } +) { + const width = spec.width ?? 2.2; + const long = spec.length + width; + return ( + <FlexWidget + key={key} + style={{ + marginLeft: tenth(spec.cx - long / 2), + marginTop: tenth(spec.cy - width / 2), + width: tenth(long), + height: width, + borderRadius: width / 2, + backgroundColor: spec.color, + ...(spec.angle === 0 ? {} : { rotation: spec.angle }), + }} + /> + ); +} + +/** A `+` drawn from two strokes, centred in a box of `size`. */ +export function plusStrokes( + key: string, + spec: { size: number; arm: number; color: HexColor; width?: number } +) { + const centre = spec.size / 2; + const base = { + cx: centre, + cy: centre, + length: spec.arm * 2, + color: spec.color, + width: spec.width, + }; + return [stroke(`${key}-h`, { ...base, angle: 0 }), stroke(`${key}-v`, { ...base, angle: 90 })]; +} + +export type GlyphKind = 'create' | 'approve' | 'approving'; + +function glyphStrokes(f: Frame, kind: GlyphKind, r: number): ReactNode[] { + const { palette } = f.paint; + if (kind === 'create') { + return plusStrokes('plus', { size: r * 2, arm: r / 2, color: palette.foreground }); + } + if (kind === 'approve') { + // The design path: from (-a, 0) down to (-0.2a, 0.8a), then up to (1.2a, -0.8a). + const a = r * 0.42; + const color = palette.primaryForeground; + return [ + stroke('check-short', { + cx: r - 0.6 * a, + cy: r + 0.4 * a, + length: Math.hypot(0.8 * a, 0.8 * a), + angle: 45, + color, + }), + stroke('check-long', { + cx: r + 0.5 * a, + cy: r, + length: Math.hypot(1.4 * a, 1.6 * a), + angle: -49, + color, + }), + ]; + } + return [-5, 0, 5].map(dx => ( + <FlexWidget + key={`dot-${dx}`} + style={{ + marginLeft: tenth(r + dx - 1.8), + marginTop: tenth(r - 1.8), + width: 3.6, + height: 3.6, + borderRadius: 1.8, + backgroundColor: palette.muted, + }} + /> + )); +} + +/** + * The round action glyphs: `+` on the secondary fill, a checkmark on primary, + * and three muted dots while an approve is in flight. Glyph strokes are not + * mirrored in RTL; only the circle's position is. + */ +export function glyph(f: Frame, kind: GlyphKind, spec: { cx: number; cy: number; r: number }) { + const { cx, cy, r } = spec; + const fill = kind === 'approve' ? f.paint.palette.primary : f.paint.palette.secondary; + return ( + <OverlapWidget + key={`${kind}-glyph`} + style={{ + ...place(f, { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }), + backgroundColor: fill, + borderRadius: r, + }} + > + {glyphStrokes(f, kind, r)} + </OverlapWidget> + ); +} + +/** A padlock from two shapes: a stroked shackle sitting on a rounded body. */ +export function lock(f: Frame, spec: { cx: number; cy: number; size: number }) { + const { cx, cy, size } = spec; + const body = size * 0.62; + const weight = Math.max(2, size * 0.13); + const shackle = size * 0.56; + const bodyTop = cy - size / 2 + size - body; + const rise = (size - body) * 0.45 + (shackle - weight) / 2 + weight / 2; + return [ + <FlexWidget + key="lock-shackle" + style={{ + // The legs run into the body by one stroke so the two shapes read as one. + ...place(f, { + x: cx - shackle / 2, + y: bodyTop - rise, + width: shackle, + height: rise + weight, + }), + borderWidth: tenth(weight), + borderBottomWidth: 0, + borderColor: f.paint.palette.foreground, + borderTopLeftRadius: tenth(shackle / 2), + borderTopRightRadius: tenth(shackle / 2), + }} + />, + shape(f, 'lock-body', { + x: cx - size * 0.43, + y: bodyTop, + width: size * 0.86, + height: body, + fill: 'foreground', + radius: tenth(size * 0.14), + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx new file mode 100644 index 0000000000..9fba59e91f --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx @@ -0,0 +1,114 @@ +'use no memo'; + +import { countLines, type Entry } from './active-agents-widget-card'; +import { + dot, + estimateWidth, + type Frame, + label, + shape, + textRow, +} from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; + +/** The Large cell's building blocks: its status with the other counts, headings and list rows. */ + +export type Status = { + countBaseline: number; + countSize: number; + labelBaseline: number; + labelSize: number; + r: number; +}; + +/** The count, with the other counts top-right (from 226dp of 364) when they clear a wide count. */ +export function largeStatus( + f: Frame, + spec: Status & { countsBaseline: number; step: number }, + dy: number +) { + const clear = Math.ceil(16 + estimateWidth(f.copy.primaryCount, spec.countSize, true) + 16); + const x = Math.max(Math.round(f.width * 0.621), clear); + const counts = f.copy.secondaryCounts.length > 0 && f.width - 16 - (x + 12) >= 48; + return [ + ...stackedStatus(f, { + x: 16, + countBaseline: spec.countBaseline + dy, + countSize: spec.countSize, + labelBaseline: spec.labelBaseline + dy, + labelSize: spec.labelSize, + r: spec.r, + width: (counts ? x - 10 : f.width - 16) - 16, + }), + ...(counts + ? countLines(f, { + x, + baseline: spec.countsBaseline + dy, + step: spec.step, + width: f.width - 16 - (x + 12), + }) + : []), + ]; +} + +export function divider(f: Frame, y: number) { + return shape(f, 'divider', { x: 16, y, width: f.width - 32, height: 1, fill: 'divider' }); +} + +export function heading(f: Frame, key: string, spec: { value: string; baseline: number }) { + return label(f, key, { + ...spec, + x: 16, + width: f.width - 32, + size: 12, + weight: '600', + ink: 'muted', + }); +} + +/** A waiting row: dot, 14dp title, 12dp reason, 46dp apart. */ +export function waitRow(f: Frame, entry: Entry, spec: { index: number; baseline: number }) { + const { index, baseline } = spec; + return [ + dot(f, `row-${index}-dot`, { cx: 21, cy: baseline - 5, r: 4, fill: entry.ink }), + label(f, `row-${index}-title`, { + x: 33, + baseline, + width: f.width - 49, + value: entry.title, + size: 14, + }), + ...(entry.sub === null + ? [] + : [ + label(f, `row-${index}-sub`, { + x: 33, + baseline: baseline + 17, + width: f.width - 49, + value: entry.sub, + size: 12, + ink: 'muted', + }), + ]), + ]; +} + +/** A scheduled row: dot, title, and its time at the trailing edge, 34dp apart. */ +export function runRow(f: Frame, entry: Entry, spec: { index: number; baseline: number }) { + const { index, baseline } = spec; + return [ + dot(f, `row-${index}-dot`, { cx: 21, cy: baseline - 5, r: 4, fill: 'info' }), + textRow(f, `row-${index}`, { + x: 33, + baseline, + width: f.width - 49, + size: 14, + gap: 8, + fill: true, + parts: [ + { value: entry.title, flex: true }, + ...(entry.sub === null ? [] : [{ value: entry.sub, ink: 'muted' as const }]), + ], + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx new file mode 100644 index 0000000000..f776fbed27 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx @@ -0,0 +1,269 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { canCreate, newAgentPill, pillActions } from './active-agents-widget-actions'; +import { + brand, + cardEdges, + entries, + footer, + HEADER_BOTTOM, + locked, +} from './active-agents-widget-card'; +import { + divider, + heading, + largeStatus, + runRow, + type Status, + waitRow, +} from './active-agents-widget-large-rows'; +import { bar, boxTop, estimateWidth, type Frame, label } from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; +import { type Block, fitRows, phaseOf, stack } from './active-agents-widget-stack'; + +/** Large (3x3, 4x3, 4x4) at the 364x382 design. */ + +const DESIGN = { top: HEADER_BOTTOM, bottom: 352.4 }; + +/** Needs input (rounds 2/6) and scheduled (round 4): the count block, then a titled list. */ +function listed(f: Frame) { + const { copy } = f; + const rows = entries(copy); + const scheduled = copy.primaryKind === 'scheduled'; + const step = scheduled ? 34 : 46; + const status: Status = scheduled + ? { countBaseline: 128, countSize: 80, labelBaseline: 158, labelSize: 19, r: 5 } + : { countBaseline: 106, countSize: 64, labelBaseline: 131, labelSize: 17, r: 4.5 }; + const short = scheduled ? (copy.wake ?? copy.title) : null; + const list = scheduled + ? { top: 206, bottom: 329.7, heading: 232, first: 258, value: copy.headings.nextScheduled } + : { top: 152, bottom: 314.1, heading: 176, first: 202, value: copy.headings.waitingForYou }; + const layout = (count: number) => + stack({ + blocks: [ + { + top: scheduled ? 43.5 : 38.4, + bottom: scheduled ? 185.4 : 135.5, + shrink: 4, + draw: dy => [ + ...largeStatus(f, { ...status, countsBaseline: 74, step: 22 }, dy), + ...(short === null + ? [] + : [ + label(f, 'short', { + x: 16, + baseline: 182 + dy, + width: f.width - 32, + value: short, + size: 13, + ink: 'muted', + }), + ]), + ], + }, + { + top: list.top, + bottom: list.bottom, + height: list.bottom - list.top - (3 - count) * step, + shrink: 8, + ...(count === 0 + ? {} + : { + draw: (dy: number) => [ + divider(f, list.top + dy), + heading(f, 'section', { value: list.value, baseline: list.heading + dy }), + ...rows.slice(0, count).flatMap((entry, index) => { + const row = { index, baseline: list.first + index * step + dy }; + return scheduled ? runRow(f, entry, row) : waitRow(f, entry, row); + }), + ], + }), + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 20, + }); + return layout(fitRows(Math.min(3, rows.length), layout)).nodes; +} + +/** Working or idle (round 3): the latest title, and the next wake when one is scheduled. */ +function latest(f: Frame) { + const { copy } = f; + const mixed = copy.secondaryCounts.length > 0; + const next = copy.scheduledAgents[0]; + const recent = (top: number, headingBaseline: number, titleBaseline: number): Block => ({ + top, + bottom: titleBaseline + 4, + shrink: 10, + ...(copy.title === null + ? {} + : { + draw: (dy: number) => [ + divider(f, top + dy), + heading(f, 'recent-heading', { + value: copy.headings.recent, + baseline: headingBaseline + dy, + }), + label(f, 'recent-title', { + x: 16, + baseline: titleBaseline + dy, + width: f.width - 32, + value: copy.title ?? '', + size: 15, + }), + ], + }), + }); + const blocks: Block[] = mixed + ? [ + { + top: 52.4, + bottom: 149.5, + shrink: 8, + draw: dy => + largeStatus( + f, + { + countBaseline: 120, + countSize: 64, + labelBaseline: 145, + labelSize: 17, + r: 4.5, + countsBaseline: 92, + step: 24, + }, + dy + ), + }, + recent(178, 210, 234), + { + top: 271.3, + bottom: 329.1, + shrink: 12, + ...(next === undefined + ? {} + : { + draw: (dy: number) => [ + heading(f, 'next-heading', { + value: copy.headings.nextScheduled, + baseline: 284 + dy, + }), + ...waitRow( + f, + { title: next.title, sub: next.time, ink: 'info' }, + { index: 0, baseline: 308 + dy } + ), + ], + }), + }, + ] + : [ + { + top: 75.1, + bottom: 208.3, + shrink: 14, + draw: dy => + stackedStatus(f, { + x: 16, + countBaseline: 168 + dy, + countSize: 88, + labelBaseline: 203 + dy, + labelSize: 20, + r: 5, + width: f.width - 32, + }), + }, + recent(238, 268, 292), + ]; + return stack({ blocks, design: DESIGN, edges: cardEdges(f), tailShrink: mixed ? 10 : 20 }).nodes; +} + +function placeholders(f: Frame) { + const bars = [ + ['bar-count', 80, 70, 60], + ['bar-label', 152, 140, 16], + ['bar-row-0', 210, 300, 14], + ['bar-row-0-sub', 232, 180, 11], + ['bar-row-1', 262, 260, 14], + ['bar-row-1-sub', 284, 160, 11], + ] as const; + return stack({ + blocks: [ + { + top: 80, + bottom: 295, + shrink: 20, + draw: dy => + bars.map(([key, y, width, height]) => bar(f, key, { x: 16, y: y + dy, width, height })), + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 20, + }).nodes; +} + +function empty(f: Frame) { + const value = f.copy.status ?? ''; + const lines = estimateWidth(value, 22, true) > f.width - 32 ? 2 : 1; + const extra = (lines - 1) * 26; + return stack({ + blocks: [ + { + top: boxTop(value, 22, 176), + bottom: 234 + extra, + shrink: 40, + draw: dy => [ + label(f, 'status', { + x: 16, + baseline: 176 + dy, + width: f.width - 32, + value, + size: 22, + weight: '600', + lines, + align: 'center', + }), + ...newAgentPill(f, { + x: 0, + y: 198 + extra + dy, + height: 36, + size: 14, + arm: 7, + centred: true, + }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 40, + }).nodes; +} + +export function large(f: Frame): ReactNode[] { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'locked') { + return locked(f, { + height: 382, + lockY: 168, + lockSize: 40, + baseline: 236, + size: 20, + wrap: false, + }); + } + if (phase === 'updating') { + return [...brand(f), ...placeholders(f), footer(f, true)]; + } + if (phase === 'empty') { + return [...brand(f), ...empty(f), footer(f, true)]; + } + const body = + copy.primaryKind === 'needsInput' || copy.primaryKind === 'scheduled' ? listed(f) : latest(f); + return [...brand(f), ...pillActions(f, canCreate(f)), ...body, footer(f, true)]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx new file mode 100644 index 0000000000..770bdc45e7 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx @@ -0,0 +1,183 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { canCreate, newAgentPill, pillActions } from './active-agents-widget-actions'; +import { + brand, + cardEdges, + countLines, + entries, + type Entry, + footer, + HEADER_BOTTOM, + locked, +} from './active-agents-widget-card'; +import { bar, boxTop, dot, type Frame, label } from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; +import { fitRows, phaseOf, stack } from './active-agents-widget-stack'; + +/** Medium (3x2, 4x2) at the 364x170 design; its right column starts at 172dp and scales. */ + +const DESIGN = { top: HEADER_BOTTOM, bottom: 140.4 }; + +/** A Medium row: dot, 13dp title, 11dp reason or time, 42dp apart. */ +function mediumRow(f: Frame, entry: Entry, spec: { index: number; x: number; baseline: number }) { + const { index, baseline } = spec; + const x = spec.x + 12; + const width = f.width - 16 - x; + return [ + dot(f, `row-${index}-dot`, { cx: spec.x, cy: baseline - 4, r: 4, fill: entry.ink }), + label(f, `row-${index}-title`, { x, baseline, width, value: entry.title, size: 13 }), + ...(entry.sub === null || entry.sub === '' + ? [] + : [ + label(f, `row-${index}-sub`, { + x, + baseline: baseline + 16, + width, + value: entry.sub, + size: 11, + ink: 'muted', + }), + ]), + ]; +} + +function placeholders(f: Frame, x: number) { + return stack({ + blocks: [ + { + top: 62, + bottom: 138, + shrink: 10, + draw: dy => [ + bar(f, 'bar-count', { x: 16, y: 62 + dy, width: 44, height: 34 }), + bar(f, 'bar-label', { x: 16, y: 104 + dy, width: 96, height: 12 }), + bar(f, 'bar-row-0', { x: x + 4, y: 66 + dy, width: 150, height: 12 }), + bar(f, 'bar-row-0-sub', { x: x + 4, y: 86 + dy, width: 100, height: 10 }), + bar(f, 'bar-row-1', { x: x + 4, y: 108 + dy, width: 140, height: 12 }), + bar(f, 'bar-row-1-sub', { x: x + 4, y: 128 + dy, width: 90, height: 10 }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + }).nodes; +} + +function empty(f: Frame) { + const value = f.copy.status ?? ''; + return stack({ + blocks: [ + { + top: boxTop(value, 20, 92), + bottom: 134, + shrink: 12, + draw: dy => [ + label(f, 'status', { + x: 16, + baseline: 92 + dy, + width: f.width - 32, + value, + size: 20, + weight: '600', + }), + ...newAgentPill(f, { x: 16, y: 106 + dy, height: 28, size: 13, arm: 6, centred: false }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 2, + }).nodes; +} + +/** The right column: waiting/scheduled rows, else the other counts, else the latest title. */ +function column( + f: Frame, + spec: { x: number; rows: Entry[]; count: number }, + dy: number +): ReactNode[] { + const { copy } = f; + const { x } = spec; + if (spec.rows.length > 0) { + return spec.rows + .slice(0, spec.count) + .flatMap((entry, index) => mediumRow(f, entry, { index, x, baseline: 74 + index * 42 + dy })); + } + if (copy.secondaryCounts.length > 0) { + return countLines(f, { x: x + 4, baseline: 78 + dy, step: 24, width: f.width - 16 - (x + 16) }); + } + if (copy.title === null) { + return []; + } + const width = f.width - 16 - (x + 4); + return [ + label(f, 'recent-heading', { + x: x + 4, + baseline: 88 + dy, + width, + value: copy.headings.recent, + size: 11, + weight: '600', + ink: 'muted', + }), + label(f, 'recent-title', { x: x + 4, baseline: 106 + dy, width, value: copy.title, size: 13 }), + ]; +} + +export function medium(f: Frame): ReactNode[] { + const phase = phaseOf(f.copy); + if (phase === 'locked') { + return locked(f, { + height: 170, + lockY: 83, + lockSize: 28, + baseline: 118, + size: 15, + wrap: false, + }); + } + const x = Math.round(f.width * 0.4725); + if (phase === 'updating') { + return [...brand(f), ...placeholders(f, x), footer(f, true)]; + } + if (phase === 'empty') { + // The pill is the one create control here: the header `+` is hidden. + return [...brand(f), ...empty(f), footer(f, true)]; + } + const rows = entries(f.copy); + let bottom = 115.7; + if (rows.length === 0 && f.copy.secondaryCounts.length > 0) { + bottom = 130.4; + } + const band = (count: number) => + stack({ + blocks: [ + { + top: 45.5, + bottom: 134.9, + height: (rows.length > 0 ? Math.max(bottom, 92.9 + (count - 1) * 42) : bottom) - 45.5, + shrink: 8, + draw: dy => [ + ...stackedStatus(f, { + x: 16, + countBaseline: 92 + dy, + countSize: 44, + labelBaseline: 112 + dy, + labelSize: 14, + r: 4, + width: x - 28, + }), + ...column(f, { x, rows, count }, dy), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 4, + }); + const count = fitRows(Math.min(3, rows.length), band); + return [...brand(f), ...pillActions(f, canCreate(f)), ...band(count).nodes, footer(f, true)]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx index f7bf2f6847..b1b77b3ace 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx @@ -3,333 +3,288 @@ import { FlexWidget, type HexColor, TextWidget } from 'react-native-android-widget'; -import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; - +import { type AndroidWidgetHomeCopy } from './home-copy'; import { type AndroidWidgetProps } from './widget-props'; -// Short rows reserve two 48dp targets, side by side on one band or stacked in a column. -export const ACTION_TARGET = 48; +/** + * Every layout here draws on one absolute canvas: the library renders the whole + * tree to a bitmap and overlays the clickable views by their bounds, so each + * element is placed by its design coordinate (dp from the top-left, LTR) and RTL + * mirrors that rectangle about the vertical axis. + */ -type Palette = { +export type Palette = { background: HexColor; foreground: HexColor; - mutedForeground: HexColor; - primary: HexColor; - primaryForeground: HexColor; + muted: HexColor; warn: HexColor; good: HexColor; + info: HexColor; + idle: HexColor; + primary: HexColor; + primaryForeground: HexColor; + secondary: HexColor; + divider: HexColor; }; + export type Paint = { palette: Palette; rtl: boolean }; -export type Copy = NonNullable<AndroidWidgetProps['homeCopy']>; -type CountLine = Copy['secondaryCounts'][number]; -export type EntryRow = { title: string; time: string | null }; -/** Strip and compact heroes put the label beside the number; cards stack it. */ -export type HeroVariant = 'strip' | 'compact' | 'regular' | 'large'; -const HERO_METRICS = { - strip: { inline: true, statusSize: 12, statusLines: 1, numberSize: 22, labelSize: 13 }, - compact: { inline: true, statusSize: 12, statusLines: 2, numberSize: 25, labelSize: 13 }, - regular: { inline: false, statusSize: 18, statusLines: 2, numberSize: 34, labelSize: 14 }, - large: { inline: false, statusSize: 18, statusLines: 3, numberSize: 44, labelSize: 14 }, -} as const; +export type Copy = AndroidWidgetHomeCopy; + +/** One placed widget in one palette. */ +export type Frame = { + props: AndroidWidgetProps; + copy: Copy; + paint: Paint; + width: number; + height: number; +}; + +export type Rect = { x: number; y: number; width: number; height: number }; + +export type Ink = 'foreground' | 'muted' | 'warn' | 'primaryForeground'; -const DOT_COLORS = { +export type Weight = 'normal' | '600' | 'bold'; + +const DOT_INK = { needsInput: 'warn', running: 'good', - idle: 'mutedForeground', - scheduled: 'mutedForeground', -} as const satisfies Record<CountLine['kind'], keyof Palette>; + scheduled: 'info', + idle: 'idle', +} as const satisfies Record<NonNullable<Copy['primaryKind']>, keyof Palette>; + +export function dotInk(kind: Copy['primaryKind']): keyof Palette { + return kind === null ? 'idle' : DOT_INK[kind]; +} const TALL_SCRIPT = /[\u0600-\u08FF\u0900-\u0DFF]/u; +/** Roboto's ascent plus font padding above the baseline, per unit of font size. */ +const BASELINE = 1.056; + +const LATIN_LINE = 1.32; + /** - * Largest font whose line box fits `height`. Scripts with tall fallback metrics - * (Arabic-Indic, Devanagari) need the host's 1.62 line ratio, not the Latin 1.32. + * The line box a TextView draws one line in: Roboto with font padding (1.32em), + * or the taller fallback box Arabic-Indic and Devanagari need (1.62em). */ -function fittedSize(value: string, height: number, preferred: number): number { - const ratio = TALL_SCRIPT.test(value) ? 1.62 : 1.32; - const sizes = [preferred, 12, 11, 10, 9].filter(size => size <= preferred); - return sizes.find(size => Math.ceil(size * ratio) <= height) ?? 9; +export function lineBox(value: string, size: number): number { + return Math.ceil(size * (TALL_SCRIPT.test(value) ? 1.62 : LATIN_LINE)); } -export function readingOrder(children: React.ReactNode[], rtl: boolean) { - return rtl ? [...children].toReversed() : children; +/** Top of the box whose Latin baseline sits at `baseline`; a taller script keeps the same centre. */ +export function boxTop(value: string, size: number, baseline: number): number { + return baseline - BASELINE * size - (lineBox(value, size) - Math.ceil(size * LATIN_LINE)) / 2; } -/** - * Pick the hero variant a band can hold, using the tallest fallback line metrics - * so neither the count nor its label is ever clipped. - */ -export function heroVariantFor(heroHeight: number): HeroVariant { - if (heroHeight >= 82) { - return 'large'; - } - if (heroHeight >= 68) { - return 'regular'; - } - if (heroHeight >= 33) { - return 'compact'; +/** Rough advance width, only for choices with slack (pill widths, one line or two). */ +export function estimateWidth(value: string, size: number, bold = false): number { + let ems = 0; + for (const char of value) { + if (/[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFF00-\uFF60]/u.test(char)) { + ems += 1; + } else if (/[\s.,:;'|!ilI]/u.test(char)) { + ems += 0.28; + } else if (/\p{Lu}/u.test(char)) { + ems += 0.66; + } else { + ems += 0.56; + } } - return 'strip'; + return ems * size * (bold ? 1.06 : 1); } -export function text( - value: string, - paint: Paint, - options: { - size: number; - bold?: boolean; - muted?: boolean; - lines?: number; - /** Centred copy ignores the surface direction: it is a composition axis, not a reading edge. */ - align?: 'left' | 'center' | 'right'; +export const tenth = (value: number) => Math.round(value * 10) / 10; + +/** The native placement of an LTR design rectangle. */ +export function place(f: Frame, rect: Rect) { + return { + marginLeft: tenth(f.paint.rtl ? f.width - rect.x - rect.width : rect.x), + marginTop: tenth(rect.y), + width: tenth(rect.width), + height: tenth(rect.height), + }; +} + +export function textAlign(f: Frame, align: 'start' | 'center' | 'end') { + if (align === 'center') { + return 'center'; } + return (align === 'end') === f.paint.rtl ? 'left' : 'right'; +} + +export function shape( + f: Frame, + key: string, + spec: Rect & { fill: keyof Palette; radius?: number } ) { return ( - <TextWidget - text={value} - maxLines={options.lines ?? 1} - truncate="END" - allowFontScaling={false} + <FlexWidget + key={key} style={{ - fontSize: options.size, - fontWeight: options.bold ? 'bold' : 'normal', - color: options.muted ? paint.palette.mutedForeground : paint.palette.foreground, - textAlign: options.align ?? (paint.rtl ? 'right' : 'left'), + ...place(f, spec), + backgroundColor: f.paint.palette[spec.fill], + borderRadius: spec.radius ?? 0, }} /> ); } -export function action( - props: AndroidWidgetProps, - kind: 'create' | 'approve' | 'approve-labeled', - paint: Paint +export function dot( + f: Frame, + key: string, + spec: { cx: number; cy: number; r: number; fill: keyof Palette } ) { - const create = kind === 'create'; - const labeled = kind === 'approve-labeled'; - const width = labeled ? 104 : ACTION_TARGET; - const approvalKey = props.home?.approvalKey ?? null; - const enabled = create - ? (props.home?.canCreate ?? props.actions.newAgent) - : props.home?.canApprove === true && - approvalKey !== null && - props.actionFeedback !== 'approving'; - if (!enabled) { - // Both controls own a fixed slot, even while approving or privacy-blanked. - return <FlexWidget key={kind} style={{ width, height: ACTION_TARGET }} />; - } - const label = create ? props.actions.newAgentLabel : props.actions.approveLabel; - // oxlint-disable-next-line no-literal-copy/no-literal-copy -- compact action glyphs, with translated spoken labels - const glyph = { create: '+', approve: '✓', 'approve-labeled': label }[kind]; - return ( - <FlexWidget - key={kind} - {...(create - ? { clickAction: 'OPEN_URI', clickActionData: { uri: LAUNCHER_NEW_AGENT_URL } } - : { clickAction: 'approve', clickActionData: { approvalKey } })} - accessibilityLabel={label} - style={{ width, height: ACTION_TARGET, alignItems: 'center', justifyContent: 'center' }} - > - <FlexWidget - style={{ - width: labeled ? 88 : 32, - height: 32, - borderRadius: 16, - backgroundColor: create ? paint.palette.primary : paint.palette.warn, - alignItems: 'center', - justifyContent: 'center', - }} - > - <TextWidget - text={glyph} - maxLines={1} - truncate="END" - allowFontScaling={false} - style={{ - fontSize: labeled ? 13 : 22, - color: paint.palette.primaryForeground, - fontWeight: 'bold', - }} - /> - </FlexWidget> - </FlexWidget> - ); + const { cx, cy, r } = spec; + return shape(f, key, { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + fill: spec.fill, + radius: r, + }); } -export function header(props: AndroidWidgetProps, paint: Paint, wide: boolean) { - return ( - <FlexWidget - style={{ - width: 'match_parent', - height: ACTION_TARGET, - flexDirection: 'row', - alignItems: 'center', - flexGap: 8, - }} - > - {readingOrder( - [ - <FlexWidget - key="brand" - style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} - > - {text('Kilo', paint, { size: 18, bold: true })} - </FlexWidget>, - action(props, wide ? 'approve-labeled' : 'approve', paint), - action(props, 'create', paint), - ], - paint.rtl - )} - </FlexWidget> - ); +/** A grey placeholder bar, its width clamped to the content edge. */ +export function bar(f: Frame, key: string, rect: Rect) { + const width = Math.max(8, Math.min(rect.width, f.width - rect.x - 16)); + return shape(f, key, { ...rect, width, fill: 'secondary', radius: rect.height / 2 }); } -export function hero(copy: Copy, paint: Paint, variant: HeroVariant) { - const metrics = HERO_METRICS[variant]; - if (copy.primaryLabel === null) { - return ( - <FlexWidget style={{ flex: 1, justifyContent: 'center' }}> - {text(copy.status ?? '', paint, { - size: metrics.statusSize, - bold: true, - lines: metrics.statusLines, - })} - </FlexWidget> - ); - } - const number = text(copy.primaryCount, paint, { size: metrics.numberSize, bold: true }); - const label = text(copy.primaryLabel, paint, { size: metrics.labelSize }); - const leadingEdge = paint.rtl ? 'flex-end' : 'flex-start'; - return ( - <FlexWidget - style={{ - flex: 1, - flexDirection: metrics.inline ? 'row' : 'column', - alignItems: metrics.inline ? 'center' : leadingEdge, - justifyContent: 'center', - flexGap: metrics.inline ? 6 : 0, - }} - > - {metrics.inline - ? readingOrder( - [ - <FlexWidget key="number">{number}</FlexWidget>, - <FlexWidget - key="label" - style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} - > - {label} - </FlexWidget>, - ], - paint.rtl - ) - : [ - <FlexWidget key="number">{number}</FlexWidget>, - <FlexWidget key="label">{label}</FlexWidget>, - ]} - </FlexWidget> - ); -} +export type LabelSpec = { + x: number; + baseline: number; + width: number; + value: string; + size: number; + weight?: Weight; + ink?: Ink; + lines?: number; + align?: 'start' | 'center' | 'end'; +}; -export function countRow(line: CountLine, paint: Paint) { +/** One text, ellipsized at its width; `lines` only lets copy that must read whole wrap. */ +export function label(f: Frame, key: string, spec: LabelSpec) { + const lines = spec.lines ?? 1; + const rect = { + x: spec.x, + y: boxTop(spec.value, spec.size, spec.baseline), + width: spec.width, + height: lineBox(spec.value, spec.size) * lines, + }; return ( - <FlexWidget - key={line.kind} + <TextWidget + key={key} + text={spec.value} + maxLines={lines} + truncate="END" + allowFontScaling={false} style={{ - width: 'match_parent', - height: 26, - flexDirection: 'row', - alignItems: 'center', - flexGap: 6, + ...place(f, rect), + fontSize: spec.size, + fontWeight: spec.weight ?? 'normal', + color: f.paint.palette[spec.ink ?? 'foreground'], + textAlign: textAlign(f, spec.align ?? 'start'), }} - > - {readingOrder( - [ - <FlexWidget - key="dot" - style={{ - width: 5, - height: 5, - borderRadius: 3, - backgroundColor: paint.palette[DOT_COLORS[line.kind]], - }} - />, - <FlexWidget key="number">{text(line.count, paint, { size: 15, bold: true })}</FlexWidget>, - <FlexWidget - key="label" - style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} - > - {text(line.label, paint, { size: 13, muted: true })} - </FlexWidget>, - ], - paint.rtl - )} - </FlexWidget> + /> ); } -/** `fallback` fills an empty detail slot with the first support count. */ -export function detailValue(copy: Copy, fallback: boolean): string { - if (copy.detail !== null && copy.detail !== '') { - return copy.detail; - } - const first = copy.secondaryCounts[0]; - return fallback && first !== undefined ? `${first.count} ${first.label}` : ''; -} +type Part = { value: string; weight?: Weight; ink?: Ink; flex?: boolean }; -export function detail( - copy: Copy, - paint: Paint, - slot: { height: number; fallback: boolean; size?: number; available?: number } +/** A one-line text inside a weighted slot: it takes the room left and ellipsizes. */ +export function flexText( + f: Frame, + key: string, + spec: { value: string; size: number; weight?: Weight; ink?: Ink } ) { - const composed = detailValue(copy, slot.fallback); - const first = entryRows(copy)[0]; - // A 148-179dp line fits the agent title, not "Last known - <title>". - const prefixed = - first !== undefined && - first.title !== '' && - composed.includes(first.title) && - composed !== first.title; - const value = prefixed && (slot.available ?? Infinity) < 200 ? first.title : composed; - const preferred = slot.size ?? (slot.height < 20 ? 11 : 12); return ( - <FlexWidget - style={{ - width: 'match_parent', - height: slot.height, - justifyContent: 'center', - alignItems: paint.rtl ? 'flex-end' : 'flex-start', - }} - > - {text(value, paint, { - size: fittedSize(value, slot.height, preferred), - muted: true, - })} + <FlexWidget key={key} style={{ width: 0, flex: 1 }}> + <TextWidget + text={spec.value} + maxLines={1} + truncate="END" + allowFontScaling={false} + style={{ + width: 'match_parent', + fontSize: spec.size, + fontWeight: spec.weight ?? 'normal', + color: f.paint.palette[spec.ink ?? 'foreground'], + textAlign: textAlign(f, 'start'), + }} + /> </FlexWidget> ); } -export function footer(copy: Copy, paint: Paint, height = 20) { - const value = copy.checked ?? ''; - const preferred = height >= 20 ? 12 : 11; +/** + * Texts sharing one baseline row; the one `flex` part ellipsizes. `fill` lets it + * take every spare dp (a trailing time at the content edge); otherwise the row + * hugs its copy, and the flex part only shrinks when the row would overflow. + * Reversed in RTL so reading order holds. + */ +export function textRow( + f: Frame, + key: string, + spec: { + x: number; + baseline: number; + width: number; + size: number; + gap: number; + fill: boolean; + parts: Part[]; + } +) { + const height = Math.max(...spec.parts.map(part => lineBox(part.value, spec.size))); + const tallest = spec.parts.find(part => lineBox(part.value, spec.size) === height)?.value ?? ''; + const children = spec.parts.map((part, index) => + part.flex === true ? ( + flexText(f, `${key}-${index}`, { ...part, size: spec.size }) + ) : ( + <TextWidget + key={`${key}-${index}`} + text={part.value} + maxLines={1} + truncate="END" + allowFontScaling={false} + style={{ + fontSize: spec.size, + fontWeight: part.weight ?? 'normal', + color: f.paint.palette[part.ink ?? 'foreground'], + }} + /> + ) + ); + const ordered = f.paint.rtl ? children.toReversed() : children; + const row = { flexDirection: 'row', alignItems: 'center', flexGap: spec.gap } as const; + const rect = place(f, { + x: spec.x, + y: boxTop(tallest, spec.size, spec.baseline), + width: spec.width, + height, + }); + if (spec.fill) { + return ( + <FlexWidget key={key} style={{ ...rect, ...row }}> + {ordered} + </FlexWidget> + ); + } + // A wrap-width row measured against the fixed slot: LinearLayout gives the weighted + // part its natural width, and takes only the overflow back from it. return ( <FlexWidget + key={key} style={{ - width: 'match_parent', - height, - justifyContent: 'center', - alignItems: paint.rtl ? 'flex-end' : 'flex-start', + ...rect, + flexDirection: 'row', + alignItems: 'center', + justifyContent: f.paint.rtl ? 'flex-end' : 'flex-start', }} > - {text(value, paint, { size: fittedSize(value, height, preferred), muted: true })} + <FlexWidget style={row}>{ordered}</FlexWidget> </FlexWidget> ); } - -/** Earliest waits first, then scheduled wakes: the card lists whichever the copy carries. */ -export function entryRows(copy: Copy): EntryRow[] { - return copy.waitingAgents.length > 0 - ? copy.waitingAgents.map(agent => ({ title: agent.title, time: agent.reason })) - : copy.scheduledAgents.map(agent => ({ title: agent.title, time: agent.time })); -} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx new file mode 100644 index 0000000000..aa800b3f1e --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx @@ -0,0 +1,208 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { approveSlot, canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; +import { + bar, + boxTop, + estimateWidth, + type Frame, + label, + lineBox, +} from './active-agents-widget-parts'; +import { statusRow } from './active-agents-widget-status'; +import { type Block, phaseOf, stack } from './active-agents-widget-stack'; + +/** Row (3x1, 4x1) at the 360x104 design; taller bands split the extra height across the gaps. */ + +const DESIGN = { top: 27.2, bottom: 90 }; + +export const approveFailed = (f: Frame) => + f.props.actionFeedback === 'couldNotApprove' && phaseOf(f.copy) === 'content'; + +/** The agent line a short cell shows: the lead title, with the scheduled wake after it. */ +function agentLine(f: Frame): string | null { + const { copy } = f; + if (copy.primaryKind === 'scheduled') { + return [copy.title, copy.wake].filter(Boolean).join(' · ') || null; + } + return copy.title; +} + +function header(f: Frame) { + const { copy } = f; + const phase = phaseOf(copy); + const nodes = [ + logo(f, { x: 14, y: 13, size: 14 }), + label(f, 'brand', { x: 34, baseline: 24, width: 26, value: 'Kilo', size: 12, weight: '600' }), + ]; + if ((phase === 'content' || phase === 'empty') && copy.footer !== null) { + const value = `· ${copy.footer}`; + nodes.push( + label(f, 'footer', { + x: 62, + baseline: 24, + width: f.width - 76, + value, + size: 11, + ink: 'muted', + }) + ); + } + return nodes; +} + +function lockedBlocks(f: Frame): Block[] { + const value = f.copy.status ?? ''; + const lines = estimateWidth(value, 15, true) > f.width - 62 ? 2 : 1; + const baseline = 72 - (lines - 1) * 9; + return [ + { + top: Math.min(55, boxTop(value, 15, baseline)), + bottom: boxTop(value, 15, baseline) + lineBox(value, 15) * lines, + shrink: 14, + draw: dy => [ + ...lock(f, { cx: 28, cy: 66 + dy, size: 20 }), + // Always room for a second line: an instruction never ellipsizes. + label(f, 'status', { + x: 48, + baseline: baseline + dy, + width: f.width - 62, + value, + size: 15, + weight: '600', + lines: 2, + }), + ], + }, + ]; +} + +function blocksFor(f: Frame): Block[] { + const { width: W, copy } = f; + const phase = phaseOf(copy); + const actions = (dy: number) => + roundActions(f, { + cy: 52 + dy, + r: 18, + plus: W - 32, + approve: W - 76, + plusTarget: W - 50, + create: canCreate(f), + }); + // The label ends before the Approve slot only while that slot draws. + const end = approveSlot(f) === null ? W - 58 : W - 102; + if (phase === 'locked') { + return lockedBlocks(f); + } + if (phase === 'updating') { + return [ + { + top: 40, + bottom: 66, + shrink: 8, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 40 + dy, width: 30, height: 26 }), + bar(f, 'bar-label', { x: 54, y: 46 + dy, width: 110, height: 14 }), + ], + }, + { + top: 78, + bottom: 88, + shrink: 6, + draw: dy => [bar(f, 'bar-line', { x: 14, y: 78 + dy, width: 180, height: 10 })], + }, + ]; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + const hint = f.props.actions.newAgentLabel; + return [ + { + top: boxTop(value, 17, 62), + bottom: 66.5, + shrink: 10, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 62 + dy, + width: end - 14, + value, + size: 17, + weight: '600', + }), + ...actions(dy), + ], + }, + { + top: 73.3, + bottom: 89.1, + shrink: 4, + draw: dy => [ + label(f, 'hint', { + x: 14, + baseline: 86 + dy, + width: end - 14, + value: hint, + size: 12, + ink: 'muted', + }), + ], + }, + ]; + } + const failed = approveFailed(f); + const line = failed ? copy.actionLine : agentLine(f); + return [ + { + top: 32.3, + bottom: 72, + shrink: 12, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 64 + dy, + width: end - 14, + countSize: 30, + labelSize: 15, + r: 4, + }), + ...actions(dy), + ], + }, + { + top: 74.3, + bottom: 90.1, + shrink: 8, + ...(line === null + ? {} + : { + draw: (dy: number) => [ + label(f, 'line', { + x: 14, + baseline: 87 + dy, + width: W - 28, + value: line, + size: 12, + ...(failed + ? { ink: 'warn' as const, weight: '600' as const } + : { ink: 'muted' as const }), + }), + ], + }), + }, + ]; +} + +export function row(f: Frame): ReactNode[] { + const phase = phaseOf(f.copy); + const { nodes } = stack({ + blocks: blocksFor(f), + design: DESIGN, + edges: { top: DESIGN.top, bottom: f.height - 14 }, + tailShrink: { locked: 8, updating: 2, empty: 6, content: 10 }[phase], + }); + return [...header(f), ...nodes]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx index 8fcb4af73a..799b98c19d 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx @@ -1,180 +1,278 @@ -/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ 'use no memo'; -import { FlexWidget } from 'react-native-android-widget'; +import { type ReactNode } from 'react'; -import { type Frame, MARGIN, rowDetail } from './active-agents-widget-card'; -import { - action, - ACTION_TARGET, - detailValue, - footer, - hero, - type Paint, - readingOrder, - text, -} from './active-agents-widget-parts'; -import { type AndroidWidgetProps } from './widget-props'; +import { approveSlot, canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; +import { bar, boxTop, type Frame, type Ink, label, textRow } from './active-agents-widget-parts'; +import { statusRow } from './active-agents-widget-status'; +import { approveFailed } from './active-agents-widget-row'; +import { type Block, phaseOf, stack } from './active-agents-widget-stack'; -/** Above this width a short cell puts the actions beside the text instead of under it. */ -const SIDE_ACTIONS_WIDTH = 300; +/** Narrow (2x1: 172x104, rounds 5b/5c) and Landscape (~62dp rows, round 2b) cells. */ -/** The two 48dp controls, side by side, always in a fixed 100dp group. */ -function actions(props: AndroidWidgetProps, paint: Paint) { - return ( - <FlexWidget key="actions" style={{ flexDirection: 'row', alignItems: 'center', flexGap: 4 }}> - {readingOrder([action(props, 'approve', paint), action(props, 'create', paint)], paint.rtl)} - </FlexWidget> - ); +/** The 2x1 cell's last line: a failed approve, the scheduled wake, or the checked time. */ +function narrowFoot(f: Frame): { value: string; ink: Ink } | null { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { value: copy.status ?? '', ink: 'muted' }; + } + if (approveFailed(f)) { + return { value: copy.actionLine ?? '', ink: 'warn' }; + } + const value = (phase === 'content' ? copy.wake : null) ?? copy.footer; + return value === null ? null : { value, ink: 'muted' }; } -/** - * A wide short cell (landscape rows, 4x1 portrait): Kilo and the count share the - * first band, then the detail and the checked time; the actions sit in a 100dp - * column beside them so both lines get the full width. Each band is one line. - */ -function shortWide({ props, copy, paint, info }: Frame) { - const hasDetail = detailValue(copy, false) !== ''; - const textWidth = info.width - (ACTION_TARGET * 2 + 4) - MARGIN * 2 - 4; - const smallSize = info.width >= 400 ? 12 : 10; - const hasChecked = (copy.checked ?? '') !== ''; - const lines = 1 + (hasDetail ? 1 : 0) + (hasChecked ? 1 : 0); - // A 30dp head (22dp count) plus 15dp per extra line is the tightest every script fits. - const extra = lines - 1; - const outer = Math.max(0, Math.min(MARGIN, Math.floor((info.height - (30 + extra * 15)) / 2))); - const content = info.height - 2 * outer; - let small = Math.max(15, Math.min(24, Math.floor((content - 30) / Math.max(1, extra)))); - let head = content - small * extra; - if (head < 30) { - small = Math.floor((content - 30) / Math.max(1, extra)); - head = content - small * extra; +function narrowFirst(f: Frame): Block { + const { width: W, copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { + top: 50, + bottom: 70, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 50 + dy, width: 24, height: 20 }), + bar(f, 'bar-label', { x: 46, y: 54 + dy, width: 80, height: 12 }), + ], + }; } - return ( - <FlexWidget - style={{ - width: 'match_parent', - flexDirection: 'row', - alignItems: 'center', - flexGap: 4, - marginHorizontal: MARGIN, - marginVertical: outer, - }} - > - {readingOrder( - [ - <FlexWidget key="text" style={{ width: 0, flex: 1 }}> - <FlexWidget - key="head" - style={{ - width: 'match_parent', - height: head, - flexDirection: 'row', - alignItems: 'center', - flexGap: 6, - }} - > - {readingOrder( - [ - <FlexWidget key="brand"> - {text('Kilo', paint, { size: 14, bold: true })} - </FlexWidget>, - <FlexWidget key="hero" style={{ width: 0, flex: 1 }}> - {hero(copy, paint, 'strip')} - </FlexWidget>, - ], - paint.rtl - )} - </FlexWidget> - {hasDetail - ? rowDetail(copy, paint, { height: small, size: smallSize, available: textWidth }) - : null} - {hasChecked ? footer(copy, paint, small) : null} - </FlexWidget>, - actions(props, paint), - ], - paint.rtl - )} - </FlexWidget> - ); + if (phase === 'empty') { + const value = copy.emptyShort; + return { + top: boxTop(value, 14, 66), + bottom: 69.7, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 66 + dy, + width: W - 28, + value, + size: 14, + weight: '600', + }), + ], + }; + } + return { + top: 40.5, + bottom: 74.9, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 68 + dy, + width: W - 28, + countSize: 26, + labelSize: 13, + r: 3.5, + }), + ], + }; } -/** - * The narrowest short cell (2x1 portrait, 172dp): the count owns the header band - * beside the two 48dp controls, then the brand with its label, the detail and the - * checked time each get the full 148dp width. Every line is one line, so nothing - * ellipsises. The label cannot join the count band: a 22dp count, a 48dp control - * band and two tall-script-safe 15dp lines already need 108dp of the 104dp cell. - */ -function shortNarrow({ props, copy, paint, info }: Frame) { - const count = copy.primaryLabel !== null; - const label = count ? copy.primaryCount : 'Kilo'; - const hasDetail = detailValue(copy, false) !== ''; - const hasChecked = (copy.checked ?? '') !== ''; - const band = 20; - const line = 15; - const avail = info.width - MARGIN * 2; - const total = ACTION_TARGET + band + (hasDetail ? line : 0) + (hasChecked ? line : 0); - const outer = Math.max(0, Math.min(MARGIN, Math.floor((info.height - total) / 2))); - return ( - <FlexWidget style={{ width: 'match_parent', marginHorizontal: MARGIN, marginVertical: outer }}> - <FlexWidget - key="head" - style={{ - width: 'match_parent', - height: ACTION_TARGET, - flexDirection: 'row', - alignItems: 'center', - flexGap: 6, - }} - > - {readingOrder( - [ - <FlexWidget - key="count" - style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} - > - {count - ? text(label, paint, { size: 22, bold: true }) - : text(label, paint, { size: 15, bold: true })} - </FlexWidget>, - actions(props, paint), - ], - paint.rtl - )} - </FlexWidget> - <FlexWidget - key="label" - style={{ - width: 'match_parent', - height: band, - flexDirection: 'row', - alignItems: 'center', - flexGap: 6, - }} - > - {readingOrder( - [ - count ? ( - <FlexWidget key="brand">{text('Kilo', paint, { size: 12, bold: true })}</FlexWidget> - ) : null, - <FlexWidget - key="value" - style={{ width: 0, flex: 1, alignItems: paint.rtl ? 'flex-end' : 'flex-start' }} - > - {count - ? text(copy.primaryLabel ?? '', paint, { size: 12, muted: true }) - : text(copy.status ?? '', paint, { size: 12, bold: true })} - </FlexWidget>, +export function narrow(f: Frame): ReactNode[] { + const { width: W, height: H, copy } = f; + const phase = phaseOf(copy); + const brand = [ + logo(f, { x: 14, y: 14, size: 16 }), + label(f, 'brand', { x: 36, baseline: 27, width: 40, value: 'Kilo', size: 12, weight: '600' }), + ]; + const design = { top: 36, bottom: 90 }; + const edges = { top: 36, bottom: H - 14 }; + if (phase === 'locked') { + const value = copy.status ?? ''; + const status = { + x: 42, + baseline: 62, + width: W - 56, + value, + size: 12, + weight: '600', + lines: 2, + } as const; + const block: Block = { + top: boxTop(value, 12, 62), + bottom: 80, + shrink: 10, + draw: dy => [ + ...lock(f, { cx: 24, cy: 66 + dy, size: 18 }), + label(f, 'status', { ...status, baseline: 62 + dy }), + ], + }; + return [...brand, ...stack({ blocks: [block], design, edges, tailShrink: 8 }).nodes]; + } + const actions = roundActions(f, { + cy: 22, + r: 14, + plus: W - 28, + approve: W - 60, + plusTarget: W - 48, + create: canCreate(f), + }); + const foot = narrowFoot(f); + const footBlock: Block = { + top: 78.4, + bottom: 92.9, + shrink: 4, + ...(foot === null + ? {} + : { + draw: (dy: number) => [ + label(f, 'footer', { + x: 14, + baseline: 90 + dy, + width: W - 28, + value: foot.value, + size: 11, + ink: foot.ink, + ...(foot.ink === 'warn' ? { weight: '600' as const } : {}), + }), ], - paint.rtl - )} - </FlexWidget> - {hasDetail ? rowDetail(copy, paint, { height: line, size: 11, available: avail }) : null} - {hasChecked ? footer(copy, paint, line) : null} - </FlexWidget> - ); + }), + }; + const blocks = [{ ...narrowFirst(f), shrink: 10 }, footBlock]; + return [...brand, ...actions, ...stack({ blocks, design, edges, tailShrink: 6 }).nodes]; +} + +function landscapeFirst(f: Frame, end: number): Block { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { + top: 13, + bottom: 33, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 13 + dy, width: 22, height: 20 }), + bar(f, 'bar-label', { x: 46, y: 17 + dy, width: 90, height: 12 }), + ], + }; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + return { + top: boxTop(value, 14, 28), + bottom: 31.7, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 28 + dy, + width: end - 14, + value, + size: 14, + weight: '600', + }), + ], + }; + } + return { + top: 7.8, + bottom: 36.9, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 31 + dy, + width: end - 14, + countSize: 22, + labelSize: 14, + r: 3.5, + }), + ], + }; } -export function short(frame: Frame) { - return frame.info.width >= SIDE_ACTIONS_WIDTH ? shortWide(frame) : shortNarrow(frame); +/** The second line: logo + checked time (with the title first from 400dp), or the failure. */ +function landscapeSecond(f: Frame, end: number): Block { + const { copy } = f; + const phase = phaseOf(copy); + const tail = + phase === 'updating' ? copy.status : ((phase === 'content' ? copy.wake : null) ?? copy.footer); + const title = phase === 'content' && f.width >= 400 ? copy.title : null; + const block = { top: 37.4, bottom: 51.9 }; + if (approveFailed(f)) { + const value = copy.actionLine ?? ''; + return { + ...block, + draw: dy => [ + label(f, 'line', { + x: 14, + baseline: 49 + dy, + width: end - 14, + value, + size: 11, + weight: '600', + ink: 'warn', + }), + ], + }; + } + if (tail === null) { + return block; + } + const muted = 'muted' as const; + const parts = [ + ...(title === null + ? [] + : [ + { value: title, ink: muted, flex: true }, + { value: '·', ink: muted }, + ]), + { value: tail, ink: muted, ...(title === null ? { flex: true } : {}) }, + ]; + return { + ...block, + draw: dy => [ + logo(f, { x: 14, y: 40 + dy, size: 11 }), + textRow(f, 'line', { + x: 33, + baseline: 49 + dy, + width: end - 33, + size: 11, + gap: 8, + fill: false, + parts, + }), + ], + }; +} + +export function landscape(f: Frame): ReactNode[] { + const { width: W, height: H, copy } = f; + const phase = phaseOf(copy); + const design = { top: 0, bottom: 62 }; + const edges = { top: 0, bottom: H }; + if (phase === 'locked') { + const value = copy.status ?? ''; + const block: Block = { + top: 21, + bottom: 40, + shrink: 8, + draw: dy => [ + ...lock(f, { cx: 26, cy: 31 + dy, size: 18 }), + label(f, 'status', { + x: 46, + baseline: 36 + dy, + width: W - 60, + value, + size: 14, + weight: '600', + }), + ], + }; + return stack({ blocks: [block], design, edges, tailShrink: 8 }).nodes; + } + const end = approveSlot(f) === null ? W - 60 : W - 102; + const actions = + phase === 'updating' + ? [] + : roundActions(f, { + cy: H / 2, + r: 18, + plus: W - 32, + approve: W - 74, + plusTarget: W - 50, + create: canCreate(f), + }); + const blocks = [{ ...landscapeFirst(f, end), shrink: 4 }, landscapeSecond(f, end)]; + return [...actions, ...stack({ blocks, design, edges, tailShrink: 4 }).nodes]; } diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts b/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts new file mode 100644 index 0000000000..34faca53fb --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts @@ -0,0 +1,77 @@ +import { type ReactNode } from 'react'; + +import { type Copy, tenth } from './active-agents-widget-parts'; + +export type Phase = 'locked' | 'updating' | 'empty' | 'content'; + +const PHASES = { + privacy: 'locked', + unavailable: 'locked', + signed_out: 'locked', + waiting: 'updating', + empty: 'empty', + content: 'content', +} as const satisfies Record<Copy['statusKind'], Phase>; + +export function phaseOf(copy: Copy): Phase { + return PHASES[copy.statusKind]; +} + +/** + * A vertical band of the design: `top`/`bottom` are its design extent (with + * every optional block present), `height` what it draws now. `draw` gets the + * offset from its design position; an absent `draw` omits the block. + */ +export type Block = { + top: number; + bottom: number; + height?: number; + shrink?: number; + draw?: (dy: number) => ReactNode[]; +}; + +type Edges = { top: number; bottom: number }; + +/** + * Lay blocks between two edges. Gaps keep their design size; free height (also + * the room an omitted block leaves) splits evenly across every gap so it never + * pools in one hole, and a short cell takes the shortfall from each gap's + * shrinkable share. `slack` is the free height before it is spread. + */ +export function stack(spec: { blocks: Block[]; design: Edges; edges: Edges; tailShrink?: number }) { + const { blocks, design, edges } = spec; + const gaps = blocks.map((block, index) => block.top - (blocks[index - 1]?.bottom ?? design.top)); + const tail = design.bottom - (blocks.at(-1)?.bottom ?? design.top); + const present = blocks.flatMap((block, index) => + block.draw === undefined ? [] : [{ block, gap: gaps[index] ?? 0 }] + ); + const natural = + present.reduce( + (sum, { block, gap }) => sum + gap + (block.height ?? block.bottom - block.top), + 0 + ) + tail; + // Tenths absorb float noise, so an exact fit is not read as a shortfall. + const slack = tenth(edges.bottom - edges.top - natural); + const shrinks = [...present.map(({ block }) => block.shrink ?? 0), spec.tailShrink ?? 0]; + const shrinkable = shrinks.reduce((sum, value) => sum + value, 0); + const deltas = shrinks.map(value => + slack >= 0 || shrinkable === 0 ? slack / shrinks.length : (slack * value) / shrinkable + ); + let y = edges.top; + const nodes = present.flatMap(({ block, gap }, index) => { + y += gap + (deltas[index] ?? 0); + const drawn = block.draw?.(y - block.top) ?? []; + y += block.height ?? block.bottom - block.top; + return drawn; + }); + return { slack, nodes }; +} + +/** The most rows (from `most` down to one) whose layout still fits without shrinking. */ +export function fitRows(most: number, layout: (rows: number) => { slack: number }): number { + let rows = most; + while (rows > 1 && layout(rows).slack < 0) { + rows -= 1; + } + return rows; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts new file mode 100644 index 0000000000..09a4d7f835 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts @@ -0,0 +1,232 @@ +import { buildHomeWidgetData } from '@kilocode/app-shared/home-widget'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; +import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; +import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; + +import { + byKey, + CELLS, + DESIGN, + hasKey, + nodes, + NOW, + placed, + rectOf, + render, + texts, + translate, +} from './active-agents-widget.test-helpers'; +import { + close, + LONG, + PERMISSION, + type State, + stateProps, + STATES, + targets, +} from './active-agents-widget.test-fixtures'; +import { buildCurrentWidgetProps } from './widget-props'; + +vi.mock('react-native-android-widget', () => ({ + FlexWidget: () => null, + OverlapWidget: () => null, + TextWidget: () => null, +})); + +beforeEach(() => { + _resetHomeWidgetDataForTests(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); +}); + +describe('Home widget extra height', () => { + it('spends a taller Small cell on agent lines, then splits the rest evenly', () => { + const props = stateProps(STATES['needs input with Approve']); + const short = render(props, [170, 170]).light; + const tall = render(props, [172, 224]).light; + expect(placed(short).filter(node => node.key?.startsWith('line-'))).toHaveLength(1); + expect(placed(tall).filter(node => node.key?.startsWith('line-'))).toHaveLength(3); + close(rectOf(byKey(tall, 'footer')).y, 224 - 18 - 1.056 * 11); + }); + + it('adds Medium and Large rows only when they fit', () => { + const props = stateProps(STATES['needs input with Approve']); + const rows = (size: readonly [number, number]) => + placed(render(props, size).light).filter(node => /^row-\d-title$/u.test(node.key ?? '')) + .length; + expect(rows([467, 208])).toBe(2); + expect(rows([360, 224])).toBe(3); + expect(rows([360, 344])).toBe(2); + expect(rows([360, 464])).toBe(3); + }); + + it('splits a taller narrow cell evenly across its three gaps', () => { + const props = stateProps(STATES.working); + const y = (height: number, key: string) => + rectOf(byKey(render(props, [172, height]).light, key)).y; + const extra = 31; + close(y(135, 'status') - y(104, 'status'), extra / 3); + close(y(135, 'footer') - y(104, 'footer'), (extra * 2) / 3); + }); +}); + +describe('Home widget states', () => { + const at = (state: State, size: readonly [number, number]) => + render(stateProps(state), size).light; + + it('shows approve progress in the Approve slot and the failure in place of the detail', () => { + const approving = at(STATES.approving, DESIGN.small); + expect(hasKey(approving, 'approving-glyph')).toBe(true); + expect(hasKey(approving, 'approve-target')).toBe(false); + expect(hasKey(approving, 'create-target')).toBe(true); + expect(texts(at(STATES.approving, DESIGN.medium))).toContain('Approving…'); + const failed = at(STATES['could not approve'], DESIGN.small); + expect(byKey(failed, 'line-0').props.text).toBe('Could not approve'); + expect(hasKey(failed, 'approve-target')).toBe(true); + expect(byKey(at(STATES['could not approve'], DESIGN.medium), 'footer').props.text).toBe( + "Couldn't approve. Tap Approve to try again." + ); + expect(byKey(at(STATES['could not approve'], DESIGN.narrow), 'footer').props.text).toBe( + 'Could not approve' + ); + }); + + it('keeps the Approve slot empty without moving `+` when no approval is possible', () => { + for (const size of Object.values(DESIGN)) { + const withApprove = at(STATES['needs input with Approve'], size); + const without = at(STATES['needs input without Approve'], size); + expect(hasKey(without, 'approve-target')).toBe(false); + expect(rectOf(byKey(without, 'create-target'))).toEqual( + rectOf(byKey(withApprove, 'create-target')) + ); + } + }); + + it('hides the header `+` on an empty Medium/Large cell behind one New agent pill', () => { + for (const size of [DESIGN.medium, DESIGN.large, [266, 344] as const]) { + const root = at(STATES.empty, size); + expect(hasKey(root, 'create-glyph')).toBe(false); + expect(hasKey(root, 'new-agent-pill')).toBe(true); + expect(targets(root)).toHaveLength(1); + expect(texts(root)).toEqual( + expect.arrayContaining(['Nothing running right now', 'New agent']) + ); + } + expect(hasKey(at(STATES.empty, DESIGN.small), 'create-glyph')).toBe(true); + expect(texts(at(STATES.empty, DESIGN.narrow))).toContain('No work in progress'); + }); + + it('draws updating placeholders with no actions', () => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES.updating, size); + expect(targets(root)).toEqual([]); + expect(placed(root).some(node => node.key?.startsWith('bar-'))).toBe(true); + // The 4x1 row draws bars only (round 5): its header has no footer slot while updating. + if (size !== DESIGN.row) { + expect(texts(root)).toContain('Updating agents'); + } + } + }); + + it.each(['privacy', 'signed out'] as const)( + 'locks %s with a two-shape lock and no actions or titles', + name => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES[name], size); + expect(hasKey(root, 'lock-shackle')).toBe(true); + expect(hasKey(root, 'lock-body')).toBe(true); + expect(targets(root)).toEqual([]); + const shown = texts(root).join(' '); + expect(shown).not.toContain('Review the release'); + expect(shown).not.toContain('Checked'); + } + } + ); + + it('labels scheduled time today, overdue and unknown without relative words', () => { + const today = texts(at(STATES['scheduled today'], DESIGN.small)); + expect(today).toContain('Next run 8:00 PM'); + expect(texts(at(STATES['scheduled today'], DESIGN.large))).toEqual( + expect.arrayContaining(['Usage report', 'Dependency audit', '8:00 PM']) + ); + expect(texts(at(STATES['scheduled today'], DESIGN.narrow))).toContain('Next run 8:00 PM'); + const overdue = at(STATES['scheduled overdue'], DESIGN.small); + expect(byKey(overdue, 'line-0').props.style?.textAlign).toBe('left'); + expect(byKey(overdue, 'line-0').props.text).toBe('Awaiting update'); + expect(texts(at(STATES['scheduled unknown time'], DESIGN.small))).toContain('Dependency audit'); + }); + + it('shows the latest title for working and idle, and counts for mixed', () => { + expect(texts(at(STATES.working, DESIGN.medium))).toEqual( + expect.arrayContaining(['Recent', 'Fix the flaky login test']) + ); + expect(texts(at(STATES.mixed, DESIGN.medium))).toEqual( + expect.arrayContaining(['1 Scheduled', '1 Idle']) + ); + expect(texts(at(STATES.mixed, DESIGN.large))).toEqual( + expect.arrayContaining(['Recent', 'Build the app', 'Next scheduled', 'Morning checks']) + ); + expect(hasKey(at(STATES.idle, DESIGN.small), 'status')).toBe(true); + }); + + it('keeps a stress count at full size and an untitled agent generic', () => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES.stress, size); + const count = nodes(root).find(node => node.props.text === '9999'); + expect(count?.props.style?.fontSize).toBeGreaterThanOrEqual(22); + } + expect(texts(at(STATES.stress, DESIGN.large))).toEqual(expect.arrayContaining([LONG, 'Agent'])); + }); + + it.each(CELLS)('budgets German and Arabic/RTL copy at %dx%d', (width, height) => { + for (const language of ['de', 'ar']) { + const translated = (key: string) => + language === 'ar' ? `العربية ${translate(key)}` : `Deutsch ${translate(key)}`; + const props = stateProps(STATES['needs input with Approve'], translated); + const root = render(props, [width, height], language === 'ar').light; + for (const node of placed(root)) { + const rect = rectOf(node); + expect(rect.y + rect.height, String(node.key)).toBeLessThanOrEqual(height + 0.05); + expect(rect.y, String(node.key)).toBeGreaterThanOrEqual(-0.05); + } + const label = nodes(root).find(node => node.props.text === props.homeCopy?.primaryLabel); + expect(label?.props.style?.textAlign).toBe(language === 'ar' ? 'right' : 'left'); + } + }); + + it('uses the approved palettes', () => { + const { light, dark } = render(stateProps(STATES.working), DESIGN.small); + expect(light.props.style?.backgroundColor).toBe(lightColors.background); + expect(dark.props.style?.backgroundColor).toBe(darkColors.card); + }); + + it('retains last-known data after activity expiry without renewing the checked time', () => { + const data = buildHomeWidgetData({ + sessions: PERMISSION, + userId: 'u1', + organizationId: null, + now: NOW, + }); + vi.setSystemTime(Date.parse(data.snapshot.expiresAt) + 1); + const props = buildCurrentWidgetProps( + data.snapshot, + translate, + String, + String, + () => '8:00 PM', + data + ); + expect(props.home?.checkedAt).toBe(data.snapshot.updatedAt); + const root = render(props, DESIGN.narrow).light; + expect(texts(root)).toEqual( + expect.arrayContaining(['3', 'Needs input', 'Last known · 8:00 PM']) + ); + expect(hasKey(root, 'create-target')).toBe(true); + }); +}); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx new file mode 100644 index 0000000000..cf94cac1ab --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx @@ -0,0 +1,120 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget, TextWidget } from 'react-native-android-widget'; + +import { + boxTop, + dotInk, + flexText, + type Frame, + label, + lineBox, + place, +} from './active-agents-widget-parts'; + +/** Spec: count, dot and label share one row with fixed 6dp gaps; only the label ellipsizes. */ +const STATUS_GAP = 6; + +function statusDot(f: Frame, r: number) { + return ( + <FlexWidget + key="dot" + style={{ + width: r * 2, + height: r * 2, + borderRadius: r, + backgroundColor: f.paint.palette[dotInk(f.copy.primaryKind)], + }} + /> + ); +} + +/** + * Count → dot → label on one row, centred on the count. The count keeps its + * natural width; the label takes what is left up to `width` and ellipsizes. + */ +export function statusRow( + f: Frame, + spec: { + x: number; + baseline: number; + width: number; + countSize: number; + labelSize: number; + r: number; + } +) { + const count = f.copy.primaryCount; + const value = f.copy.primaryLabel ?? ''; + const countBox = lineBox(count, spec.countSize); + const height = Math.max(countBox, lineBox(value, spec.labelSize)); + const top = boxTop(count, spec.countSize, spec.baseline) - (height - countBox) / 2; + const children = [ + <TextWidget + key="count" + text={count} + maxLines={1} + allowFontScaling={false} + style={{ fontSize: spec.countSize, fontWeight: 'bold', color: f.paint.palette.foreground }} + />, + statusDot(f, spec.r), + flexText(f, 'label', { value, size: spec.labelSize, weight: '600' }), + ]; + return ( + <FlexWidget + key="status" + style={{ + ...place(f, { x: spec.x, y: top, width: spec.width, height }), + flexDirection: 'row', + alignItems: 'center', + flexGap: STATUS_GAP, + }} + > + {f.paint.rtl ? children.toReversed() : children} + </FlexWidget> + ); +} + +/** The stacked count (Small/Medium/Large): the number on its own line, then dot → label. */ +export function stackedStatus( + f: Frame, + spec: { + x: number; + countBaseline: number; + countSize: number; + labelBaseline: number; + labelSize: number; + r: number; + width: number; + } +) { + const value = f.copy.primaryLabel ?? ''; + const children = [ + statusDot(f, spec.r), + flexText(f, 'label', { value, size: spec.labelSize, weight: '600' }), + ]; + // The dot's centre sits 5dp in from the count's leading edge. + const rect = { + x: spec.x + 5 - spec.r, + y: boxTop(value, spec.labelSize, spec.labelBaseline), + width: spec.width - 5 + spec.r, + height: lineBox(value, spec.labelSize), + }; + return [ + label(f, 'count', { + x: spec.x, + baseline: spec.countBaseline, + width: spec.width, + value: f.copy.primaryCount, + size: spec.countSize, + weight: 'bold', + }), + <FlexWidget + key="status" + style={{ ...place(f, rect), flexDirection: 'row', alignItems: 'center', flexGap: STATUS_GAP }} + > + {f.paint.rtl ? children.toReversed() : children} + </FlexWidget>, + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts new file mode 100644 index 0000000000..f013699e2d --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts @@ -0,0 +1,100 @@ +import { + type buildHomeWidgetData, + type HomeWidgetSessionRow, +} from '@kilocode/app-shared/home-widget'; +import { expect } from 'vitest'; + +import { type GlanceableActionFeedback, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; + +import { + type Element, + NOW, + placed, + propsFor, + type Rect, + translate, + WAKE, +} from './active-agents-widget.test-helpers'; + +/** Home widget states every renderer test walks. */ + +const KEY = 'a'.repeat(64); +export const LONG = 'Untangle the payment reconciliation job that keeps timing out every night'; +export const PERMISSION: HomeWidgetSessionRow[] = [ + { status: 'permission', title: 'Review the release', approvalKey: KEY }, + { status: 'question', title: 'Pick a color for the badge' }, + { status: 'question', title: 'Choose the migration strategy' }, + { status: 'busy', title: 'Build the app' }, + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle', title: 'Connected agent' }, +]; +const MIXED: HomeWidgetSessionRow[] = [ + { status: 'busy', title: 'Build the app' }, + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle', title: 'Connected agent' }, +]; +export type State = { + sessions: HomeWidgetSessionRow[]; + status?: Parameters<typeof buildHomeWidgetData>[0]['status']; + feedback?: GlanceableActionFeedback; +}; +export const STATES = { + 'needs input with Approve': { sessions: PERMISSION }, + 'needs input without Approve': { + sessions: [ + { status: 'question', title: 'Pick a color for the badge' }, + { status: 'retry', title: 'Recover the interrupted build' }, + ], + }, + approving: { sessions: PERMISSION, feedback: 'approving' }, + 'could not approve': { sessions: PERMISSION, feedback: 'couldNotApprove' }, + working: { sessions: [{ status: 'busy', title: 'Fix the flaky login test' }] }, + idle: { sessions: [{ status: 'idle', title: 'Review the onboarding copy' }] }, + mixed: { sessions: MIXED }, + 'scheduled today': { + sessions: [ + { status: 'scheduled', title: 'Usage report', scheduledAt: WAKE }, + { + status: 'scheduled', + title: 'Dependency audit', + scheduledAt: new Date(NOW + 9e6).toISOString(), + }, + ], + }, + 'scheduled overdue': { + sessions: [ + { + status: 'scheduled', + title: 'Dependency audit', + scheduledAt: new Date(NOW - 60_000).toISOString(), + }, + ], + }, + 'scheduled unknown time': { sessions: [{ status: 'scheduled', title: 'Dependency audit' }] }, + empty: { sessions: [], status: 'empty' }, + updating: { sessions: [], status: 'waiting' }, + privacy: { sessions: PERMISSION, status: 'privacy' }, + 'signed out': { sessions: PERMISSION, status: 'signed_out' }, + stress: { + sessions: [ + { status: 'permission', title: LONG, approvalKey: KEY }, + { status: 'question' }, + ...Array.from({ length: 9997 }, () => ({ status: 'question' as const })), + ], + }, +} satisfies Record<string, State>; +export function stateProps(state: State, copy = translate) { + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: state.feedback ?? null }); + return propsFor(state.sessions, state.status, copy); +} + +export function close(actual: number, expected: number) { + expect(actual).toBeCloseTo(expected, 0); +} +/** A placed rect read back in LTR design coordinates. */ +export function ltr(rect: Rect, frame: { width: number; rtl: boolean }): Rect { + return frame.rtl ? { ...rect, x: frame.width - rect.x - rect.width } : rect; +} +export function targets(root: Element) { + return placed(root).filter(node => node.key === 'create-target' || node.key === 'approve-target'); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts index a1c5c0bc58..b3ae0890f5 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts @@ -1,5 +1,4 @@ import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; -import { expect } from 'vitest'; import { renderActiveAgentsWidget } from './active-agents-widget'; import { type AndroidWidgetProps, buildAndroidWidgetProps } from './widget-props'; @@ -14,8 +13,10 @@ const COPY: Record<string, string> = { 'common.scheduled': 'Scheduled', 'common.idle': 'Idle', 'common.agent': 'Agent', + 'common.recent': 'Recent', 'glanceable.empty': 'No work in progress', - 'glanceable.waiting': 'Waiting for agents', + 'home.noLiveSessions': 'Nothing running right now', + 'glanceable.waiting': 'Updating agents', 'glanceable.signedOut': 'Sign in to see agents', 'glanceable.privacy': 'Open Kilo to see agents', 'glanceable.checked': 'Checked', @@ -23,9 +24,12 @@ const COPY: Record<string, string> = { 'glanceable.awaitingUpdate': 'Awaiting update', 'glanceable.nextRun': 'Next run', 'glanceable.newAgent': 'New agent', + 'glanceable.waitingForYou': 'Waiting for you', + 'glanceable.nextScheduled': 'Next scheduled', 'common.approve': 'Approve', 'glanceable.approving': 'Approving…', 'glanceable.couldNotApprove': 'Could not approve', + 'glanceable.approveFailed': "Couldn't approve. Tap Approve to try again.", 'agentChat.permissionCard.title': 'Permission required', 'glanceable.answerNeeded': 'Answer needed', 'glanceable.waitingToRetry': 'Waiting to retry', @@ -33,11 +37,20 @@ const COPY: Record<string, string> = { }; export const translate = (key: string) => COPY[key] ?? key; -// Launcher-reported dp frames, including the shallow landscape variants. +/** The design frames, then launcher-reported dp cells including the landscape bands. */ +export const DESIGN = { + small: [170, 170], + medium: [364, 170], + large: [364, 382], + row: [360, 104], + narrow: [172, 104], + landscape: [627, 62], +} as const; export const CELLS = [ [172, 104], [266, 104], [360, 104], + [172, 135], [172, 224], [266, 224], [360, 224], @@ -55,27 +68,34 @@ export const CELLS = [ [467, 208], [627, 208], [627, 281], + ...Object.values(DESIGN), ] as const; +type Style = { + height?: string | number; + width?: string | number; + marginLeft?: number; + marginTop?: number; + flex?: number; + flexDirection?: string; + flexGap?: number; + fontSize?: number; + fontWeight?: string; + backgroundColor?: string; + textAlign?: string; + rotation?: number; +}; export type Element = { + key?: string | null; props: { children?: unknown; text?: string; maxLines?: number; + truncate?: string; clickAction?: string; - clickActionData?: { uri?: string }; + clickActionData?: { uri?: string; approvalKey?: string | null }; accessibilityLabel?: string; - style?: { - height?: string | number; - width?: string | number; - flex?: number; - flexDirection?: string; - flexGap?: number; - padding?: number; - fontSize?: number; - backgroundColor?: string; - textAlign?: string; - }; + style?: Style; }; }; export function nodes(root: unknown): Element[] { @@ -88,6 +108,33 @@ export function nodes(root: unknown): Element[] { const element = root as Element; return [element, ...nodes(element.props.children)]; } +/** The canvas's absolutely placed children. */ +export function placed(root: Element): Element[] { + return [root.props.children] + .flat(Infinity) + .filter((child): child is Element => child !== null && typeof child === 'object'); +} +export type Rect = { x: number; y: number; width: number; height: number }; +export function rectOf(node: Element): Rect { + const { marginLeft = 0, marginTop = 0, width, height } = node.props.style ?? {}; + return { + x: marginLeft, + y: marginTop, + width: typeof width === 'number' ? width : Number.NaN, + height: typeof height === 'number' ? height : Number.NaN, + }; +} +export function hasKey(root: Element, key: string): boolean { + return placed(root).some(node => node.key === key); +} +/** The placed child with `key`; a missing one fails the test that asked for it. */ +export function byKey(root: Element, key: string): Element { + const found = placed(root).find(node => node.key === key); + if (found === undefined) { + throw new Error(`no placed ${key}`); + } + return found; +} export function propsFor( sessions: HomeWidgetSessionRow[], status?: Parameters<typeof buildHomeWidgetData>[0]['status'], @@ -120,41 +167,3 @@ export function render(props: AndroidWidgetProps, size: readonly [number, number export function texts(root: Element) { return nodes(root).flatMap(node => (node.props.text ? [node.props.text] : [])); } - -/** Check emitted native slot budgets, including fallback-script font padding. Not pixel measurement. */ -export function minimumHeight(root: unknown): number { - if (root === null || typeof root !== 'object') { - return 0; - } - if (Array.isArray(root)) { - return root.reduce((sum: number, child) => sum + minimumHeight(child), 0); - } - const element = root as Element; - const { style = {}, text: value, maxLines = 1 } = element.props; - if (value !== undefined) { - if (value === '') { - return 0; - } - const tall = /[\u0600-\u08FF\u0900-\u0DFF]/u.test(value); - return Math.ceil((style.fontSize ?? 14) * (tall ? 1.62 : 1.32)) * maxLines; - } - const children = ( - Array.isArray(element.props.children) - ? element.props.children.flat(Infinity) - : [element.props.children] - ).filter(child => child != null); - const heights = children.map(child => minimumHeight(child)); - const content = - style.flexDirection === 'row' - ? Math.max(0, ...heights) - : heights.reduce((sum, height) => sum + height, 0) + - Math.max(0, heights.length - 1) * (style.flexGap ?? 0); - if (typeof style.height === 'number') { - expect( - content, - JSON.stringify({ style, text: nodes(element).map(node => node.props.text) }) - ).toBeLessThanOrEqual(style.height - 2 * (style.padding ?? 0)); - return style.height; - } - return content + 2 * (style.padding ?? 0); -} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index cb837e16e2..141b31ddfe 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -1,26 +1,36 @@ -import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; - import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; -import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; +import { lightColors } from '@/lib/hooks/theme-colors.generated'; +import { type SizeClass, sizeClassFor } from './active-agents-widget'; import { + byKey, CELLS, - minimumHeight, + DESIGN, + type Element, + hasKey, nodes, NOW, - propsFor, + placed, + type Rect, + rectOf, render, texts, - translate, - WAKE, } from './active-agents-widget.test-helpers'; -import { buildCurrentWidgetProps } from './widget-props'; +import { + close, + ltr, + type State, + stateProps, + STATES, + targets, +} from './active-agents-widget.test-fixtures'; vi.mock('react-native-android-widget', () => ({ FlexWidget: () => null, + OverlapWidget: () => null, TextWidget: () => null, })); @@ -34,216 +44,225 @@ afterEach(() => { setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); }); -const MIXED: HomeWidgetSessionRow[] = [ - { status: 'permission', title: 'Review the release', approvalKey: 'a'.repeat(64) }, - { status: 'busy', title: 'Build the app' }, - { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, - { status: 'idle', title: 'Connected agent' }, -]; -const STATES = [ - ['waiting', [], 'waiting'], - ['empty', [], 'empty'], - ['signed out', MIXED, 'signed_out'], - ['privacy', MIXED, 'privacy'], - ['mixed', MIXED, undefined], - ['running', [{ status: 'busy' }], undefined], - ['idle', [{ status: 'idle' }], undefined], - ['scheduled', [{ status: 'scheduled', scheduledAt: WAKE }], undefined], - ['missing wake', [{ status: 'scheduled' }], undefined], - [ - 'overdue wake', - [{ status: 'scheduled', scheduledAt: new Date(NOW - 60_000).toISOString() }], - undefined, - ], -] as const; +const PADDING: Record<SizeClass, number> = { + small: 16, + medium: 16, + large: 16, + row: 14, + narrow: 14, + landscape: 14, +}; +/** The `+` target's trailing edge inset per class; Approve's target sits directly before it. */ +const PLUS_INSET: Record<SizeClass, number> = { + small: 0, + medium: 0, + large: 0, + row: 2, + narrow: 0, + landscape: 2, +}; -describe('Home widget native compositions', () => { - it.each(CELLS)('preserves the state, navigation and action at %dx%d', (width, height) => { - for (const [name, sessions, status] of STATES) { - const props = propsFor([...sessions], status); - const representation = render(props, [width, height]); - for (const theme of [representation.light, representation.dark]) { - const visible = texts(theme); - expect(visible, name).toContain('Kilo'); - expect(theme.props.clickActionData, name).toEqual({ uri: 'kiloapp:///cloud/sessions' }); - expect(theme.props.accessibilityLabel, name).toBe(props.homeCopy?.accessibilityLabel); - if (props.home?.status === 'content') { - expect(visible, name).toContain(props.homeCopy?.primaryCount); - expect(visible, name).toContain(props.homeCopy?.primaryLabel); - expect(visible, name).not.toContain('0'); - expect(visible, name).toContain('Checked 8:00 PM'); - } else { - expect(visible, name).toContain(props.homeCopy?.status); +describe('Home widget geometry per cell and state', () => { + it.each(CELLS)('places every element inside the %dx%d cell', (width, height) => { + const sizeClass = sizeClassFor(width, height); + const pad = PADDING[sizeClass]; + const slots: Record<string, Rect> = {}; + for (const [name, state] of Object.entries(STATES) as [string, State][]) { + const props = stateProps(state); + for (const rtl of [false, true]) { + const { light, dark } = render(props, [width, height], rtl); + expect(texts(light), name).toEqual(texts(dark)); + expect(light.props.clickActionData, name).toEqual({ uri: 'kiloapp:///cloud/sessions' }); + expect(light.props.accessibilityLabel, name).toBe(props.homeCopy?.accessibilityLabel); + for (const node of placed(light)) { + const rect = rectOf(node); + const where = `${name} ${rtl ? 'rtl' : 'ltr'} ${String(node.key)} ${JSON.stringify(rect)}`; + expect(rect.width, where).toBeGreaterThan(0); + expect(rect.height, where).toBeGreaterThan(0); + expect(rect.x, where).toBeGreaterThanOrEqual(-0.05); + expect(rect.y, where).toBeGreaterThanOrEqual(-0.05); + expect(rect.x + rect.width, where).toBeLessThanOrEqual(width + 0.05); + expect(rect.y + rect.height, where).toBeLessThanOrEqual(height + 0.05); + if (node.props.text !== undefined || node.key === 'status' || node.key === 'line') { + // Copy stays inside the side padding. + expect(rect.x, where).toBeGreaterThanOrEqual(pad - 0.05); + expect(rect.x + rect.width, where).toBeLessThanOrEqual(width - pad + 0.05); + } } - const controls = nodes(theme).filter( - node => - node.props.clickAction === 'approve' || - node.props.clickActionData?.uri === 'kiloapp:///cloud/sessions/new' - ); + for (const node of nodes(light).filter(child => child.props.text !== undefined)) { + expect(node.props.maxLines, name).toBeGreaterThanOrEqual(1); + if (node.props.text !== props.homeCopy?.primaryCount) { + expect(node.props.truncate, `${name} ${node.props.text}`).toBe('END'); + } + } + const controls = targets(light); expect(controls.length, name).toBe( - Number(props.home?.canCreate) + Number(props.home?.canApprove) + (['content', 'empty'].includes(props.home?.status ?? '') && props.home?.canCreate + ? 1 + : 0) + + (props.home?.status === 'content' && + props.home.canApprove && + state.feedback !== 'approving' + ? 1 + : 0) ); for (const control of controls) { - expect(control.props.style?.width).toBeGreaterThanOrEqual(48); - expect(control.props.style?.height).toBeGreaterThanOrEqual(48); - expect(control.props.accessibilityLabel).toBeTruthy(); + const rect = ltr(rectOf(control), { width: width, rtl: rtl }); + expect(rect.width, name).toBeGreaterThanOrEqual(48); + expect(rect.height, name).toBeGreaterThanOrEqual(48); + expect(control.props.accessibilityLabel, name).toBeTruthy(); + // Action slots never move between states of one cell (the empty-state pill aside). + const slot = `${String(control.key)}-${rtl}`; + if (props.home?.status === 'content' && !['medium', 'large'].includes(sizeClass)) { + slots[slot] ??= rect; + expect(rect, `${name} ${slot}`).toEqual(slots[slot]); + } + } + const [first, second] = controls.map(control => + ltr(rectOf(control), { width: width, rtl: rtl }) + ); + if (first !== undefined && second !== undefined) { + const [left, right] = first.x < second.x ? [first, second] : [second, first]; + expect(left.x + left.width, name).toBeLessThanOrEqual(right.x + 0.05); } - expect(minimumHeight(theme), name).toBeLessThanOrEqual(height); } - expect(texts(representation.light)).toEqual(texts(representation.dark)); - expect(representation.light.props.style?.backgroundColor).toBe(lightColors.background); - expect(representation.dark.props.style?.backgroundColor).toBe(darkColors.background); - } - }); - - it('never overflows at the card boundary with counts stacked and a detail line', () => { - // 180dp is where the card layout starts: hero floor, footer, three counts and a detail - // line do not all fit, so the layout must shed the detail and a count row instead. - for (const [width, height] of [ - [172, 180], - [266, 180], - [360, 180], - ] as const) { - const props = propsFor([...MIXED]); - const theme = render(props, [width, height]).light; - expect(minimumHeight(theme), `${width}x${height}`).toBeLessThanOrEqual(height); - const collapsed = nodes(theme).filter( - node => typeof node.props.style?.height === 'number' && node.props.style.height <= 0 - ); - expect(collapsed, `${width}x${height}`).toEqual([]); } }); it.each(CELLS)( - 'budgets German and Arabic/RTL without shrinking the count at %dx%d', + 'pins the `+` target to the trailing edge with Approve before it at %dx%d', (width, height) => { - for (const language of ['de', 'ar']) { - const translated = (key: string) => - language === 'ar' ? `العربية ${translate(key)}` : `Deutsch ${translate(key)}`; - const props = propsFor(MIXED, undefined, translated); - const tree = render(props, [width, height], language === 'ar').light; - expect(minimumHeight(tree)).toBeLessThanOrEqual(height); - const primary = nodes(tree).find( - node => - node.props.text === props.homeCopy?.primaryCount && - (node.props.style?.fontSize ?? 0) >= 22 - ); - expect(primary?.props.style?.fontSize).toBeGreaterThanOrEqual(22); - const label = nodes(tree).find(node => node.props.text === props.homeCopy?.primaryLabel); - expect(label?.props.style?.textAlign).toBe(language === 'ar' ? 'right' : 'left'); + const sizeClass = sizeClassFor(width, height); + for (const rtl of [false, true]) { + const root = render( + stateProps(STATES['needs input with Approve']), + [width, height], + rtl + ).light; + const plus = ltr(rectOf(byKey(root, 'create-target')), { width: width, rtl: rtl }); + const approve = ltr(rectOf(byKey(root, 'approve-target')), { width: width, rtl: rtl }); + close(plus.x + plus.width, width - PLUS_INSET[sizeClass]); + close(approve.x + approve.width, plus.x); } } ); - it('uses compact, wide and tall space for hierarchy rather than a four-zero ledger', () => { - const props = propsFor(MIXED); - const small = texts(render(props, [172, 104]).light); - const wide = texts(render(props, [360, 224]).light); - const tall = texts(render(props, [360, 464]).light); - expect(small).toContain('Review the release'); - expect(wide).toEqual(expect.arrayContaining(['Working', 'Scheduled', 'Idle'])); - expect(tall).toEqual(expect.arrayContaining(['Review the release', 'Permission required'])); + it('maps every cell to one size class', () => { expect( - nodes(render(props, [360, 464]).light).find(node => node.props.text === '1')?.props.style - ?.fontSize - ).toBeGreaterThan(30); + CELLS.map(([width, height]) => `${width}x${height}:${sizeClassFor(width, height)}`) + ).toEqual([ + '172x104:narrow', + '266x104:row', + '360x104:row', + '172x135:narrow', + '172x224:small', + '266x224:medium', + '360x224:medium', + '172x344:small', + '266x344:large', + '360x344:large', + '360x464:large', + '307x62:landscape', + '467x62:landscape', + '627x62:landscape', + '307x135:row', + '467x135:row', + '627x135:row', + '307x208:medium', + '467x208:medium', + '627x208:medium', + '627x281:medium', + '170x170:small', + '364x170:medium', + '364x382:large', + '360x104:row', + '172x104:narrow', + '627x62:landscape', + ]); }); +}); - it.each(CELLS)('keeps a scheduled wake ahead of idle details at %dx%d', (width, height) => { - const props = propsFor([ - { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, - { status: 'idle' }, - ]); - expect(texts(render(props, [width, height]).light)).toContain('Next run 8:00 PM'); - const overdue = propsFor([ - { status: 'scheduled', scheduledAt: new Date(NOW - 1).toISOString() }, - ]); - expect(texts(render(overdue, [width, height]).light)).toContain('Awaiting update'); - expect(texts(render(overdue, [width, height]).light)).not.toContain('Working'); +describe('Home widget design coordinates at the design frames', () => { + const at = (size: readonly [number, number], state = STATES['needs input with Approve']) => + render(stateProps(state), size).light; + const rect = (root: Element, key: string) => rectOf(byKey(root, key)); + + it('draws Small at the round 2 coordinates', () => { + const root = at(DESIGN.small); + expect(rect(root, 'logo')).toEqual({ x: 16, y: 15, width: 18, height: 18 }); + expect(rect(root, 'create-glyph')).toEqual({ x: 130, y: 12, width: 24, height: 24 }); + expect(rect(root, 'approve-glyph')).toEqual({ x: 102, y: 12, width: 24, height: 24 }); + expect(rect(root, 'count').x).toBe(16); + close(rect(root, 'count').y, 78 - 1.056 * 44); + close(rect(root, 'footer').y, 152 - 1.056 * 11); + close(rect(root, 'line-0').y, 117 - 1.056 * 13); + expect(byKey(root, 'line-0').props.text).toBe('Review the release'); }); - it('uses the large list for earliest waits or wakes, with honest generic missing titles', () => { - const waiting = propsFor([ - { status: 'permission', title: 'First' }, - { status: 'question', title: 'Second' }, - { status: 'retry' }, - ]); - expect(texts(render(waiting, [360, 464]).light)).toEqual( - expect.arrayContaining([ - 'First', - 'Second', - 'Agent', - 'Permission required', - 'Answer needed', - 'Waiting to retry', - ]) - ); - const scheduled = propsFor([ - { status: 'scheduled', title: 'Later', scheduledAt: new Date(NOW + 20_000).toISOString() }, - { status: 'scheduled', title: 'Sooner', scheduledAt: new Date(NOW + 10_000).toISOString() }, - ]); - const shown = texts(render(scheduled, [360, 464]).light); - expect(shown.indexOf('Sooner')).toBeLessThan(shown.indexOf('Later')); + it('draws Medium with the Approve pill ending at the `+` slot and two rows', () => { + const root = at(DESIGN.medium); + expect(rect(root, 'approve-pill')).toEqual({ x: 230, y: 12, width: 86, height: 24 }); + expect(rect(root, 'create-glyph')).toEqual({ x: 324, y: 12, width: 24, height: 24 }); + expect(rect(root, 'row-0-dot')).toEqual({ x: 168, y: 66, width: 8, height: 8 }); + expect(rect(root, 'row-1-title').x).toBe(184); + expect(hasKey(root, 'row-2-title')).toBe(false); + close(rect(root, 'count').y, 92 - 1.056 * 44); }); - it('retains last-known data after activity expiry without renewing the checked timestamp', () => { - const data = buildHomeWidgetData({ - sessions: MIXED, - userId: 'u1', - organizationId: null, - now: NOW, - }); - vi.setSystemTime(Date.parse(data.snapshot.expiresAt) + 1); - const props = buildCurrentWidgetProps( - data.snapshot, - translate, - String, - String, - () => '8:00 PM', - data - ); - expect(props.home?.checkedAt).toBe(data.snapshot.updatedAt); - expect(props.home?.stale).toBe(true); - expect(texts(render(props, [172, 104]).light)).toEqual( - expect.arrayContaining(['1', 'Needs input', 'Checked 8:00 PM']) - ); - expect( - nodes(render(props, [172, 104]).light).some(node => - node.props.clickActionData?.uri?.endsWith('/new') - ) - ).toBe(true); + it('draws Large with secondary counts, the divider and three waiting rows', () => { + const root = at(DESIGN.large); + expect(rect(root, 'divider')).toEqual({ x: 16, y: 152, width: 332, height: 1 }); + expect(rect(root, 'count-running-dot')).toEqual({ x: 222, y: 66, width: 8, height: 8 }); + for (const index of [0, 1, 2]) { + close(rect(root, `row-${index}-title`).y, 202 + index * 46 - 1.056 * 14); + } + expect(byKey(root, 'section').props.text).toBe('Waiting for you'); }); - it('shows action progress in the fixed detail slot and keeps the create control', () => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'approving' }); - const inFlight = render(propsFor(MIXED), [172, 104]).light; - expect(texts(inFlight)).toContain('Approving…'); - expect(nodes(inFlight).some(node => node.props.clickAction === 'approve')).toBe(false); - expect(nodes(inFlight).some(node => node.props.clickActionData?.uri?.endsWith('/new'))).toBe( - true - ); - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: 'couldNotApprove' }); - const failed = render(propsFor(MIXED), [172, 104]).light; - expect(texts(failed)).toContain('Could not approve'); - expect(nodes(failed).some(node => node.props.clickAction === 'approve')).toBe(true); + it('draws Row, Narrow and Landscape glyphs at their design centres', () => { + const rowRoot = at(DESIGN.row); + expect(rect(rowRoot, 'create-glyph')).toEqual({ x: 310, y: 34, width: 36, height: 36 }); + expect(rect(rowRoot, 'approve-glyph')).toEqual({ x: 266, y: 34, width: 36, height: 36 }); + expect(byKey(rowRoot, 'footer').props.text).toBe('· Checked 8:00 PM'); + const narrowRoot = at(DESIGN.narrow); + expect(rect(narrowRoot, 'create-glyph')).toEqual({ x: 130, y: 8, width: 28, height: 28 }); + expect(rect(narrowRoot, 'approve-glyph')).toEqual({ x: 98, y: 8, width: 28, height: 28 }); + expect(rect(narrowRoot, 'logo')).toEqual({ x: 14, y: 14, width: 16, height: 16 }); + const land = at(DESIGN.landscape); + expect(rect(land, 'create-glyph')).toEqual({ x: 577, y: 13, width: 36, height: 36 }); + expect(rect(land, 'approve-glyph')).toEqual({ x: 535, y: 13, width: 36, height: 36 }); + expect(texts(land)).toContain('Review the release'); + expect(texts(at([307, 62]))).not.toContain('Review the release'); }); - it.each(['signed_out', 'privacy'] as const)('removes titles and counts on %s', status => { - const props = propsFor(MIXED, status); - const shown = texts(render(props, [360, 464]).light).join(' '); - expect(shown).not.toContain('Review the release'); - expect(shown).not.toContain('Build the app'); - expect(shown).not.toContain('Checked'); - expect(shown).not.toContain('1'); + it('keeps the count, dot and label in one 6dp row whose label alone ellipsizes', () => { + for (const size of [DESIGN.row, DESIGN.narrow, DESIGN.landscape]) { + const row = byKey(at(size), 'status'); + expect(row.props.style?.flexDirection).toBe('row'); + expect(row.props.style?.flexGap).toBe(6); + const [count, dotNode, labelSlot] = [row.props.children].flat() as Element[]; + expect(count?.props.text).toBe('3'); + expect(count?.props.truncate).toBeUndefined(); + expect(count?.props.style?.width).toBeUndefined(); + expect(dotNode?.props.style?.backgroundColor).toBe(lightColors.warn); + expect(labelSlot?.props.style).toMatchObject({ width: 0, flex: 1 }); + const labelNode = nodes(labelSlot).find(node => node.props.text === 'Needs input'); + expect(labelNode?.props).toMatchObject({ maxLines: 1, truncate: 'END' }); + } }); -}); -it.each(CELLS)('keeps four-digit primary counts legible at %dx%d', (width, height) => { - const props = propsFor(Array.from({ length: 9999 }, () => ({ status: 'busy' }))); - const tree = render(props, [width, height]).light; - const count = nodes(tree).find(node => node.props.text === '9999'); - expect(count).toBeDefined(); - expect(count?.props.style?.fontSize).toBeGreaterThanOrEqual(22); - expect(texts(tree)).toContain('Working'); - expect(minimumHeight(tree)).toBeLessThanOrEqual(height); + it('mirrors every placed rectangle in RTL and aligns copy to the right', () => { + for (const size of Object.values(DESIGN)) { + const props = stateProps(STATES['needs input with Approve']); + const left = placed(render(props, size).light); + const right = placed(render(props, size, true).light); + expect(right.map(node => node.key)).toEqual(left.map(node => node.key)); + expect(right.map(node => ltr(rectOf(node), { width: size[0], rtl: true }))).toEqual( + left.map(node => rectOf(node)) + ); + for (const node of right.filter(child => child.props.text !== undefined)) { + expect(['right', 'center'], String(node.key)).toContain(node.props.style?.textAlign); + } + } + }); }); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx index 708bcb6113..5b39ec73b9 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -2,29 +2,69 @@ 'use no memo'; import { - FlexWidget, + OverlapWidget, type WidgetInfo, type WidgetRepresentation, } from 'react-native-android-widget'; import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; -import { card, type Frame, locked, MARGIN, SHORT_HEIGHT } from './active-agents-widget-card'; -import { type Copy, type Paint } from './active-agents-widget-parts'; -import { short } from './active-agents-widget-short'; +import { small } from './active-agents-widget-card'; +import { large } from './active-agents-widget-large'; +import { medium } from './active-agents-widget-medium'; +import { type Copy, type Frame, type Paint, type Palette } from './active-agents-widget-parts'; +import { row } from './active-agents-widget-row'; +import { landscape, narrow } from './active-agents-widget-short'; import { type AndroidWidgetProps } from './widget-props'; export const WIDGET_NAME = 'ActiveAgentsWidget'; -/** Privacy, org-lock and signed-out all share the centred locked composition. */ -function compose(frame: Frame) { - const { statusKind } = frame.copy; - if (statusKind === 'privacy' || statusKind === 'unavailable' || statusKind === 'signed_out') { - return locked(frame); +/** The approved widget palettes; three surfaces have no app theme token. */ +const LIGHT: Palette = { + background: lightColors.background, + foreground: lightColors.foreground, + muted: lightColors.mutedForeground, + warn: lightColors.warn, + good: lightColors.good, + info: lightColors.info, + idle: lightColors.mutedSoft, + primary: lightColors.primary, + primaryForeground: lightColors.primaryForeground, + secondary: lightColors.secondary, + divider: '#E6E3DA', +}; +const DARK: Palette = { + background: darkColors.card, + foreground: darkColors.foreground, + muted: darkColors.mutedForeground, + warn: darkColors.warn, + good: darkColors.good, + info: darkColors.info, + idle: darkColors.mutedSoft, + primary: darkColors.primary, + primaryForeground: darkColors.primaryForeground, + secondary: '#26262B', + divider: '#2A2A30', +}; + +export type SizeClass = 'small' | 'medium' | 'large' | 'row' | 'narrow' | 'landscape'; + +/** Every launcher cell maps to one size class; its layout then fills the cell. */ +export function sizeClassFor(width: number, height: number): SizeClass { + if (height < 84) { + return 'landscape'; + } + if (height < 152) { + return width < 220 ? 'narrow' : 'row'; } - return frame.info.height < SHORT_HEIGHT ? short(frame) : card(frame); + if (width < 240) { + return 'small'; + } + return height < 300 ? 'medium' : 'large'; } +const LAYOUTS = { small, medium, large, row, narrow, landscape } as const; + // Old persisted props have no Home presentation. They get the same hierarchy; // fresh producers always provide shared Home policy and translated Home copy. function copyFor(props: AndroidWidgetProps): Copy { @@ -34,11 +74,18 @@ function copyFor(props: AndroidWidgetProps): Copy { const primary = props.countLines.find(line => line.label === props.primaryLabel); return { statusKind: primary === undefined ? 'signed_out' : 'content', + primaryKind: primary?.kind ?? null, primaryCount: primary?.count ?? '', primaryLabel: primary?.label ?? null, status: props.statusLine, - detail: props.scheduledTime, - checked: null, + emptyShort: props.statusLine ?? '', + title: null, + wake: null, + wakeOverdue: false, + footer: null, + actionLine: null, + approveFailed: '', + headings: { recent: '', waitingForYou: '', nextScheduled: '' }, secondaryCounts: props.countLines.filter(line => line !== primary && line.count !== '0'), waitingAgents: [], scheduledAgents: [], @@ -47,23 +94,30 @@ function copyFor(props: AndroidWidgetProps): Copy { } function draw(props: AndroidWidgetProps, info: WidgetInfo, paint: Paint) { - const frame: Frame = { props, copy: copyFor(props), paint, info }; + const { palette } = paint; + const frame: Frame = { + props, + copy: copyFor(props), + paint, + width: info.width, + height: info.height, + }; + const sizeClass = sizeClassFor(info.width, info.height); return ( - <FlexWidget + <OverlapWidget clickAction="OPEN_URI" clickActionData={{ uri: 'kiloapp:///cloud/sessions' }} accessibilityLabel={frame.copy.accessibilityLabel} style={{ width: 'match_parent', height: 'match_parent', - borderRadius: 20, - backgroundColor: paint.palette.background, - // Short bands inset their own content so the free height reaches the text. - padding: info.height < SHORT_HEIGHT ? 0 : MARGIN, + borderRadius: sizeClass === 'landscape' ? 18 : 22, + backgroundColor: palette.background, + overflow: 'hidden', }} > - {compose(frame)} - </FlexWidget> + {LAYOUTS[sizeClass](frame)} + </OverlapWidget> ); } @@ -73,7 +127,7 @@ export function renderActiveAgentsWidget( rtl = false ): WidgetRepresentation { return { - light: draw(props, info, { palette: lightColors, rtl }), - dark: draw(props, info, { palette: darkColors, rtl }), + light: draw(props, info, { palette: LIGHT, rtl }), + dark: draw(props, info, { palette: DARK, rtl }), }; } diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index bc84a4ff51..3f7a126ea8 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -30,17 +30,20 @@ vi.mock('expo-localization', () => ({ })); const mocks = vi.hoisted(() => { - let notification: { + type Card = { title: string; text: string; + textIsError: boolean; + subText: string | null; + compactText: string | null; openLabel: string; openUrl: string; approveLabel: string | null; - compactText: string | null; - channelId: string; - alerting: boolean; - promotion: boolean; - } | null = null; + newAgentLabel: string | null; + newAgentUrl: string; + }; + let notification: (Card & { channelId: string; alerting: boolean; promotion: boolean }) | null = + null; // Capture the requested bridge timeout, not Android's alarm cancellation behavior. let notificationDeadline: number | null = null; @@ -52,58 +55,24 @@ const mocks = vi.hoisted(() => { // eslint-disable-next-line max-params -- the fake models the native bridge arguments function post( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, + card: Card, channelId: string, alerting: boolean, promotion: boolean, timeoutMs = 0 ): void { - notification = { - title, - text, - openLabel: openAction.label, - openUrl: openAction.url, - approveLabel, - compactText, - channelId, - alerting, - promotion, - }; + notification = { ...card, channelId, alerting, promotion }; notificationDeadline = timeoutMs > 0 ? Date.now() + timeoutMs : null; postedChannel = channelId; } const isPromotionCapable = vi.fn(() => true); - // The native `update` spends its eighth bridge slot on the terminal timeout, - // so it applies the promotion gate from the capability check it re-runs - // instead of a JS flag. + // The native `update` carries the terminal timeout and applies the promotion + // gate from the capability check it re-runs instead of a JS flag. // eslint-disable-next-line max-params -- the fake models the native update bridge arguments - function postUpdate( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, - channelId: string, - alerting: boolean, - timeoutMs = 0 - ): void { - post( - title, - text, - openAction, - approveLabel, - compactText, - channelId, - alerting, - isPromotionCapable(), - timeoutMs - ); + function postUpdate(card: Card, channelId: string, alerting: boolean, timeoutMs = 0): void { + post(card, channelId, alerting, isPromotionCapable(), timeoutMs); } return { @@ -160,6 +129,7 @@ vi.mock('react-native', () => ({ // vitest; stub them so only the sink logic runs. vi.mock('react-native-android-widget', () => ({ FlexWidget: () => null, + OverlapWidget: () => null, TextWidget: () => null, ImageWidget: () => null, requestWidgetUpdate: (...args: unknown[]) => mocks.requestWidgetUpdate(...args), @@ -301,7 +271,7 @@ describe('androidSink start and update', () => { publisher.applySnapshot(snapshotFor([{ status: 'busy' }], 1), CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('1 Working'); + expect(mocks.getNotification()).toMatchObject({ title: '1 Working', text: '' }); publisher.handleSessions([], CTX); await vi.advanceTimersByTimeAsync(8000); @@ -313,9 +283,9 @@ describe('androidSink start and update', () => { it('forwards the ranked compact number and all counts on start and update', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', @@ -325,9 +295,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '3 Idle', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -337,9 +307,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...MIXED, revision: 3, needsInput: 0, idle: 0 }, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -349,21 +319,27 @@ describe('androidSink start and update', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.update).toHaveBeenCalledTimes(2); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '2 Needs input, 4 Working, 3 Idle', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '2', + expect.objectContaining({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '2', + }), 'needs-input', true, true ); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '4 Working', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '4', + expect.objectContaining({ + title: '4 Working', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '4', + }), 'agent-progress', false, 0 @@ -379,11 +355,12 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...needsInput, revision: 2 }, CTX); await flushAsync(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', false, 0 @@ -396,11 +373,12 @@ describe('androidSink start and update', () => { expect(mocks.getNotification()).toMatchObject({ channelId: 'agent-progress', alerting: false }); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'agent-progress', false, true @@ -429,11 +407,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, 0 @@ -454,11 +433,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', false, true @@ -477,11 +457,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -508,11 +489,12 @@ describe('androidSink start and update', () => { await handleAppStateActive(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -534,11 +516,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -574,7 +557,8 @@ describe('androidSink start and update', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: '4 Working, 3 Idle', + title: '4 Working', + text: '3 Idle', channelId: 'agent-progress', alerting: false, }); @@ -585,9 +569,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', @@ -605,9 +589,9 @@ describe('androidSink start and update', () => { deferred.resolve('granted'); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '3 Idle', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -717,11 +701,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBe(i18n.t('common.approve')); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_approve' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_approve', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, true @@ -733,11 +720,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '1 Working', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '1', + expect.objectContaining({ + title: '1 Working', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '1', + }), 'agent-progress', false, 0 @@ -757,11 +747,14 @@ describe('androidSink approve action', () => { await handleAppStateActive(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_retry' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_retry', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, true @@ -774,11 +767,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_question' }, - null, - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_question', + approveLabel: null, + compactText: '1', + }), 'needs-input', true, true @@ -799,11 +795,14 @@ describe('androidSink approve action', () => { androidSink.publish(snapshotFor([{ status: 'permission' }], 1)); expect(mocks.getNotification()?.approveLabel).toBe(i18n.t('common.approve')); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_late' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_late', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, 0 @@ -824,11 +823,14 @@ describe('androidSink approve action', () => { expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - 'No agents waiting', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_done' }, - null, - null, + expect.objectContaining({ + title: 'No agents waiting', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_done', + approveLabel: null, + compactText: null, + }), 'agent-progress', false, expect.any(Number) @@ -944,7 +946,10 @@ describe('androidSink action notice', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, running: 5 }, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('Approval failed 2 Needs input, 5 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: 'Approval failed', + }); }); it('drops the notice once the counts reach zero', async () => { @@ -954,10 +959,13 @@ describe('androidSink action notice', () => { setGlanceableActionNotice('Approval failed'); androidSink.publish({ ...MIXED, revision: 2, needsInput: 0 }); - expect(mocks.getNotification()?.text).toBe('4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ title: '4 Working', text: '3 Idle' }); androidSink.publish({ ...MIXED, revision: 3, needsInput: 3 }); - expect(mocks.getNotification()?.text).toBe('3 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '3 Needs input', + text: '4 Working · 3 Idle', + }); }); it('drops the notice when the recorded ask changes', async () => { @@ -970,14 +978,20 @@ describe('androidSink action notice', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, running: 5 }, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 5 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '5 Working · 3 Idle', + }); }); it('prefixes the notice only after it is set', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + }); }); }); @@ -996,11 +1010,14 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.getWidgetSnapshot).toHaveBeenCalled(); expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - 'Approval failed 2 Needs input, 4 Working, 3 Idle', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_retry' }, - i18n.t('common.approve'), - '2', + expect.objectContaining({ + title: '2 Needs input', + text: 'Approval failed', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_retry', + approveLabel: i18n.t('common.approve'), + compactText: '2', + }), 'needs-input', true, true @@ -1021,7 +1038,8 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); @@ -1040,7 +1058,8 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).not.toHaveBeenCalled(); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); @@ -1060,7 +1079,8 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).not.toHaveBeenCalled(); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); @@ -1088,14 +1108,14 @@ describe('androidSink widget publish and end', () => { expect(runningCount()).toBe('1'); }); - it('publishes the stale warning and retained counts through the native bridge', async () => { + it('publishes the last-known time and retained counts through the native bridge', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); androidSink.publish({ ...MIXED, revision: 2, status: 'stale' }); const notification = mocks.getNotification(); - expect(notification?.text).toContain(i18n.t('glanceable.stale')); - expect(notification?.text).toContain('2 Needs input, 4 Working, 3 Idle'); + expect(notification).toMatchObject({ title: '2 Needs input', text: '4 Working · 3 Idle' }); + expect(notification?.subText).toMatch(new RegExp(`^${i18n.t('glanceable.lastKnown')} · `, 'u')); expect(notification?.compactText).toBe('2'); expect(getCurrentWidgetProps()?.accessibilityLabel).toContain( '2 Needs input, 4 Working, 3 Idle, Open agents' @@ -1151,7 +1171,8 @@ describe('androidSink widget publish and end', () => { androidSink.publish(snapshotFor([{ status: 'idle' }], MIXED.revision)); expect(mocks.getNotification()).toMatchObject({ - text: i18n.t('glanceable.empty'), + title: i18n.t('glanceable.empty'), + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1240,7 +1261,8 @@ describe('androidSink widget publish and end', () => { _resetAndroidSinkForTests(); expect(mocks.getNotification()).toMatchObject({ - text: 'No agents waiting', + title: 'No agents waiting', + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1271,13 +1293,17 @@ describe('androidSink widget publish and end', () => { expect(() => { androidSink.publish(empty); }).toThrow('Cannot persist the active agents notification timeout'); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + }); expect(mocks.getRequestedNotificationDeadline()).toBeNull(); vi.setSystemTime(NOW + 3000); androidSink.publish(empty); expect(mocks.getNotification()).toMatchObject({ - text: 'No agents waiting', + title: 'No agents waiting', + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1305,7 +1331,8 @@ describe('androidSink widget publish and end', () => { expect(mocks.getRequestedNotificationDeadline()).toBeNull(); expect(mocks.getNotification()).toMatchObject({ - text: '2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: '4 Working · 3 Idle', compactText: '2', }); expect(mocks.getWidgetDeadline()).toBe(method === 'publish' ? NOW + 28_800_000 : 0); @@ -1364,9 +1391,9 @@ describe('handleAppStateActive', () => { // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); await handleAppStateActive(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index 49b3bc404e..871971283a 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -22,9 +22,14 @@ import { type GlanceableSink, type GlanceableSinkContext, } from '@/lib/glanceable/sink-registry'; -import { getWaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { getActionNotice, pruneActionNotice, setGlanceableActionNotice } from './action-notice'; +import { + getActionNotice, + isActionApproving, + pruneActionNotice, + setGlanceableActionApproving, + setGlanceableActionNotice, +} from './action-notice'; import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget'; import { formatGlanceableAgo, @@ -34,7 +39,6 @@ import { } from './count-format'; import { ensureAndroidNotificationChannels } from './ensure-notification-channels'; import { - buildNotificationActions, end as endLiveUpdate, getPostedNotificationChannel, getStoredWidgetSnapshot, @@ -42,25 +46,19 @@ import { start as startLiveUpdate, update as updateLiveUpdate, } from './live-update'; +import { cardFor } from './notification-card'; import { isNotificationPermissionGranted } from './permission'; -import { - type AndroidWidgetProps, - buildCompactNotificationText, - buildCurrentWidgetProps, - buildOngoingNotificationText, -} from './widget-props'; +import { type AndroidWidgetProps, buildCurrentWidgetProps } from './widget-props'; -// Re-exported because the approve task and the widget suite import it from the -// sink; the notice state itself now lives in `./action-notice`. -export { setGlanceableActionNotice }; +// Re-exported because the approve task and the widget suite import them from the +// sink; the notice state itself lives in `./action-notice`. +export { setGlanceableActionApproving, setGlanceableActionNotice }; /** * Android owns the widget expiry and notification timeout. The sink supplies * translated copy, persists the latest snapshot, and fences pending starts. * Ending the ongoing notification never cancels a still-eligible widget expiry. */ -const NOTIFICATION_TITLE_KEY = 'glanceable.channelName'; - function translate(key: string): string { return i18n.t(key); } @@ -92,19 +90,8 @@ let terminalExpiresAt: number | null = null; * task for an OS retry. */ let inflightStart: Promise<void> | null = null; - -/** The ongoing notification line, carrying the pending notice when one waits. */ -function notificationText(snapshot: GlanceableAgentsSnapshot): string { - pruneActionNotice(snapshot); - return buildOngoingNotificationText( - snapshot, - {}, - translate, - formatGlanceableCount, - getActionNotice(), - formatGlanceableAgo - ); -} +/** Whether the posted card reads "Approving…", so settling the answer redraws it. */ +let postedApproving = false; /** * A needs-input card is the kind that asks the user a question, so its first @@ -121,33 +108,21 @@ function postNotification( method: 'start' | 'update', terminalText?: string ): void { - const actions = buildNotificationActions(getWaitingAsk(), translate); const kind = agentNotificationKindForGlanceableSnapshot(snapshot); - const args = [ - translate(NOTIFICATION_TITLE_KEY), - terminalText ?? notificationText(snapshot), - actions.openLabel, - actions.openUrl, - // A terminal card has nothing to answer, even if a background delivery left - // an ask recorded. Open remains the route back; Approve must disappear. - terminalText === undefined ? actions.approveLabel : null, - terminalText === undefined - ? buildCompactNotificationText(snapshot, {}, formatGlanceableCount) - : null, - androidChannelIdForAgentKind(kind), - shouldAlert(kind), - ] as const; + const card = cardFor(snapshot, terminalText); + const channelId = androidChannelIdForAgentKind(kind); if (method === 'start') { - startLiveUpdate(...args); + startLiveUpdate(card, channelId, shouldAlert(kind)); } else { const timeoutMs = terminalText === undefined || terminalExpiresAt === null ? 0 : Math.max(1, terminalExpiresAt - Date.now()); - updateLiveUpdate(...args, timeoutMs); + updateLiveUpdate(card, channelId, shouldAlert(kind), timeoutMs); } notificationKind = kind; revision = snapshot.revision; + postedApproving = terminalText === undefined && isActionApproving(); } /** The widget props for `snapshot`, with the deadline and staleness checks every redraw runs. */ @@ -199,6 +174,7 @@ function endNotification(): void { pending = null; startEpoch += 1; terminalExpiresAt = null; + postedApproving = false; } /** @@ -227,7 +203,12 @@ async function tryStartOrUpdate( // A pending notice must reach the surface even when the counts did not // change: it is the only carrier of the retryable failure, and the republish // that carries it can arrive with the same counts (or not arrive at all). - if (notificationActive && snapshot.revision <= revision && getActionNotice() === null) { + if ( + notificationActive && + snapshot.revision <= revision && + getActionNotice() === null && + postedApproving === isActionApproving() + ) { return; } if (notificationActive) { @@ -381,7 +362,10 @@ export const androidSink: GlanceableSink = { return; } } - if (notificationActive && snapshot.revision > revision) { + if ( + notificationActive && + (snapshot.revision > revision || postedApproving !== isActionApproving()) + ) { postNotification( snapshot, 'update', @@ -415,5 +399,7 @@ export function _resetAndroidSinkForTests(): void { startEpoch += 1; terminalExpiresAt = null; inflightStart = null; + postedApproving = false; setGlanceableActionNotice(null); + setGlanceableActionApproving(false); } diff --git a/apps/mobile/src/glanceable-android/approve-task.test.ts b/apps/mobile/src/glanceable-android/approve-task.test.ts index e028e93c15..a397213dc5 100644 --- a/apps/mobile/src/glanceable-android/approve-task.test.ts +++ b/apps/mobile/src/glanceable-android/approve-task.test.ts @@ -89,6 +89,7 @@ vi.mock('@/lib/hooks/use-language-preference', () => ({ vi.mock('./android-sink', () => ({ androidSink: mocks.sink, setGlanceableActionNotice: mocks.setGlanceableActionNotice, + setGlanceableActionApproving: (): void => undefined, renderStoredSnapshotWithNotice: mocks.renderStoredSnapshotWithNotice, })); @@ -167,7 +168,8 @@ describe('handleApproveTask', () => { // clear it again, and an approval is not a failure to report. expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ userId: 'u1', @@ -191,7 +193,7 @@ describe('handleApproveTask', () => { expect(mocks.setGlanceableActionNotice).toHaveBeenCalledTimes(2); expect(mocks.setGlanceableActionNotice).toHaveBeenNthCalledWith(1, APPROVE_FAILED); expect(mocks.setGlanceableActionNotice).toHaveBeenNthCalledWith(2, APPROVE_FAILED); - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(3); expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenNthCalledWith(1, { userId: 'u1', organizationId: 'org_1', @@ -205,10 +207,11 @@ describe('handleApproveTask', () => { // republish, which writes the notification again and re-selects the ask from // the tray — a line drawn only first could be pruned by that render. const firstNotice = mocks.setGlanceableActionNotice.mock.invocationCallOrder[0]; - const firstRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[0]; + // Render 0 is the Approving… draw before the answer. + const firstRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[1]; const refreshOrder = mocks.refreshGlanceableSnapshot.mock.invocationCallOrder[0]; const lastNotice = mocks.setGlanceableActionNotice.mock.invocationCallOrder[1]; - const lastRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[1]; + const lastRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[2]; expect(firstNotice).toBeLessThan(firstRender ?? Number.POSITIVE_INFINITY); expect(firstRender).toBeLessThan(refreshOrder ?? Number.POSITIVE_INFINITY); expect(refreshOrder).toBeLessThan(lastNotice ?? Number.POSITIVE_INFINITY); @@ -227,15 +230,20 @@ describe('handleApproveTask', () => { it('waits for the failure line before it republishes and finishes', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); const firstRender = deferredRender(); - mocks.renderStoredSnapshotWithNotice.mockImplementationOnce(async () => { - await firstRender.promise; - }); + // The Approving… draw resolves; the failure draw after it stays in flight. + mocks.renderStoredSnapshotWithNotice + .mockImplementationOnce(async () => { + await Promise.resolve(); + }) + .mockImplementationOnce(async () => { + await firstRender.promise; + }); const pending = handleApproveTask(); // The task must not republish, let alone resolve, while the first draw is // still in flight: the headless process would exit with the line unshown. await vi.waitFor(() => { - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); }); expect(mocks.refreshGlanceableSnapshot).not.toHaveBeenCalled(); @@ -243,7 +251,7 @@ describe('handleApproveTask', () => { await pending; expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(3); }); it('renders the failure line from the stored snapshot when the republish rejects', async () => { @@ -295,7 +303,8 @@ describe('handleApproveTask', () => { expect(mocks.recordWaitingAsk).toHaveBeenCalledTimes(1); expect(mocks.recordWaitingAsk).toHaveBeenCalledWith(null); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); // The ask is gone, so its stale tray row is skipped like an answered one. expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ @@ -313,7 +322,8 @@ describe('handleApproveTask', () => { expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); // The ask is neither answered nor gone: it is still there, so the republish // re-selects it and the notification keeps the Open it names. diff --git a/apps/mobile/src/glanceable-android/approve-task.ts b/apps/mobile/src/glanceable-android/approve-task.ts index 546e254783..00402fac19 100644 --- a/apps/mobile/src/glanceable-android/approve-task.ts +++ b/apps/mobile/src/glanceable-android/approve-task.ts @@ -12,6 +12,7 @@ import { dismissNeedsInputNotification } from '@/lib/needs-input-notification'; import { androidSink, renderStoredSnapshotWithNotice, + setGlanceableActionApproving, setGlanceableActionNotice, } from './android-sink'; @@ -72,6 +73,22 @@ async function showApproveFailed(ask: WaitingAsk): Promise<void> { } } +/** + * Show the answer in flight: the card reads "Approving…" and drops Approve until + * the answer settles. Best-effort: the republish after the answer redraws it. + */ +async function showApproving(ask: WaitingAsk): Promise<void> { + setGlanceableActionApproving(true); + try { + await renderStoredSnapshotWithNotice({ + userId: ask.userId, + organizationId: ask.organizationId, + }); + } catch { + // The answer still runs; the republish below corrects the surface. + } +} + /** * Answer the recorded ask and update the notification in place. Never throws: * the worker completes from the headless task's finish, so a rejection would @@ -108,6 +125,7 @@ export async function handleApproveTask(): Promise<void> { // Nothing is recorded, so there is no ask to answer and no action to drop. return; } + await showApproving(ask); const result = await runGlanceableApprove({ now: () => Date.now() }); askEnded = result.kind === 'approved' || result.kind === 'gone'; if (result.kind === 'gone') { @@ -121,6 +139,8 @@ export async function handleApproveTask(): Promise<void> { // Keep the recorded ask and its Approve; only the failure line is needed. failed = ask !== null; } + // Settled either way: the next draw shows the answer, the failure, or Approve again. + setGlanceableActionApproving(false); if (ask === null) { return; } diff --git a/apps/mobile/src/glanceable-android/count-format.ts b/apps/mobile/src/glanceable-android/count-format.ts index e67da01829..77fb2a3fa9 100644 --- a/apps/mobile/src/glanceable-android/count-format.ts +++ b/apps/mobile/src/glanceable-android/count-format.ts @@ -13,7 +13,7 @@ import { parseTimestamp, timeAgo } from '@/lib/utils'; * separator in a two-character number is only noise. */ export function formatGlanceableCount(value: number): string { - return numberFormat(i18n.language, { useGrouping: false }).format(value); + return numberFormat(i18n.language, { useGrouping: true }).format(value); } /** @@ -42,8 +42,8 @@ export function formatGlanceableAgo(at: string): string { return timeAgo(parseTimestamp(at)); } -/** Checked dates stay absolute because a widget bitmap cannot advance a relative age offline. */ -export function formatGlanceableClock(at: string, options?: { includeDate: boolean }): string { +/** Clock time for today, date and time otherwise: a widget bitmap cannot advance a relative age offline. */ +export function formatGlanceableClock(at: string): string { const date = parseTimestamp(at); const now = new Date(); const today = @@ -52,7 +52,7 @@ export function formatGlanceableClock(at: string, options?: { includeDate: boole date.getDate() === now.getDate(); const uses24hourClock = getCalendars().at(0)?.uses24hourClock ?? null; return dateTimeFormat(i18n.language, { - ...(today && !options?.includeDate + ...(today ? { timeStyle: 'short' as const } : { dateStyle: 'short' as const, timeStyle: 'short' as const }), ...(uses24hourClock === null diff --git a/apps/mobile/src/glanceable-android/home-copy.ts b/apps/mobile/src/glanceable-android/home-copy.ts index ddca1ed81d..98bd19fbe9 100644 --- a/apps/mobile/src/glanceable-android/home-copy.ts +++ b/apps/mobile/src/glanceable-android/home-copy.ts @@ -5,17 +5,33 @@ import { type GlanceableActionFeedback } from '@/lib/glanceable/surface-extras'; export type AndroidWidgetCount = { label: string; kind: GlanceableCountKind; count: string }; export type GlanceableCountFormat = (value: number) => string; -export type GlanceableClockFormat = (at: string, options?: { includeDate: boolean }) => string; +/** A clock time, with the date added when `at` is not today. */ +export type GlanceableClockFormat = (at: string) => string; /** Translated Home widget copy; the layout never translates or formats on its own. */ export type AndroidWidgetHomeCopy = { /** The presented state: privacy/signed_out/unavailable centre a locked composition. */ statusKind: HomeWidgetPresentation['status']; + /** Picks the status dot colour. */ + primaryKind: GlanceableCountKind | null; primaryCount: string; primaryLabel: string | null; + /** Locked, empty ("Nothing running right now") and updating copy; null while content shows. */ status: string | null; - detail: string | null; - checked: string | null; + /** The 2x1 cell's shorter empty wording. */ + emptyShort: string; + /** The agent the count is about; an untitled agent reads `common.agent`. */ + title: string | null; + /** "Next run <time>", or "Awaiting update" once the wake has passed. */ + wake: string | null; + wakeOverdue: boolean; + /** "Checked <time>", or "Last known · <time>" when the data is stale. */ + footer: string | null; + /** "Approving…" or "Could not approve" while an approve press is in flight or failed. */ + actionLine: string | null; + /** The Medium/Large footer that replaces the checked time after a failed approve. */ + approveFailed: string; + headings: { recent: string; waitingForYou: string; nextScheduled: string }; secondaryCounts: AndroidWidgetCount[]; waitingAgents: { title: string; reason: string }[]; scheduledAgents: { title: string; time: string | null }[]; @@ -65,6 +81,20 @@ function scheduledAgentTime( : formatClock(scheduledAt); } +function footerLine( + home: HomeWidgetPresentation, + translate: (key: string) => string, + formatClock: GlanceableClockFormat +): string | null { + if (home.checkedAt === null) { + return null; + } + const at = formatClock(home.checkedAt); + return home.stale + ? `${translate('glanceable.lastKnown')} · ${at}` + : `${translate('glanceable.checked')} ${at}`; +} + // eslint-disable-next-line max-params -- shared policy, translator and injected number/clock formatters keep native/i18n imports out of this builder export function buildAndroidHomeCopy( home: HomeWidgetPresentation, @@ -92,20 +122,16 @@ export function buildAndroidHomeCopy( privacy: 'glanceable.privacy', }[home.status] ); - const checked = - home.checkedAt === null - ? null - : `${translate('glanceable.checked')} ${formatClock(home.checkedAt, { includeDate: true })}`; - const wake = wakeLine(home, translate, formatClock); + const content = home.status === 'content'; + const footer = + home.status === 'content' || home.status === 'empty' + ? footerLine(home, translate, formatClock) + : null; + const wake = + content && home.primaryKind === 'scheduled' ? wakeLine(home, translate, formatClock) : null; const actionLine = feedback === null ? null : translate(ACTION_FEEDBACK_KEYS[feedback]); - const title = home.primaryTitle === null ? null : home.primaryTitle || translate('common.agent'); - const knownTitle = home.stale - ? [translate('glanceable.lastKnown'), title].filter(Boolean).join(' · ') - : title; - const detail = - home.status !== 'content' - ? null - : (actionLine ?? (home.primaryKind === 'scheduled' ? (wake ?? knownTitle) : knownTitle)); + const title = + !content || home.primaryTitle === null ? null : home.primaryTitle || translate('common.agent'); const counts = home.primaryKind === null ? [] @@ -115,11 +141,22 @@ export function buildAndroidHomeCopy( ]; return { statusKind: home.status, + primaryKind: home.primaryKind, primaryCount: formatCount(home.primaryCount), primaryLabel, status, - detail, - checked, + emptyShort: translate('glanceable.empty'), + title, + wake, + wakeOverdue: home.awaitingUpdate, + footer, + actionLine, + approveFailed: translate('glanceable.approveFailed'), + headings: { + recent: translate('common.recent'), + waitingForYou: translate('glanceable.waitingForYou'), + nextScheduled: translate('glanceable.nextScheduled'), + }, secondaryCounts, waitingAgents: home.waitingAgents.map(agent => ({ title: agent.title || translate('common.agent'), @@ -132,9 +169,8 @@ export function buildAndroidHomeCopy( accessibilityLabel: [ status, ...counts, - detail, - home.stale && home.status === 'content' ? translate('glanceable.lastKnown') : null, - checked, + actionLine ?? wake ?? title, + footer, translate('glanceable.openAgents'), ] .filter(Boolean) diff --git a/apps/mobile/src/glanceable-android/live-update.test.ts b/apps/mobile/src/glanceable-android/live-update.test.ts index 0842284b82..dbd4451408 100644 --- a/apps/mobile/src/glanceable-android/live-update.test.ts +++ b/apps/mobile/src/glanceable-android/live-update.test.ts @@ -8,6 +8,8 @@ import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links'; import { type WaitingAsk } from '@/lib/glanceable/waiting-ask'; +import { type LiveUpdateCard } from './live-update'; + const mocks = vi.hoisted(() => ({ native: { isPromotionCapable: vi.fn(() => true), @@ -94,148 +96,44 @@ describe('notification Open destination', () => { }); }); +const CARD: LiveUpdateCard = { + title: '2 Needs input', + text: '3 Working · 1 Scheduled', + textIsError: false, + subText: 'Checked 4:32 PM', + compactText: '2', + openLabel: 'Open', + openUrl: 'kiloapp:///cloud/sessions/ses_1', + approveLabel: 'Approve', + newAgentLabel: null, + newAgentUrl: 'kiloapp:///cloud/sessions/new', +}; + describe('live-update bridge argument shape', () => { - it('forwards the Open and Approve fields in the native argument order on start', () => { - start( - 'Active agents', - '2 Needs input', - 'Open', - 'kiloapp:///cloud/sessions/ses_1', - 'Approve', - '2', - 'needs-input', - true - ); + it('forwards the card record, channel, alert and promotion gate on start', () => { + start(CARD, 'needs-input', true); expect(mocks.native.start).toHaveBeenCalledTimes(1); - expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '2 Needs input', - { label: 'Open', url: 'kiloapp:///cloud/sessions/ses_1' }, - 'Approve', - '2', - 'needs-input', - true, - true - ); - }); - - it('passes a null Approve label so the native side omits the action', () => { - start( - 'Active agents', - '3 Working', - 'Open', - 'kiloapp:///cloud/sessions', - null, - '3', - 'agent-progress', - false - ); - - expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '3 Working', - { label: 'Open', url: 'kiloapp:///cloud/sessions' }, - null, - '3', - 'agent-progress', - false, - true - ); + expect(mocks.native.start).toHaveBeenCalledWith(CARD, 'needs-input', true, true); }); it('defaults the update timeout to zero and forwards an explicit one', () => { - update( - 'Active agents', - 'No work in progress', - 'Open', - 'kiloapp:///cloud/sessions', - null, - null, - 'agent-progress', - false - ); + update(CARD, 'agent-progress', false); + expect(mocks.native.update).toHaveBeenLastCalledWith(CARD, 'agent-progress', false, 0); - expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - 'No work in progress', - { label: 'Open', url: 'kiloapp:///cloud/sessions' }, - null, - null, - 'agent-progress', - false, - 0 - ); - - update( - 'Active agents', - '4 Working', - 'Open', - 'kiloapp:///cloud/sessions/ses_2', - 'Approve', - '4', - 'needs-input', - true, - 8000 - ); - - expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '4 Working', - { label: 'Open', url: 'kiloapp:///cloud/sessions/ses_2' }, - 'Approve', - '4', - 'needs-input', - true, - 8000 - ); + update(CARD, 'needs-input', true, 8000); + expect(mocks.native.update).toHaveBeenLastCalledWith(CARD, 'needs-input', true, 8000); }); it('mirrors the native promotion gate on start and leaves it to the native update', () => { mocks.native.isPromotionCapable.mockReturnValue(false); - start( - 'Active agents', - '1 Working', - 'Open', - 'kiloapp:///cloud/sessions', - null, - '1', - 'agent-progress', - false - ); - update( - 'Active agents', - '1 Working', - 'Open', - 'kiloapp:///cloud/sessions', - null, - '1', - 'agent-progress', - false - ); + start(CARD, 'agent-progress', false); + update(CARD, 'agent-progress', false); - expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Working', - { label: 'Open', url: 'kiloapp:///cloud/sessions' }, - null, - '1', - 'agent-progress', - false, - false - ); - // The native `update` spends its eighth bridge slot on the terminal timeout - // and reads the promotion gate from its own `isPromotionCapable()`. - expect(mocks.native.update).toHaveBeenCalledWith( - 'Active agents', - '1 Working', - { label: 'Open', url: 'kiloapp:///cloud/sessions' }, - null, - '1', - 'agent-progress', - false, - 0 - ); + expect(mocks.native.start).toHaveBeenCalledWith(CARD, 'agent-progress', false, false); + // The native `update` carries the terminal timeout and reads the promotion + // gate from its own `isPromotionCapable()`. + expect(mocks.native.update).toHaveBeenCalledWith(CARD, 'agent-progress', false, 0); }); it('ends the notification through the native module', () => { diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts index 9b3d548562..0643b11248 100644 --- a/apps/mobile/src/glanceable-android/live-update.ts +++ b/apps/mobile/src/glanceable-android/live-update.ts @@ -18,25 +18,35 @@ import { launcherSessionUrl } from '@/lib/launcher-surfaces'; * importance, and Do Not Disturb override. */ +/** + * One card: the system-template content plus its actions. A single record keeps + * both native entry points within Expo's eight-argument `Function` limit. + * `approveLabel` and `newAgentLabel` are null when the card omits that action. + */ +export type LiveUpdateCard = { + title: string; + text: string; + textIsError: boolean; + subText: string | null; + compactText: string | null; + openLabel: string; + openUrl: string; + approveLabel: string | null; + newAgentLabel: string | null; + newAgentUrl: string; +}; + type LiveUpdateNativeModule = { isPromotionCapable(): boolean; isDndAccessGranted(): boolean; start( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, + card: LiveUpdateCard, channelId: AndroidNotificationChannelId, alerting: boolean, promotion: boolean ): void; update( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, + card: LiveUpdateCard, channelId: AndroidNotificationChannelId, alerting: boolean, timeoutMs: number @@ -102,60 +112,28 @@ export function buildNotificationActions( } /** - * Open action and content intent. `openUrl` is the recorded waiting session's - * deep link, or the Agents tab when nothing waits; `approveLabel` is non-null - * only while a cloud-agent permission ask can be answered, and adds the - * Approve action the receiver answers headlessly. The Open fields share one - * native argument to keep both entry points within Expo's eight-argument limit. + * Post the card. `openUrl` is the recorded waiting session's deep link, or the + * Agents tab when nothing waits; a non-null `approveLabel` adds the Approve + * action the receiver answers headlessly, and `newAgentLabel` the New agent one. */ -// eslint-disable-next-line max-params -- mirrors the native presentation fields export function start( - title: string, - text: string, - openLabel: string, - openUrl: string, - approveLabel: string | null, - compactText: string | null, + card: LiveUpdateCard, channelId: AndroidNotificationChannelId, alerting: boolean ): void { - nativeModule?.start( - title, - text, - { label: openLabel, url: openUrl }, - approveLabel, - compactText, - channelId, - alerting, - isPromotionCapable() - ); + nativeModule?.start(card, channelId, alerting, isPromotionCapable()); } -// eslint-disable-next-line max-params -- translated bridge fields plus the native terminal timeout +// eslint-disable-next-line max-params -- the card, its kind's channel and alert, plus the native terminal timeout export function update( - title: string, - text: string, - openLabel: string, - openUrl: string, - approveLabel: string | null, - compactText: string | null, + card: LiveUpdateCard, channelId: AndroidNotificationChannelId, alerting: boolean, timeoutMs = 0 ): void { - // The native `update` spends its eighth bridge slot on the terminal timeout, - // which is Expo's argument limit for a native `Function`, so it reads the - // promotion gate from its own `isPromotionCapable()` instead of a JS flag. - nativeModule?.update( - title, - text, - { label: openLabel, url: openUrl }, - approveLabel, - compactText, - channelId, - alerting, - timeoutMs - ); + // The native `update` carries the terminal timeout instead of the promotion + // flag; it reads the promotion gate from its own `isPromotionCapable()`. + nativeModule?.update(card, channelId, alerting, timeoutMs); } export function end(): void { diff --git a/apps/mobile/src/glanceable-android/notification-card.ts b/apps/mobile/src/glanceable-android/notification-card.ts new file mode 100644 index 0000000000..a3140836d4 --- /dev/null +++ b/apps/mobile/src/glanceable-android/notification-card.ts @@ -0,0 +1,47 @@ +import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; + +import { i18n } from '@/i18n'; +import { getWaitingAsk } from '@/lib/glanceable/waiting-ask'; +import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; + +import { getActionNotice, isActionApproving, pruneActionNotice } from './action-notice'; +import { formatGlanceableClock, formatGlanceableCount } from './count-format'; +import { buildNotificationActions, type LiveUpdateCard } from './live-update'; +import { + buildOngoingNotificationContent, + terminalNotificationContent, +} from './ongoing-notification'; + +function translate(key: string): string { + return i18n.t(key); +} + +/** The card for `snapshot`, carrying the approve state the headless task left for it. */ +export function cardFor(snapshot: GlanceableAgentsSnapshot, terminalText?: string): LiveUpdateCard { + pruneActionNotice(snapshot); + const approving = isActionApproving(); + const content = + terminalText === undefined + ? buildOngoingNotificationContent( + snapshot, + { translate, formatCount: formatGlanceableCount, formatClock: formatGlanceableClock }, + { approving, failure: getActionNotice() } + ) + : terminalNotificationContent(terminalText); + const actions = buildNotificationActions(getWaitingAsk(), translate); + return { + title: content.title, + text: content.text, + textIsError: content.textIsError, + subText: content.subText, + compactText: content.compactText, + openLabel: actions.openLabel, + openUrl: actions.openUrl, + // A terminal card has nothing to answer, even if a background delivery left + // an ask recorded, and an answer in flight cannot be sent twice. Open + // remains the route back; Approve disappears. + approveLabel: terminalText === undefined && !approving ? actions.approveLabel : null, + newAgentLabel: content.offersNewAgent ? translate('glanceable.newAgent') : null, + newAgentUrl: LAUNCHER_NEW_AGENT_URL, + }; +} diff --git a/apps/mobile/src/glanceable-android/ongoing-notification.test.ts b/apps/mobile/src/glanceable-android/ongoing-notification.test.ts new file mode 100644 index 0000000000..6503dacf85 --- /dev/null +++ b/apps/mobile/src/glanceable-android/ongoing-notification.test.ts @@ -0,0 +1,141 @@ +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, + type GlanceableSessionRow, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { describe, expect, it } from 'vitest'; + +import { + buildOngoingNotificationContent, + type NotificationAction, + type NotificationFormat, +} from './ongoing-notification'; + +const NOW = 1_750_000_000_000; +const WAKE = new Date(NOW + 7_200_000).toISOString(); +const COPY: Record<string, string> = { + 'glanceable.needsInput': 'Needs input', + 'common.working': 'Working', + 'common.scheduled': 'Scheduled', + 'common.idle': 'Idle', + 'glanceable.checked': 'Checked', + 'glanceable.lastKnown': 'Last known', + 'glanceable.nextRun': 'Next run', + 'glanceable.approving': 'Approving…', + 'glanceable.signedOut': 'Sign in to see agents', + 'glanceable.privacy': 'Open Kilo to see agents', + 'glanceable.expired': 'Status expired', + 'glanceable.waiting': 'Updating agents', + 'glanceable.empty': 'No work in progress', +}; +const FORMAT: NotificationFormat = { + translate: key => COPY[key] ?? key, + formatCount: String, + formatClock: at => (at === WAKE ? '6:00 PM' : '4:32 PM'), +}; +const IDLE: NotificationAction = { approving: false, failure: null }; + +function snapshotFor( + sessions: GlanceableSessionRow[], + status?: GlanceableAgentsSnapshot['status'] +) { + return buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + ...(status === undefined ? {} : { status }), + }); +} +const MIXED = snapshotFor([ + { status: 'permission' }, + { status: 'question' }, + { status: 'busy' }, + { status: 'busy' }, + { status: 'busy' }, + { status: 'scheduled', scheduledAt: WAKE }, +]); + +describe('ongoing notification content (round 8)', () => { + it('titles the ranked count and lists the others with the checked time', () => { + expect(buildOngoingNotificationContent(MIXED, FORMAT, IDLE)).toEqual({ + title: '2 Needs input', + text: '3 Working · 1 Scheduled', + textIsError: false, + subText: 'Checked 4:32 PM', + compactText: '2', + offersNewAgent: false, + }); + }); + + it('replaces the text while approving and with the red retry line after a failure', () => { + expect( + buildOngoingNotificationContent(MIXED, FORMAT, { approving: true, failure: null }) + ).toMatchObject({ + title: '2 Needs input', + text: 'Approving…', + textIsError: false, + }); + const failure = "Couldn't approve. Tap Approve to try again."; + expect( + buildOngoingNotificationContent(MIXED, FORMAT, { approving: false, failure }) + ).toMatchObject({ + text: failure, + textIsError: true, + }); + }); + + it('offers New agent on a working card, not on a stale one', () => { + const working = snapshotFor([{ status: 'busy' }, { status: 'busy' }, { status: 'idle' }]); + expect(buildOngoingNotificationContent(working, FORMAT, IDLE)).toMatchObject({ + title: '2 Working', + text: '1 Idle', + offersNewAgent: true, + }); + expect( + buildOngoingNotificationContent({ ...working, status: 'stale' }, FORMAT, IDLE) + ).toMatchObject({ subText: 'Last known · 4:32 PM', offersNewAgent: false }); + }); + + it('shows the run time as text and chip when only a wake is scheduled', () => { + const scheduled = snapshotFor([ + { status: 'scheduled', scheduledAt: WAKE }, + { status: 'scheduled', scheduledAt: new Date(NOW + 9e6).toISOString() }, + ]); + expect(buildOngoingNotificationContent(scheduled, FORMAT, IDLE)).toMatchObject({ + title: '2 Scheduled', + text: 'Next run 6:00 PM', + compactText: '6:00 PM', + }); + }); + + it('keeps a question-only card to its count', () => { + expect( + buildOngoingNotificationContent(snapshotFor([{ status: 'question' }]), FORMAT, IDLE) + ).toMatchObject({ title: '1 Needs input', text: '', compactText: '1' }); + }); + + it.each(['signed_out', 'privacy', 'expired', 'waiting', 'empty'] as const)( + 'shows only the status line on %s', + status => { + const content = buildOngoingNotificationContent({ ...MIXED, status }, FORMAT, IDLE); + expect(content).toMatchObject({ + text: '', + subText: null, + compactText: null, + offersNewAgent: false, + }); + expect(content.title).toBe( + FORMAT.translate( + { + signed_out: 'glanceable.signedOut', + privacy: 'glanceable.privacy', + expired: 'glanceable.expired', + waiting: 'glanceable.waiting', + empty: 'glanceable.empty', + }[status] + ) + ); + } + ); +}); diff --git a/apps/mobile/src/glanceable-android/ongoing-notification.ts b/apps/mobile/src/glanceable-android/ongoing-notification.ts new file mode 100644 index 0000000000..aca775e464 --- /dev/null +++ b/apps/mobile/src/glanceable-android/ongoing-notification.ts @@ -0,0 +1,106 @@ +import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; + +import { + glanceableCountLines, + glanceableScheduledAt, + glanceableStatusCopyKey, + primaryGlanceableCount, + resolveGlanceableStatus, +} from '@/lib/glanceable/presentation'; + +import { type GlanceableClockFormat, type GlanceableCountFormat } from './home-copy'; + +/** + * The ongoing notification's system-template content (round 8). It is visible + * on the lock screen, so it carries counts, status and times only: never an + * agent title, a session id or an organization. + */ +export type OngoingNotificationContent = { + /** "<count> <status>" for the ranked primary count, or the status line. */ + title: string; + /** The other counts, "Next run <time>" for scheduled-only, "Approving…", or the retry line. */ + text: string; + /** Draws `text` as the failed-approve retry line. */ + textIsError: boolean; + /** "Checked <time>", or "Last known · <time>" while the data is stale. */ + subText: string | null; + /** The promoted chip: the primary count, or the run time when only a wake is scheduled. */ + compactText: string | null; + /** Working cards offer New agent; waiting, scheduled and stale ones do not. */ + offersNewAgent: boolean; +}; + +export type NotificationFormat = { + translate: (key: string) => string; + formatCount: GlanceableCountFormat; + formatClock: GlanceableClockFormat; +}; + +/** What the approve flow is doing to the card: nothing, in flight, or failed with its line. */ +export type NotificationAction = { approving: boolean; failure: string | null }; + +function checkedLine( + snapshot: GlanceableAgentsSnapshot, + format: NotificationFormat +): string | null { + if (!Number.isFinite(Date.parse(snapshot.updatedAt))) { + return null; + } + const at = format.formatClock(snapshot.updatedAt); + return snapshot.status === 'stale' + ? `${format.translate('glanceable.lastKnown')} · ${at}` + : `${format.translate('glanceable.checked')} ${at}`; +} + +export function buildOngoingNotificationContent( + snapshot: GlanceableAgentsSnapshot, + format: NotificationFormat, + action: NotificationAction +): OngoingNotificationContent { + const { translate, formatCount, formatClock } = format; + const status = resolveGlanceableStatus(snapshot); + const primary = + status === 'happy' || status === 'stale' ? primaryGlanceableCount(snapshot) : null; + if (primary === null) { + return { + title: translate(glanceableStatusCopyKey(snapshot) ?? 'glanceable.empty'), + text: '', + textIsError: false, + subText: null, + compactText: null, + offersNewAgent: false, + }; + } + const others = glanceableCountLines(snapshot) + .filter(line => line.kind !== primary.kind && line.count > 0) + .map(line => `${formatCount(line.count)} ${translate(line.key)}`) + .join(' · '); + const scheduledAt = glanceableScheduledAt(snapshot); + const wakeOnly = primary.kind === 'scheduled' && others === '' && scheduledAt !== null; + let text = wakeOnly ? `${translate('glanceable.nextRun')} ${formatClock(scheduledAt)}` : others; + if (action.failure !== null) { + text = action.failure; + } else if (action.approving) { + text = translate('glanceable.approving'); + } + return { + title: `${formatCount(primary.count)} ${translate(primary.key)}`, + text, + textIsError: action.failure !== null, + subText: checkedLine(snapshot, format), + compactText: wakeOnly ? formatClock(scheduledAt) : formatCount(primary.count), + offersNewAgent: primary.kind === 'running' && snapshot.status !== 'stale', + }; +} + +/** A terminal card (work just ended): the status line alone until the native timeout. */ +export function terminalNotificationContent(title: string): OngoingNotificationContent { + return { + title, + text: '', + textIsError: false, + subText: null, + compactText: null, + offersNewAgent: false, + }; +} diff --git a/apps/mobile/src/glanceable-android/register.test-helpers.ts b/apps/mobile/src/glanceable-android/register.test-helpers.ts index 66870f48d9..d538920b66 100644 --- a/apps/mobile/src/glanceable-android/register.test-helpers.ts +++ b/apps/mobile/src/glanceable-android/register.test-helpers.ts @@ -109,3 +109,20 @@ export function collectText(node: ReactNode | undefined): string[] { const text = node.props.text === undefined ? [] : [node.props.text]; return [...text, ...collectText(node.props.children)]; } + +/** Spoken labels of the in-widget controls (the whole-widget open action excluded). */ +export function collectControls(node: ReactNode | undefined, root = true): string[] { + if (Array.isArray(node)) { + return node.flatMap((child: ReactNode) => collectControls(child, false)); + } + if ( + !isValidElement<{ clickAction?: string; accessibilityLabel?: string; children?: ReactNode }>( + node + ) + ) { + return []; + } + const own = + !root && node.props.clickAction !== undefined ? [node.props.accessibilityLabel ?? ''] : []; + return [...own, ...collectControls(node.props.children, false)]; +} diff --git a/apps/mobile/src/glanceable-android/register.test.ts b/apps/mobile/src/glanceable-android/register.test.ts index c64e10a8d5..2c039bc703 100644 --- a/apps/mobile/src/glanceable-android/register.test.ts +++ b/apps/mobile/src/glanceable-android/register.test.ts @@ -6,6 +6,7 @@ import { type JSX } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + collectControls, collectText, NOW, runWidgetClickTask, @@ -72,6 +73,7 @@ vi.mock('react-native-android-widget', () => ({ requestWidgetUpdateById: mocks.requestById, getWidgetInfo: mocks.getWidgetInfo, FlexWidget: () => null, + OverlapWidget: () => null, TextWidget: () => null, })); @@ -123,9 +125,8 @@ describe.each([172, 360])('headless Home widget at %d dp', width => { const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); const rendered = await runWidgetTask(handleWidgetTask, width); const text = collectText(rendered.light); - expect(text).toEqual( - expect.arrayContaining(['Kilo', '2', 'Needs input', '+', width >= 266 ? 'Approve' : '✓']) - ); + expect(text).toEqual(expect.arrayContaining(['Kilo', '2', 'Needs input'])); + expect(collectControls(rendered.light)).toEqual(['Approve', 'New agent']); expect(text).not.toContain('0'); expect(text.some(line => line.startsWith('Checked'))).toBe(true); expect(rendered.light.props).toMatchObject({ @@ -139,7 +140,8 @@ describe.each([172, 360])('headless Home widget at %d dp', width => { const { handleWidgetTask } = await registerAfterRestart(stored); vi.setSystemTime(Date.parse(stored.expiresAt) + 1); const rendered = await runWidgetTask(handleWidgetTask, width); - expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['2', 'Needs input', '+'])); + expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['2', 'Needs input'])); + expect(collectControls(rendered.light)).toContain('New agent'); expect(collectText(rendered.light)).not.toContain('Status expired'); expect(mocks.getDeadline()).toBe(Date.parse(stored.expiresAt)); }); @@ -155,7 +157,7 @@ describe.each([172, 360])('headless Home widget at %d dp', width => { status === 'privacy' ? 'Open Kilo to see agents' : 'Sign in to see agents' ); expect(collectText(rendered.light)).not.toContain('2'); - expect(collectText(rendered.light)).not.toContain('✓'); + expect(collectControls(rendered.light)).toEqual([]); } ); @@ -190,9 +192,12 @@ describe.each([172, 360])('headless Home widget at %d dp', width => { const renders = await runWidgetClickTask(handleWidgetTask, width, 'approve'); expect(mocks.runWidgetApprove).toHaveBeenCalledTimes(1); expect(renders).toHaveLength(2); - expect(collectText(renders[0]?.light)).toContain('Approving…'); - expect(collectText(renders[0]?.light)).not.toContain(width >= 266 ? 'Approve' : '✓'); - expect(collectText(renders[1]?.light)).toContain(width >= 266 ? 'Approve' : '✓'); + // Small shows the in-flight dots in the Approve slot; Medium's pill reads "Approving…". + if (width >= 266) { + expect(collectText(renders[0]?.light)).toContain('Approving…'); + } + expect(collectControls(renders[0]?.light)).not.toContain('Approve'); + expect(collectControls(renders[1]?.light)).toContain('Approve'); expect(mocks.linking.openURL).not.toHaveBeenCalled(); }); @@ -200,9 +205,11 @@ describe.each([172, 360])('headless Home widget at %d dp', width => { mocks.runWidgetApprove.mockResolvedValue({ kind: 'failed' }); const { handleWidgetTask } = await registerAfterRestart(snapshotFor()); const renders = await runWidgetClickTask(handleWidgetTask, width, 'approve'); - expect(collectText(renders.at(-1)?.light)).toEqual( - expect.arrayContaining(['Could not approve', width >= 266 ? 'Approve' : '✓', '+']) + const last = renders.at(-1)?.light; + expect(collectText(last)).toContain( + width >= 266 ? "Couldn't approve. Tap Approve to try again." : 'Could not approve' ); + expect(collectControls(last)).toEqual(['Approve', 'New agent']); expect(mocks.linking.openURL).not.toHaveBeenCalled(); }); @@ -301,7 +308,7 @@ describe('WorkManager Home-only refresh', () => { expect(mocks.applyResponse).toHaveBeenCalledWith({ terminal: 'privacy' }, CONTEXT); expect(rendered).toContain('Open Kilo to see agents'); expect(rendered).not.toContain('2'); - expect(rendered).not.toContain('+'); + expect(rendered).not.toContain('New agent'); }); }); @@ -313,7 +320,8 @@ it('renders an authorized native/Home-only record even without any activity snap details: EMPTY_HOME_WIDGET_DETAILS, }); const rendered = await runWidgetTask(handleWidgetTask, 172); - expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['1', 'Working', '+'])); + expect(collectText(rendered.light)).toEqual(expect.arrayContaining(['1', 'Working'])); + expect(collectControls(rendered.light)).toEqual(['New agent']); expect(mocks.native.startOrUpdate).not.toHaveBeenCalled(); }); @@ -357,5 +365,5 @@ it('rereads the newly published tray after approval instead of reusing the tappe const renders = await runWidgetClickTask(handleWidgetTask, 172, 'approve'); expect(collectText(renders.at(-1)?.light)).toEqual(expect.arrayContaining(['1', 'Working'])); expect(collectText(renders.at(-1)?.light)).not.toContain('Needs input'); - expect(collectText(renders.at(-1)?.light)).not.toContain('✓'); + expect(collectControls(renders.at(-1)?.light)).not.toContain('Approve'); }); diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts index c97f23ae12..9624fb1841 100644 --- a/apps/mobile/src/glanceable-android/widget-props.test.ts +++ b/apps/mobile/src/glanceable-android/widget-props.test.ts @@ -1,6 +1,5 @@ import { buildHomeWidgetData, - EMPTY_HOME_WIDGET_DETAILS, type HomeWidgetData, type HomeWidgetSessionRow, } from '@kilocode/app-shared/home-widget'; @@ -12,10 +11,8 @@ import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; import { buildAndroidWidgetProps, - buildCompactNotificationText, buildCurrentWidgetProps, buildGenericWidgetProps, - buildOngoingNotificationText, } from './widget-props'; const NOW = 1_750_000_000_000; @@ -91,8 +88,8 @@ describe('shared Home presentation localization', () => { expect(props.homeCopy).toMatchObject({ primaryCount: '2', primaryLabel: 'Needs input', - detail: 'Permission title', - checked: `Checked clock:${MIXED.snapshot.updatedAt}`, + title: 'Permission title', + footer: `Checked clock:${MIXED.snapshot.updatedAt}`, }); expect(props.homeCopy?.secondaryCounts).toEqual([ { kind: 'running', count: '1', label: 'Working' }, @@ -128,7 +125,7 @@ describe('shared Home presentation localization', () => { canCreate: true, canApprove: true, }); - expect(props.homeCopy?.checked).toBe(`Checked clock:${MIXED.snapshot.updatedAt}`); + expect(props.homeCopy?.footer).toBe(`Last known · clock:${MIXED.snapshot.updatedAt}`); expect(props.homeCopy?.accessibilityLabel).toContain('Last known'); // Activity expiry still wins for the counts-only props used by notification lifetime. expect(props.statusLine).toBe('Status expired'); @@ -173,7 +170,8 @@ describe('shared Home presentation localization', () => { canApprove: false, }); expect(props.homeCopy?.secondaryCounts).toEqual([]); - expect(props.homeCopy?.checked).toBeNull(); + expect(props.homeCopy?.footer).toBeNull(); + expect(props.homeCopy?.title).toBeNull(); expect(JSON.stringify(props)).not.toContain('Permission title'); } ); @@ -208,10 +206,13 @@ describe('shared Home presentation localization', () => { { status: 'scheduled', title: 'Wake', scheduledAt: WAKE }, { status: 'idle' }, ]); - expect(propsFor(data).homeCopy?.detail).toBe(`Next run clock:${WAKE}`); + expect(propsFor(data).homeCopy).toMatchObject({ + wake: `Next run clock:${WAKE}`, + title: 'Wake', + }); vi.setSystemTime(Date.parse(WAKE)); const props = propsFor(data); - expect(props.homeCopy?.detail).toBe('Awaiting update'); + expect(props.homeCopy).toMatchObject({ wake: 'Awaiting update', wakeOverdue: true }); expect(props.home?.primaryKind).toBe('scheduled'); expect(props.home?.primaryCount).toBe(1); }); @@ -219,7 +220,7 @@ describe('shared Home presentation localization', () => { it('keeps unknown wake times absent while retaining the scheduled count', () => { const props = propsFor(dataFor([{ status: 'scheduled' }])); expect(props.home?.primaryKind).toBe('scheduled'); - expect(props.homeCopy?.detail).toBe('Agent'); + expect(props.homeCopy).toMatchObject({ wake: null, title: 'Agent' }); expect(props.home?.scheduledAt).toBeNull(); expect(props.home?.awaitingUpdate).toBe(false); }); @@ -233,12 +234,15 @@ describe('shared Home presentation localization', () => { ]); }); - it('shows approve feedback ahead of a title without claiming that title was approved', () => { + it('carries approve feedback beside the title without claiming that title was approved', () => { setSurfaceExtras({ newestSessionTitle: 'not Home data', actionFeedback: 'approving' }); - expect(propsFor(MIXED).homeCopy?.detail).toBe('Approving…'); + expect(propsFor(MIXED).homeCopy).toMatchObject({ + actionLine: 'Approving…', + title: 'Permission title', + }); setSurfaceExtras({ newestSessionTitle: 'not Home data', actionFeedback: 'couldNotApprove' }); - expect(propsFor(MIXED).homeCopy?.detail).toBe('Could not approve'); - expect(propsFor(dataFor([], 'privacy')).homeCopy?.detail).toBeNull(); + expect(propsFor(MIXED).homeCopy?.accessibilityLabel).toContain('Could not approve'); + expect(propsFor(dataFor([], 'privacy')).homeCopy?.actionLine).toBeNull(); }); it('does not attach private titles to the generic no-account fallback', () => { @@ -249,70 +253,3 @@ describe('shared Home presentation localization', () => { expect(props.actions.newAgent).toBe(false); }); }); - -describe('notification privacy and activity policy remain independent', () => { - it('lists ranked nonzero counts without Home titles or private identifiers', () => { - expect(buildOngoingNotificationText(MIXED.snapshot, {}, translate)).toBe( - `2 Needs input, 1 Working, 1 Scheduled wakes ${WAKE}, 1 Idle` - ); - const notification = buildOngoingNotificationText(MIXED.snapshot, {}, translate); - expect(notification).not.toContain('Permission title'); - expect(notification).not.toContain('Schedule title'); - expect(notification).not.toContain('user-private-id'); - }); - - it('keeps the stale warning and retry notice in the notification only', () => { - const stale = { ...MIXED.snapshot, status: 'stale' as const }; - expect(buildOngoingNotificationText(stale, {}, translate, String, 'Could not approve')).toBe( - `Could not approve Updates delayed, 2 Needs input, 1 Working, 1 Scheduled wakes ${WAKE}, 1 Idle` - ); - }); - - it.each(['signed_out', 'privacy', 'expired', 'waiting', 'empty'] as const)( - 'does not leak retained Home counts on %s', - status => { - const snapshot = { ...MIXED.snapshot, status }; - expect(buildOngoingNotificationText(snapshot, {}, translate)).toBe( - translate( - { - signed_out: 'glanceable.signedOut', - privacy: 'glanceable.privacy', - expired: 'glanceable.expired', - waiting: 'glanceable.waiting', - empty: 'glanceable.empty', - }[status] - ) - ); - expect(buildCompactNotificationText(snapshot, {})).toBeNull(); - } - ); - - it('keeps notification relative wake formatting and literal placeholder insertion', () => { - const data = dataFor([{ status: 'scheduled', scheduledAt: WAKE }]); - expect( - buildOngoingNotificationText( - data.snapshot, - {}, - translate, - String, - null, - () => '$& in 2 hours' - ) - ).toBe('1 Scheduled wakes $& in 2 hours'); - }); - - it('formats the promoted notification primary count without titles', () => { - expect(buildCompactNotificationText(MIXED.snapshot, {}, value => `digit:${value}`)).toBe( - 'digit:2' - ); - const scheduled = dataFor([{ status: 'scheduled' }, { status: 'idle' }]); - expect(buildCompactNotificationText(scheduled.snapshot, {})).toBe('1'); - }); - - it('does not require Home details to render privacy-minimal activity text', () => { - const noDetails = { ...MIXED, details: EMPTY_HOME_WIDGET_DETAILS }; - expect(buildOngoingNotificationText(noDetails.snapshot, {}, translate)).toBe( - buildOngoingNotificationText(MIXED.snapshot, {}, translate) - ); - }); -}); diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts index 5782f18def..1b70ea14cb 100644 --- a/apps/mobile/src/glanceable-android/widget-props.ts +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -174,50 +174,3 @@ export function buildGenericWidgetProps(translate: (key: string) => string): And accessibilityLabel: signedOut, }; } - -/** Notification content stays privacy-minimal and follows activity lifetime, not Home retention. */ -// eslint-disable-next-line max-params -- existing notification API takes snapshot, flags, copy, formatter and notice -export function buildOngoingNotificationText( - snapshot: GlanceableAgentsSnapshot, - flags: GlanceableSurfaceFlags, - translate: (key: string) => string, - formatCount: GlanceableCountFormat = String, - notice: string | null = null, - formatAgo: GlanceableAgoFormat = String -): string { - const status = resolveGlanceableStatus(snapshot, flags); - if (status === 'happy' || status === 'stale') { - const lines = glanceableCountLines(snapshot).filter(line => line.count > 0); - if (lines.length > 0) { - const scheduledAt = glanceableScheduledAt(snapshot); - const counts = lines - .map(line => { - const label = `${formatCount(line.count)} ${translate(line.key)}`; - if (line.kind !== 'scheduled' || scheduledAt === null) { - return label; - } - const wake = formatAgo(scheduledAt); - return `${label} ${translate('glanceable.scheduledWakes').replace('{{time}}', () => wake)}`; - }) - .join(', '); - const value = status === 'stale' ? `${translate('glanceable.stale')}, ${counts}` : counts; - return notice === null ? value : `${notice} ${value}`; - } - } - const value = translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty'); - return notice === null ? value : `${notice} ${value}`; -} - -/** Promoted notifications only show the primary number, never a Home-only title. */ -export function buildCompactNotificationText( - snapshot: GlanceableAgentsSnapshot, - flags: GlanceableSurfaceFlags, - formatCount: GlanceableCountFormat = String -): string | null { - const status = resolveGlanceableStatus(snapshot, flags); - if (status !== 'happy' && status !== 'stale') { - return null; - } - const primary = primaryGlanceableCount(snapshot); - return primary === null ? null : formatCount(primary.count); -} From 73e3e1326dc8d491f40bc9805404ff3d74dd04ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:25:22 +0200 Subject: [PATCH 23/31] chore(mobile): drop the unused scheduled-wakes copy and dead exports --- apps/mobile/src/i18n/locales/af.json | 1 - apps/mobile/src/i18n/locales/am.json | 1 - apps/mobile/src/i18n/locales/ar.json | 1 - apps/mobile/src/i18n/locales/az.json | 1 - apps/mobile/src/i18n/locales/be.json | 1 - apps/mobile/src/i18n/locales/bg.json | 1 - apps/mobile/src/i18n/locales/bn.json | 1 - apps/mobile/src/i18n/locales/bs.json | 1 - apps/mobile/src/i18n/locales/ca.json | 1 - apps/mobile/src/i18n/locales/ckb.json | 1 - apps/mobile/src/i18n/locales/cs.json | 1 - apps/mobile/src/i18n/locales/cy.json | 1 - apps/mobile/src/i18n/locales/da.json | 1 - apps/mobile/src/i18n/locales/de.json | 1 - apps/mobile/src/i18n/locales/el.json | 1 - apps/mobile/src/i18n/locales/en.json | 1 - apps/mobile/src/i18n/locales/es.json | 1 - apps/mobile/src/i18n/locales/et.json | 1 - apps/mobile/src/i18n/locales/eu.json | 1 - apps/mobile/src/i18n/locales/fa.json | 1 - apps/mobile/src/i18n/locales/fi.json | 1 - apps/mobile/src/i18n/locales/fil.json | 1 - apps/mobile/src/i18n/locales/fr.json | 1 - apps/mobile/src/i18n/locales/ga.json | 1 - apps/mobile/src/i18n/locales/gl.json | 1 - apps/mobile/src/i18n/locales/gu.json | 1 - apps/mobile/src/i18n/locales/ha.json | 1 - apps/mobile/src/i18n/locales/he.json | 1 - apps/mobile/src/i18n/locales/hi.json | 1 - apps/mobile/src/i18n/locales/hr.json | 1 - apps/mobile/src/i18n/locales/ht.json | 1 - apps/mobile/src/i18n/locales/hu.json | 1 - apps/mobile/src/i18n/locales/hy.json | 1 - apps/mobile/src/i18n/locales/id.json | 1 - apps/mobile/src/i18n/locales/ig.json | 1 - apps/mobile/src/i18n/locales/is.json | 1 - apps/mobile/src/i18n/locales/it.json | 1 - apps/mobile/src/i18n/locales/ja.json | 1 - apps/mobile/src/i18n/locales/ka.json | 1 - apps/mobile/src/i18n/locales/kk.json | 1 - apps/mobile/src/i18n/locales/km.json | 1 - apps/mobile/src/i18n/locales/kn.json | 1 - apps/mobile/src/i18n/locales/ko.json | 1 - apps/mobile/src/i18n/locales/lo.json | 1 - apps/mobile/src/i18n/locales/lt.json | 1 - apps/mobile/src/i18n/locales/lv.json | 1 - apps/mobile/src/i18n/locales/mg.json | 1 - apps/mobile/src/i18n/locales/mi.json | 1 - apps/mobile/src/i18n/locales/mk.json | 1 - apps/mobile/src/i18n/locales/ml.json | 1 - apps/mobile/src/i18n/locales/mn.json | 1 - apps/mobile/src/i18n/locales/mr.json | 1 - apps/mobile/src/i18n/locales/ms.json | 1 - apps/mobile/src/i18n/locales/mt.json | 1 - apps/mobile/src/i18n/locales/my.json | 1 - apps/mobile/src/i18n/locales/nb.json | 1 - apps/mobile/src/i18n/locales/ne.json | 1 - apps/mobile/src/i18n/locales/nl.json | 1 - apps/mobile/src/i18n/locales/om.json | 1 - apps/mobile/src/i18n/locales/or.json | 1 - apps/mobile/src/i18n/locales/pa.json | 1 - apps/mobile/src/i18n/locales/pl.json | 1 - apps/mobile/src/i18n/locales/ps.json | 1 - apps/mobile/src/i18n/locales/pt-BR.json | 1 - apps/mobile/src/i18n/locales/pt.json | 1 - apps/mobile/src/i18n/locales/ro.json | 1 - apps/mobile/src/i18n/locales/ru.json | 1 - apps/mobile/src/i18n/locales/si.json | 1 - apps/mobile/src/i18n/locales/sk.json | 1 - apps/mobile/src/i18n/locales/sl.json | 1 - apps/mobile/src/i18n/locales/so.json | 1 - apps/mobile/src/i18n/locales/sq.json | 1 - apps/mobile/src/i18n/locales/sr.json | 1 - apps/mobile/src/i18n/locales/sv.json | 1 - apps/mobile/src/i18n/locales/sw.json | 1 - apps/mobile/src/i18n/locales/ta.json | 1 - apps/mobile/src/i18n/locales/te.json | 1 - apps/mobile/src/i18n/locales/th.json | 1 - apps/mobile/src/i18n/locales/tr.json | 1 - apps/mobile/src/i18n/locales/uk.json | 1 - apps/mobile/src/i18n/locales/ur.json | 1 - apps/mobile/src/i18n/locales/uz.json | 1 - apps/mobile/src/i18n/locales/vi.json | 1 - apps/mobile/src/i18n/locales/yo.json | 1 - apps/mobile/src/i18n/locales/zh-Hans.json | 1 - apps/mobile/src/i18n/locales/zh-Hant.json | 1 - apps/mobile/src/i18n/locales/zu.json | 1 - .../glanceable/cross-platform-approve-parity.test.ts | 6 ++---- .../src/lib/glanceable/platform-parity.test.ts | 12 ++++++------ 89 files changed, 8 insertions(+), 97 deletions(-) diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index aa68515cfc..55da9e5899 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Skakel regstreekse aktiwiteite in die instellings aan om aktiewe agente op die sluitskerm te sien.", "openSession": "Oop", "approveFailed": "Kon nie goedkeur nie. Tik op $t(common.approve) om weer te probeer.", - "scheduledWakes": "word wakker {{time}}", "checked": "Gekontroleer", "lastKnown": "Laas bekend", "awaitingUpdate": "Wag op opdatering", diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 5024884ecd..d73c7b7b72 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ንቁ ወኪሎችን በተቆለፈው ማያ ገጽ ላይ ለማየት በቅንብሮች ውስጥ የቀጥታ እንቅስቃሴዎችን አንቃ።", "openSession": "ክፈት", "approveFailed": "ማጽደቅ አልተቻለም። እንደገና ለመሞከር $t(common.approve) ይንኩ።", - "scheduledWakes": "{{time}} ይነቃል", "checked": "የተረጋገጠ", "lastKnown": "ለመጨረሻ ጊዜ የታወቀ", "awaitingUpdate": "ዝማኔ በመጠበቅ ላይ", diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index 1dff0ae9e7..964d9dda74 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3320,7 +3320,6 @@ "activityKitDisabledBody": "فعّل الأنشطة المباشرة في الإعدادات لعرض الوكلاء النشطين على شاشة القفل.", "openSession": "افتح", "approveFailed": "تعذّر الموافقة. اضغط على $t(common.approve) للمحاولة مرة أخرى.", - "scheduledWakes": "يستيقظ {{time}}", "checked": "آخر تحقق", "lastKnown": "آخر حالة معروفة", "awaitingUpdate": "بانتظار تحديث", diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 7e5e0d4667..4dd95126d4 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Kilid ekranında aktiv agentləri görmək üçün parametrlərdə canlı fəaliyyətləri aktivləşdir.", "openSession": "Aç", "approveFailed": "Təsdiqləmək mümkün olmadı. Yenidən cəhd etmək üçün $t(common.approve) üzərinə toxun.", - "scheduledWakes": "{{time}} oyanır", "checked": "Yoxlanılıb", "lastKnown": "Son məlum vəziyyət", "awaitingUpdate": "Yenilənmə gözlənilir", diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index 4dc123293a..8d50ee858d 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Уключы дзеянні ў рэальным часе ў наладах, каб бачыць актыўных агентаў на экране блакіроўкі.", "openSession": "Адкрыць", "approveFailed": "Не ўдалося ўхваліць. Націсніце $t(common.approve), каб паспрабаваць яшчэ раз.", - "scheduledWakes": "прачынаецца {{time}}", "checked": "Праверана", "lastKnown": "Апошні вядомы стан", "awaitingUpdate": "Чакае абнаўлення", diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 3164fd5d8d..e4ccb84b57 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Включи дейностите на живо в настройките, за да виждаш активните агенти на заключения екран.", "openSession": "Отвори", "approveFailed": "Не успяхме да одобрим. Докосни $t(common.approve), за да опиташ отново.", - "scheduledWakes": "се събужда {{time}}", "checked": "Проверено", "lastKnown": "Последно известно", "awaitingUpdate": "Изчаква актуализация", diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index bd41b25ab6..11cb5ba2ef 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "লক স্ক্রিনে সক্রিয় এজেন্ট দেখতে সেটিংসে লাইভ অ্যাক্টিভিটি চালু করুন।", "openSession": "খুলুন", "approveFailed": "অনুমোদন করা যায়নি। আবার চেষ্টা করতে $t(common.approve) স্পর্শ করুন।", - "scheduledWakes": "{{time}} জাগবে", "checked": "যাচাই করা হয়েছে", "lastKnown": "সর্বশেষ জানা", "awaitingUpdate": "আপডেটের অপেক্ষায়", diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 24684a75c6..e658068585 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama da vidiš aktivne agente na zaključanom ekranu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspjelo. Dodirni $t(common.approve) da pokušaš ponovo.", - "scheduledWakes": "budi se {{time}}", "checked": "Provjereno", "lastKnown": "Posljednje poznato", "awaitingUpdate": "Čeka ažuriranje", diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index b7438b1dae..03486cc7e8 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Activa les activitats en directe a la configuració del dispositiu per veure els agents actius a la pantalla de bloqueig.", "openSession": "Obre", "approveFailed": "No s'ha pogut aprovar. Toca $t(common.approve) per tornar-ho a provar.", - "scheduledWakes": "es desperta {{time}}", "checked": "Comprovat", "lastKnown": "Últim estat conegut", "awaitingUpdate": "Pendent d’actualització", diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index c0b67bc392..c6dab105d3 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "بۆ بینینی ئەجێنتە چالاکەکان لە شاشەی قوفڵدا، چالاکییە ڕاستەوخۆکان لە ڕێکخستنەکاندا چالاک بکە.", "openSession": "بیکەرەوە", "approveFailed": "نەتوانرا پەسند بکرێت. بۆ دووبارە هەوڵدانەوە، لە $t(common.approve) بدە.", - "scheduledWakes": "{{time}} خەبەر دەبێت", "checked": "پشکنراو", "lastKnown": "دوایین دۆخی زانراو", "awaitingUpdate": "چاوەڕێی نوێکردنەوە", diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 5dd423d5be..c01a4aea0a 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "V nastavení zapni živé aktivity, aby se aktivní agenti zobrazovali na zamknuté obrazovce.", "openSession": "Otevřít", "approveFailed": "Schválení se nepodařilo. Klepnutím na $t(common.approve) to zkuste znovu.", - "scheduledWakes": "probudí se {{time}}", "checked": "Ověřeno", "lastKnown": "Poslední známý stav", "awaitingUpdate": "Čeká na aktualizaci", diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index d69943fa36..e2de4e93cf 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3320,7 +3320,6 @@ "activityKitDisabledBody": "Trowch weithgareddau byw ymlaen yn y gosodiadau i weld asiantau gweithredol ar y sgrin glo.", "openSession": "Agor", "approveFailed": "Methu cymeradwyo. Tapiwch $t(common.approve) i geisio eto.", - "scheduledWakes": "yn deffro {{time}}", "checked": "Gwiriwyd", "lastKnown": "Hysbys diwethaf", "awaitingUpdate": "Yn aros am ddiweddariad", diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index d70691e32f..993266dfde 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Slå liveaktiviteter til i Indstillinger for at se aktive agenter på låseskærmen.", "openSession": "Åbn", "approveFailed": "Kunne ikke godkende. Tryk på $t(common.approve) for at prøve igen.", - "scheduledWakes": "vågner {{time}}", "checked": "Kontrolleret", "lastKnown": "Sidst kendt", "awaitingUpdate": "Afventer opdatering", diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index f076f18824..e8b7cf0e06 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Aktiviere Live-Aktivitäten in den Einstellungen, um aktive Agenten auf dem Sperrbildschirm zu sehen.", "openSession": "Öffnen", "approveFailed": "Genehmigen fehlgeschlagen. Tippe auf $t(common.approve), um es erneut zu versuchen.", - "scheduledWakes": "wacht {{time}} auf", "checked": "Geprüft", "lastKnown": "Zuletzt bekannt", "awaitingUpdate": "Aktualisierung ausstehend", diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 63ec672107..8c115ff37a 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Ενεργοποίησε τις ζωντανές δραστηριότητες στις ρυθμίσεις για να βλέπεις τους ενεργούς πράκτορες στην οθόνη κλειδώματος.", "openSession": "Άνοιξε", "approveFailed": "Δεν ήταν δυνατή η έγκριση. Πάτησε $t(common.approve) για να δοκιμάσεις ξανά.", - "scheduledWakes": "ξυπνάει {{time}}", "checked": "Ελέγχθηκε", "lastKnown": "Τελευταία γνωστή κατάσταση", "awaitingUpdate": "Αναμονή ενημέρωσης", diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index ceabab811e..ed3b59a160 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Turn on Live Activities in Settings to see Active Agents on the Lock Screen.", "openSession": "Open", "approveFailed": "Couldn't approve. Tap $t(common.approve) to try again.", - "scheduledWakes": "wakes {{time}}", "checked": "Checked", "lastKnown": "Last known", "awaitingUpdate": "Awaiting update", diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 93d307f428..c8e469cd8a 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Activa las actividades en directo en Ajustes para ver los agentes activos en la pantalla de bloqueo.", "openSession": "Abrir", "approveFailed": "No se pudo aprobar. Toca $t(common.approve) para intentarlo de nuevo.", - "scheduledWakes": "se activa {{time}}", "checked": "Comprobado", "lastKnown": "Último estado conocido", "awaitingUpdate": "En espera de actualización", diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 259bea39f7..a5259547b7 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Lülita seadetes reaalajas tegevused sisse, et näha lukustuskuval aktiivseid agente.", "openSession": "Ava", "approveFailed": "Kinnitamine ebaõnnestus. Uuesti proovimiseks puuduta $t(common.approve).", - "scheduledWakes": "ärkab {{time}}", "checked": "Kontrollitud", "lastKnown": "Viimane teadaolev", "awaitingUpdate": "Ootab uuendust", diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index 2f137b8920..2e9d97439a 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Aktibatu zuzeneko jarduerak ezarpenetan, agente aktiboak blokeo-pantailan ikusteko.", "openSession": "Ireki", "approveFailed": "Ezin izan da onartu. Sakatu $t(common.approve) berriro saiatzeko.", - "scheduledWakes": "{{time}} esnatuko da", "checked": "Egiaztatuta", "lastKnown": "Azken egoera ezaguna", "awaitingUpdate": "Eguneratzearen zain", diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index f9c452fec2..f400b02f7a 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "برای دیدن عامل‌های فعال در صفحهٔ قفل، فعالیت‌های زنده را در تنظیمات فعال کنید.", "openSession": "باز کن", "approveFailed": "تأیید ممکن نشد. برای تلاش دوباره روی $t(common.approve) ضربه بزنید.", - "scheduledWakes": "{{time}} بیدار می‌شود", "checked": "آخرین بررسی", "lastKnown": "آخرین وضعیت شناخته‌شده", "awaitingUpdate": "در انتظار به‌روزرسانی", diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index a00203a2f4..3ef3215f89 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Ota live-aktiviteetit käyttöön asetuksissa, niin näet aktiiviset agentit lukitusnäytöllä.", "openSession": "Avaa", "approveFailed": "Hyväksyminen ei onnistunut. Yritä uudelleen napauttamalla $t(common.approve).", - "scheduledWakes": "herää {{time}}", "checked": "Tarkistettu", "lastKnown": "Viimeisin tiedossa oleva", "awaitingUpdate": "Odottaa päivitystä", diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 17e5b3fe39..14eba62479 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Paganahin ang mga live na aktibidad sa mga setting para makita ang mga aktibong agent sa naka-lock na screen.", "openSession": "Buksan", "approveFailed": "Hindi maaprubahan. I-tap ang $t(common.approve) para subukang muli.", - "scheduledWakes": "gigising {{time}}", "checked": "Nakumpirma", "lastKnown": "Huling nalaman", "awaitingUpdate": "Naghihintay ng update", diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index c7e921966b..e99f25bdae 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Activez les activités en direct dans les paramètres pour voir les agents actifs sur l'écran verrouillé.", "openSession": "Ouvrir", "approveFailed": "Impossible d'approuver. Touchez $t(common.approve) pour réessayer.", - "scheduledWakes": "se réveille {{time}}", "checked": "Vérifié", "lastKnown": "Dernier état connu", "awaitingUpdate": "En attente de mise à jour", diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index eed441af53..9a1edd7d2a 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3279,7 +3279,6 @@ "activityKitDisabledBody": "Cumasaigh gníomhaíochtaí beo sna socruithe chun gníomhairí gníomhacha a fheiceáil ar an scáileán glasála.", "openSession": "Oscail", "approveFailed": "Níorbh fhéidir ceadú. Tapáil $t(common.approve) chun triail eile a bhaint as.", - "scheduledWakes": "dúisíonn {{time}}", "checked": "Seiceáilte", "lastKnown": "Stádas deireanach ar eolas", "awaitingUpdate": "Ag fanacht le nuashonrú", diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index d53e53fe11..6b7b260ade 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Activa as actividades en directo nos axustes para ver os axentes activos na pantalla de bloqueo.", "openSession": "Abrir", "approveFailed": "Non se puido aprobar. Toca $t(common.approve) para tentalo de novo.", - "scheduledWakes": "esperta {{time}}", "checked": "Comprobado", "lastKnown": "Último estado coñecido", "awaitingUpdate": "Agardando actualización", diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 0ecec39903..edbcc74141 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "લૉક સ્ક્રીન પર સક્રિય એજન્ટો જોવા માટે સેટિંગ્સમાં લાઇવ પ્રવૃત્તિઓ ચાલુ કરો.", "openSession": "ખોલો", "approveFailed": "મંજૂર કરી શકાયું નહીં. ફરી પ્રયાસ કરવા $t(common.approve) પર ટૅપ કરો.", - "scheduledWakes": "{{time}} જાગશે", "checked": "ચકાસ્યું", "lastKnown": "છેલ્લે જાણીતું", "awaitingUpdate": "અપડેટની રાહમાં", diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index b710619c52..c2e89ee0da 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Kunna ayyukan kai tsaye a saituna don ganin wakilan da ke aiki a allon kulle.", "openSession": "Buɗe", "approveFailed": "An kasa amincewa. Danna $t(common.approve) don sake gwadawa.", - "scheduledWakes": "zai farka {{time}}", "checked": "An duba", "lastKnown": "Na ƙarshe da aka sani", "awaitingUpdate": "Ana jiran sabuntawa", diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index b34ecdd8d4..cbc7750328 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "הפעל פעילויות בזמן אמת בהגדרות כדי לראות סוכנים פעילים במסך הנעילה.", "openSession": "פתח", "approveFailed": "לא ניתן היה לאשר. הקש על $t(common.approve) כדי לנסות שוב.", - "scheduledWakes": "מתעורר {{time}}", "checked": "נבדק", "lastKnown": "מצב אחרון ידוע", "awaitingUpdate": "ממתין לעדכון", diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index fd7f9cb7d0..f7c0c665aa 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "लॉक स्क्रीन पर सक्रिय एजेंट देखने के लिए सेटिंग में लाइव ऐक्टिविटी चालू करें।", "openSession": "खोलें", "approveFailed": "स्वीकार नहीं किया जा सका। फिर से कोशिश करने के लिए $t(common.approve) पर टैप करें।", - "scheduledWakes": "{{time}} जागेगा", "checked": "जाँच की गई", "lastKnown": "अंतिम ज्ञात", "awaitingUpdate": "अपडेट का इंतज़ार", diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 6b1343a189..be3789ed36 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u postavkama za prikaz aktivnih agenata na zaključanom zaslonu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspjelo. Dodirni $t(common.approve) da pokušaš ponovno.", - "scheduledWakes": "budi se {{time}}", "checked": "Provjereno", "lastKnown": "Posljednje poznato", "awaitingUpdate": "Čeka ažuriranje", diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index a676bb9a8a..ecb90557bd 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Nan paramèt yo, aktive aktivite an dirèk yo pou wè ajan aktif yo sou ekran fèmen an.", "openSession": "Louvri", "approveFailed": "Nou pa t ka apwouve. Peze $t(common.approve) pou eseye ankò.", - "scheduledWakes": "leve {{time}}", "checked": "Verifye", "lastKnown": "Dènye eta koni", "awaitingUpdate": "Ap tann mizajou", diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index b7a94183ce..5be1ea709f 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Kapcsold be az Élő tevékenységek funkciót a Beállításokban, hogy az aktív ügynökök megjelenjenek a zárolási képernyőn.", "openSession": "Megnyitás", "approveFailed": "Nem sikerült jóváhagyni. Az újrapróbáláshoz koppints a $t(common.approve) elemre.", - "scheduledWakes": "{{time}} felébred", "checked": "Ellenőrizve", "lastKnown": "Utolsó ismert", "awaitingUpdate": "Frissítésre vár", diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index bb2730d35b..15b3cb436b 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Կարգավորումներում միացրու ընթացիկ գործողությունների ցուցադրումը՝ ակտիվ գործակալներին կողպման էկրանին տեսնելու համար։", "openSession": "Բացիր", "approveFailed": "Չհաջողվեց հաստատել։ Կրկին փորձելու համար հպեք $t(common.approve)-ին։", - "scheduledWakes": "արթնանում է {{time}}", "checked": "Ստուգված", "lastKnown": "Վերջին հայտնի", "awaitingUpdate": "Սպասում է թարմացման", diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index 428662fe34..e001a9534b 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Aktifkan Aktivitas Langsung di pengaturan untuk melihat agen aktif di layar terkunci.", "openSession": "Buka", "approveFailed": "Tidak dapat menyetujui. Ketuk $t(common.approve) untuk mencoba lagi.", - "scheduledWakes": "bangun {{time}}", "checked": "Diperiksa", "lastKnown": "Terakhir diketahui", "awaitingUpdate": "Menunggu pembaruan", diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index a46128a8dd..22aa97fe20 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Gbanye ihe omume na-emelite ozugbo n'ime ntọala ka ị hụ ndị nnọchi anya na-arụ ọrụ n'ihuenyo mkpọchi.", "openSession": "Meghe", "approveFailed": "Enweghị ike ịkwado. Pịa $t(common.approve) ka ị nwaa ọzọ.", - "scheduledWakes": "ga-eteta {{time}}", "checked": "Enyochara", "lastKnown": "Nke ikpeazụ a maara", "awaitingUpdate": "Na-eche mmelite", diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index a912714fd4..b753338860 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Kveiktu á rauntímavirkni í stillingum til að sjá virka fulltrúa á lásskjánum.", "openSession": "Opna", "approveFailed": "Ekki tókst að samþykkja. Ýttu á $t(common.approve) til að reyna aftur.", - "scheduledWakes": "vaknar {{time}}", "checked": "Staðfest", "lastKnown": "Síðast þekkta staða", "awaitingUpdate": "Bíður eftir uppfærslu", diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index 3302fc07c3..7f0ca3a3e4 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Attiva le attività in tempo reale nelle impostazioni per vedere gli agenti attivi nella schermata di blocco.", "openSession": "Apri", "approveFailed": "Impossibile approvare. Tocca $t(common.approve) per riprovare.", - "scheduledWakes": "si risveglia {{time}}", "checked": "Verificato", "lastKnown": "Ultimo stato noto", "awaitingUpdate": "In attesa di aggiornamento", diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index 1dcbaf642f..0eba4d1489 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ロック画面にアクティブなエージェントを表示するには、設定でライブアクティビティをオンにしてください。", "openSession": "開く", "approveFailed": "承認できませんでした。もう一度試すには$t(common.approve)をタップしてください。", - "scheduledWakes": "{{time}}に起動", "checked": "最終確認", "lastKnown": "最終記録", "awaitingUpdate": "更新待ち", diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 625e69e45a..e9156ac0ef 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "დაბლოკილ ეკრანზე აქტიური აგენტების სანახავად პარამეტრებში ჩართე მიმდინარე აქტივობები.", "openSession": "გახსენი", "approveFailed": "დამტკიცება ვერ მოხერხდა. ხელახლა საცდელად შეეხეთ $t(common.approve)-ს.", - "scheduledWakes": "{{time}} იღვიძებს", "checked": "შემოწმდა", "lastKnown": "ბოლო ცნობილი", "awaitingUpdate": "განახლებას ელის", diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index 4d305f43a1..611fdae82d 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Құлыптау экранында белсенді агенттерді көру үшін баптауларда тікелей эфирдегі әрекеттерді қосыңыз.", "openSession": "Ашыңыз", "approveFailed": "Мақұлдау мүмкін болмады. Қайталап көру үшін $t(common.approve) түртіңіз.", - "scheduledWakes": "{{time}} оянады", "checked": "Тексерілді", "lastKnown": "Соңғы белгілі күй", "awaitingUpdate": "Жаңарту күтілуде", diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index 71c5c31529..ae124c1b4e 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "បើកសកម្មភាពបន្តផ្ទាល់នៅក្នុងការកំណត់ ដើម្បីមើលភ្នាក់ងារសកម្មនៅលើអេក្រង់ចាក់សោ។", "openSession": "បើក", "approveFailed": "មិនអាចយល់ព្រមបានទេ។ ប៉ះ $t(common.approve) ដើម្បីព្យាយាមម្តងទៀត។", - "scheduledWakes": "ភ្ញាក់ {{time}}", "checked": "បានពិនិត្យ", "lastKnown": "ស្ថានភាពដែលដឹងចុងក្រោយ", "awaitingUpdate": "រង់ចាំការធ្វើបច្ចុប្បន្នភាព", diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 7ec6b0521b..9e68cb5e92 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ಲಾಕ್ ಪರದೆಯಲ್ಲಿ ಸಕ್ರಿಯ ಏಜೆಂಟ್‌ಗಳನ್ನು ನೋಡಲು ಸೆಟ್ಟಿಂಗ್‌ಗಳಲ್ಲಿ ಲೈವ್ ಚಟುವಟಿಕೆಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ.", "openSession": "ತೆರೆಯಿರಿ", "approveFailed": "ಅನುಮೋದಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಲು $t(common.approve) ಟ್ಯಾಪ್ ಮಾಡಿ.", - "scheduledWakes": "{{time}} ಎಚ್ಚರಗೊಳ್ಳುತ್ತದೆ", "checked": "ಪರಿಶೀಲಿಸಲಾಗಿದೆ", "lastKnown": "ಕೊನೆಯದಾಗಿ ತಿಳಿದ ಸ್ಥಿತಿ", "awaitingUpdate": "ನವೀಕರಣಕ್ಕಾಗಿ ಕಾಯುತ್ತಿದೆ", diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index d94841b3cb..53dae0e8d6 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "잠금 화면에서 활성 에이전트를 보려면 설정에서 실시간 현황을 켜세요.", "openSession": "열기", "approveFailed": "승인하지 못했습니다. 다시 시도하려면 $t(common.approve)을 탭하세요.", - "scheduledWakes": "{{time}}에 깨어남", "checked": "마지막 확인", "lastKnown": "마지막 알려진 상태", "awaitingUpdate": "업데이트 대기", diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 3ecd522bd6..d16b2b6ab8 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ເປີດກິດຈະກຳສົດໃນການຕັ້ງຄ່າ ເພື່ອເບິ່ງເອເຈນທີ່ກຳລັງເຮັດວຽກໃນໜ້າຈໍລັອກ.", "openSession": "ເປີດ", "approveFailed": "ບໍ່ສາມາດອະນຸມັດໄດ້. ແຕະ $t(common.approve) ເພື່ອລອງອີກຄັ້ງ.", - "scheduledWakes": "ຕື່ນ {{time}}", "checked": "ກວດສອບແລ້ວ", "lastKnown": "ສະຖານະຫຼ້າສຸດທີ່ຮູ້", "awaitingUpdate": "ລໍການອັບເດດ", diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index 62fb16cf14..2d8130db42 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Nustatymuose įjunk tiesiogines veiklas, kad užrakinimo ekrane matytum aktyvius agentus.", "openSession": "Atidaryti", "approveFailed": "Nepavyko patvirtinti. Bakstelėkite $t(common.approve), kad bandytumėte dar kartą.", - "scheduledWakes": "prabunda {{time}}", "checked": "Patikrinta", "lastKnown": "Paskutinė žinoma", "awaitingUpdate": "Laukiama atnaujinimo", diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index b736faff9e..ca9a0b25ad 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Iestatījumos ieslēdz tiešraides aktivitātes, lai bloķēšanas ekrānā redzētu aktīvos aģentus.", "openSession": "Atvērt", "approveFailed": "Neizdevās apstiprināt. Pieskaries $t(common.approve), lai mēģinātu vēlreiz.", - "scheduledWakes": "pamostas {{time}}", "checked": "Pārbaudīts", "lastKnown": "Pēdējais zināmais", "awaitingUpdate": "Gaida atjauninājumu", diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index b16db4fc28..b5d27b2b25 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Alefaso ao amin'ny fikirana ny hetsika mivantana mba hahitana ny mpanatanteraka mandeha eo amin'ny efijery mihidy.", "openSession": "Sokafy", "approveFailed": "Tsy nahomby ny fanekena. Tsindrio $t(common.approve) hanandramana indray.", - "scheduledWakes": "mifoha {{time}}", "checked": "Voamarina", "lastKnown": "Fantatra farany", "awaitingUpdate": "Miandry fanavaozana", diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index c1ec546d47..cdc924df5f 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Whakakāngia ngā mahi wā-tūturu i ngā tautuhinga kia kite ai i ngā māngai e mahi ana i te mata maukati.", "openSession": "Tuwhera", "approveFailed": "Kāore i taea te whakaae. Pāwhiria $t(common.approve) hei whakamātau anō.", - "scheduledWakes": "ka oho {{time}}", "checked": "Kua manatoko", "lastKnown": "Mōhiotanga whakamutunga", "awaitingUpdate": "E tatari ana ki te whakahou", diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index aea74fe923..9ccb5c7281 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Вклучи ги активностите во живо во поставките за да ги видиш активните агенти на заклучениот екран.", "openSession": "Отвори", "approveFailed": "Одобрувањето не успеа. Допри $t(common.approve) за да се обидеш повторно.", - "scheduledWakes": "се буди {{time}}", "checked": "Проверено", "lastKnown": "Последно познато", "awaitingUpdate": "Чека ажурирање", diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index d84c82fef4..2b705e18cd 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ലോക്ക് സ്ക്രീനിൽ സജീവ ഏജന്റുകളെ കാണാൻ ക്രമീകരണങ്ങളിൽ തത്സമയ പ്രവർത്തനങ്ങൾ പ്രവർത്തനക്ഷമമാക്കുക.", "openSession": "തുറക്കുക", "approveFailed": "അംഗീകരിക്കാനായില്ല. വീണ്ടും ശ്രമിക്കാൻ $t(common.approve) ടാപ്പ് ചെയ്യുക.", - "scheduledWakes": "{{time}} ഉണരും", "checked": "പരിശോധിച്ചത്", "lastKnown": "അവസാനം അറിയപ്പെട്ടത്", "awaitingUpdate": "അപ്‌ഡേറ്റിനായി കാത്തിരിക്കുന്നു", diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index d304b56873..21fcd548b0 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Түгжээтэй дэлгэц дээр идэвхтэй агентуудыг харахын тулд тохиргооноос шууд үйл ажиллагааг идэвхжүүлнэ үү.", "openSession": "Нээх", "approveFailed": "Зөвшөөрч чадсангүй. Дахин оролдохын тулд $t(common.approve)-г товшино уу.", - "scheduledWakes": "{{time}} сэрнэ", "checked": "Шалгасан", "lastKnown": "Сүүлд мэдэгдсэн", "awaitingUpdate": "Шинэчлэлт хүлээж байна", diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index 3d0baa1475..c6457cae94 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "लॉक स्क्रीनवर सक्रिय एजंट्स पाहण्यासाठी सेटिंग्जमध्ये लाइव्ह अॅक्टिव्हिटी चालू करा.", "openSession": "उघडा", "approveFailed": "मंजूर करता आले नाही. पुन्हा प्रयत्न करण्यासाठी $t(common.approve) वर टॅप करा.", - "scheduledWakes": "{{time}} जागेल", "checked": "तपासले", "lastKnown": "शेवटची ज्ञात स्थिती", "awaitingUpdate": "अपडेटची प्रतीक्षा", diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index b802a7032f..b78daa0b1a 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Dayakan Aktiviti Langsung dalam Tetapan untuk melihat ejen aktif pada Skrin Kunci.", "openSession": "Buka", "approveFailed": "Tidak dapat meluluskan. Ketik $t(common.approve) untuk mencuba semula.", - "scheduledWakes": "bangun {{time}}", "checked": "Disemak", "lastKnown": "Terakhir diketahui", "awaitingUpdate": "Menunggu kemas kini", diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index cdf2cc7575..256078f959 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3279,7 +3279,6 @@ "activityKitDisabledBody": "Attiva l-attivitajiet diretti fl-issettjar biex tara l-aġenti attivi fuq l-iskrin imsakkar.", "openSession": "Iftaħ", "approveFailed": "Ma setax jiġi approvat. Tektek $t(common.approve) biex terġa' tipprova.", - "scheduledWakes": "iqum {{time}}", "checked": "Iċċekkjat", "lastKnown": "L-aħħar magħruf", "awaitingUpdate": "Jistenna aġġornament", diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index 79600ba082..6ecc6778db 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "သော့ခတ်မျက်နှာပြင်တွင် လုပ်ဆောင်နေသော အေးဂျင့်များကို ကြည့်ရန် ဆက်တင်များ၌ တိုက်ရိုက်လုပ်ဆောင်မှုများကို ဖွင့်ပါ။", "openSession": "ဖွင့်ပါ", "approveFailed": "အတည်ပြု၍မရပါ။ ထပ်ကြိုးစားရန် $t(common.approve) ကို နှိပ်ပါ။", - "scheduledWakes": "{{time}} နိုးမည်", "checked": "စစ်ဆေးထားသည်", "lastKnown": "နောက်ဆုံး သိရှိထားသည်", "awaitingUpdate": "အပ်ဒိတ် စောင့်နေ", diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index 7d5a15aa0a..e607558755 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Slå på oppdateringer i sanntid i innstillingene for å se aktive agenter på låseskjermen.", "openSession": "Åpne", "approveFailed": "Kunne ikke godkjenne. Trykk på $t(common.approve) for å prøve igjen.", - "scheduledWakes": "våkner {{time}}", "checked": "Sjekket", "lastKnown": "Sist kjent", "awaitingUpdate": "Venter på oppdatering", diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index 2fffa72f70..f6a95fbaf1 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "लक स्क्रिनमा सक्रिय एजेन्टहरू हेर्न सेटिङ्समा प्रत्यक्ष गतिविधिहरू चालू गर्नुहोस्।", "openSession": "खोल्नुहोस्", "approveFailed": "स्वीकृति गर्न सकिएन। फेरि प्रयास गर्न $t(common.approve) मा ट्याप गर्नुहोस्।", - "scheduledWakes": "{{time}} उठ्नेछ", "checked": "जाँच गरिएको", "lastKnown": "पछिल्लो ज्ञात", "awaitingUpdate": "अपडेटको प्रतीक्षामा", diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index 0307a71066..1d4163f554 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Schakel liveactiviteiten in via de instellingen om actieve agents op het toegangsscherm te zien.", "openSession": "Openen", "approveFailed": "Goedkeuren is niet gelukt. Tik op $t(common.approve) om het opnieuw te proberen.", - "scheduledWakes": "wordt wakker {{time}}", "checked": "Gecontroleerd", "lastKnown": "Laatst bekend", "awaitingUpdate": "Wacht op update", diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index e79c201ea9..8102060798 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Eejentoota hojii irra jiran iskiriinii qulfii irratti ilaaluuf, qindaa'ina keessatti sochiiwwan yeroo ammaa hojii irra oolchi.", "openSession": "Bani", "approveFailed": "Raggaasisuun hin danda'amne. Deebi'anii yaaluuf $t(common.approve) tuqi.", - "scheduledWakes": "{{time}} dammaqaa", "checked": "Mirkanaa'e", "lastKnown": "Kan dhumarratti beekame", "awaitingUpdate": "Haaromsa eeggachaa jira", diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index f77fad7f0b..89504936f3 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ଲକ୍ ସ୍କ୍ରିନ୍‌ରେ ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ ଦେଖିବାକୁ ସେଟିଂସ୍‌ରେ ଲାଇଭ୍ କାର୍ଯ୍ୟକଳାପ ଚାଲୁ କରନ୍ତୁ।", "openSession": "ଖୋଲନ୍ତୁ", "approveFailed": "ଅନୁମୋଦନ କରିହେଲା ନାହିଁ। ପୁଣି ଚେଷ୍ଟା କରିବାକୁ $t(common.approve) ଟାପ୍ କରନ୍ତୁ।", - "scheduledWakes": "{{time}} ଉଠିବ", "checked": "ଯାଞ୍ଚ ହୋଇଛି", "lastKnown": "ଶେଷ ଜଣା ସ୍ଥିତି", "awaitingUpdate": "ଅପଡେଟ୍ ଅପେକ୍ଷାରେ", diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index c8e0e1fbab..c783fdf57c 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "ਲਾਕ ਸਕ੍ਰੀਨ 'ਤੇ ਸਰਗਰਮ ਏਜੰਟ ਵੇਖਣ ਲਈ ਸੈਟਿੰਗਾਂ ਵਿੱਚ ਲਾਈਵ ਗਤੀਵਿਧੀਆਂ ਚਾਲੂ ਕਰੋ।", "openSession": "ਖੋਲ੍ਹੋ", "approveFailed": "ਮਨਜ਼ੂਰ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰਨ ਲਈ $t(common.approve) 'ਤੇ ਟੈਪ ਕਰੋ।", - "scheduledWakes": "{{time}} ਜਾਗੇਗਾ", "checked": "ਜਾਂਚ ਕੀਤੀ", "lastKnown": "ਆਖਰੀ ਜਾਣੀ ਸਥਿਤੀ", "awaitingUpdate": "ਅੱਪਡੇਟ ਦੀ ਉਡੀਕ", diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index b58567a6fd..d61600081b 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Włącz aktywności na żywo w ustawieniach, aby widzieć aktywnych agentów na ekranie blokady.", "openSession": "Otwórz", "approveFailed": "Nie udało się zatwierdzić. Dotknij $t(common.approve), aby spróbować ponownie.", - "scheduledWakes": "budzi się {{time}}", "checked": "Sprawdzono", "lastKnown": "Ostatni znany stan", "awaitingUpdate": "Czeka na aktualizację", diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index 7f0c11cfec..ec4361e4c4 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "په قلف شوې پرده کې د فعالو اجنټانو د لیدلو لپاره په ترتیباتو کې روان فعالیتونه فعال کړئ.", "openSession": "پرانیستل", "approveFailed": "منظور نه شو. د بیا هڅې لپاره $t(common.approve) ته ټک ووهئ.", - "scheduledWakes": "{{time}} بیداریږي", "checked": "تایید شوی", "lastKnown": "وروستی معلوم حالت", "awaitingUpdate": "د تازه کېدو په تمه", diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index 6e3bba27eb..a2e51dcfc9 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Ative as Atividades ao Vivo nos Ajustes para ver os agentes ativos na tela bloqueada.", "openSession": "Abrir", "approveFailed": "Não foi possível aprovar. Toque em $t(common.approve) para tentar novamente.", - "scheduledWakes": "acorda {{time}}", "checked": "Verificado", "lastKnown": "Último status conhecido", "awaitingUpdate": "Aguardando atualização", diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index 4fb7845373..f80b1de534 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Ativa as atividades em direto nas definições para ver os agentes ativos no ecrã bloqueado.", "openSession": "Abrir", "approveFailed": "Não foi possível aprovar. Toque em $t(common.approve) para tentar novamente.", - "scheduledWakes": "acorda {{time}}", "checked": "Verificado", "lastKnown": "Último estado conhecido", "awaitingUpdate": "À espera de atualização", diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index 3dcdd7614e..63616701db 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Activează activitățile live din setări pentru a vedea agenții activi pe ecranul de blocare.", "openSession": "Deschide", "approveFailed": "Aprobarea nu a reușit. Atinge $t(common.approve) pentru a încerca din nou.", - "scheduledWakes": "se trezește {{time}}", "checked": "Verificat", "lastKnown": "Ultima stare cunoscută", "awaitingUpdate": "Așteaptă actualizarea", diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index 814cec3528..131cc1a2f2 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Включите «Эфир активности» в настройках, чтобы видеть активных агентов на экране блокировки.", "openSession": "Открыть", "approveFailed": "Не удалось одобрить. Нажмите $t(common.approve), чтобы повторить.", - "scheduledWakes": "просыпается {{time}}", "checked": "Проверено", "lastKnown": "Последний известный статус", "awaitingUpdate": "Ожидание обновления", diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index e9041ce1e4..c9f107d168 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "අගුළු තිරයේ සක්‍රීය නියෝජිතයන් බැලීමට සැකසුම් තුළ සජීවී ක්‍රියාකාරකම් සක්‍රීය කරන්න.", "openSession": "විවෘත කරන්න", "approveFailed": "අනුමත කිරීමට නොහැකි විය. නැවත උත්සාහ කිරීමට $t(common.approve) තට්ටු කරන්න.", - "scheduledWakes": "{{time}} අවදි වේ", "checked": "තහවුරු කළේ", "lastKnown": "අවසන් වරට දන්නා තත්ත්වය", "awaitingUpdate": "යාවත්කාලීනයක් එනතුරු", diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 2642852a81..59a5281c57 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Zapni v nastaveniach živé aktivity, aby sa aktívne agenty zobrazovali na zamknutej obrazovke.", "openSession": "Otvoriť", "approveFailed": "Schválenie sa nepodarilo. Klepnutím na $t(common.approve) to skús znova.", - "scheduledWakes": "prebudí sa {{time}}", "checked": "Overené", "lastKnown": "Posledný známy stav", "awaitingUpdate": "Čaká na aktualizáciu", diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index de81bfbead..0579f28984 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "V nastavitvah vklopi dejavnosti v živo, da se aktivni agenti prikažejo na zaklenjenem zaslonu.", "openSession": "Odpri", "approveFailed": "Odobritev ni uspela. Tapni $t(common.approve) za ponovni poskus.", - "scheduledWakes": "zbudi se {{time}}", "checked": "Preverjeno", "lastKnown": "Nazadnje znano", "awaitingUpdate": "Čaka na posodobitev", diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 593174cdc2..51d33b9b87 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Ka daar hawlaha tooska ah dejinta si aad wakiillada firfircoon ugu aragto shaashadda qufulka.", "openSession": "Fur", "approveFailed": "Lama oggolaan karin. Taabo $t(common.approve) si aad mar kale isku daydo.", - "scheduledWakes": "wuxuu soo toosaa {{time}}", "checked": "La hubiyay", "lastKnown": "Ugu dambayn la ogaa", "awaitingUpdate": "Cusboonaysiin sugaya", diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 68e43f9f97..47c4f7253e 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Aktivizo aktivitetet në kohë reale në cilësimet e pajisjes për të parë agjentët aktivë në ekranin e kyçjes.", "openSession": "Hap", "approveFailed": "Nuk u arrit të miratohej. Trokit $t(common.approve) për të provuar përsëri.", - "scheduledWakes": "zgjohet {{time}}", "checked": "Kontrolluar", "lastKnown": "Gjendja e fundit e njohur", "awaitingUpdate": "Në pritje të përditësimit", diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index ced3fb7270..882dce1dab 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -3197,7 +3197,6 @@ "activityKitDisabledBody": "Uključi aktivnosti uživo u podešavanjima da vidiš aktivne agente na zaključanom ekranu.", "openSession": "Otvori", "approveFailed": "Odobravanje nije uspelo. Dodirni $t(common.approve) da pokušaš ponovo.", - "scheduledWakes": "budi se {{time}}", "checked": "Provereno", "lastKnown": "Poslednje poznato", "awaitingUpdate": "Čeka ažuriranje", diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 6c7c9b0582..660b74ee55 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Aktivera liveaktiviteter i Inställningar för att se aktiva agenter på låsskärmen.", "openSession": "Öppna", "approveFailed": "Det gick inte att godkänna. Tryck på $t(common.approve) för att försöka igen.", - "scheduledWakes": "vaknar {{time}}", "checked": "Kontrollerad", "lastKnown": "Senast kända", "awaitingUpdate": "Väntar på uppdatering", diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index ce3e241ddf..596aeb501e 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Washa shughuli za wakati halisi kwenye mipangilio ili uone mawakala wanaofanya kazi kwenye skrini iliyofungwa.", "openSession": "Fungua", "approveFailed": "Imeshindikana kuidhinisha. Gusa $t(common.approve) ili ujaribu tena.", - "scheduledWakes": "itaamka {{time}}", "checked": "Imethibitishwa", "lastKnown": "Hali ya mwisho iliyojulikana", "awaitingUpdate": "Inasubiri sasisho", diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index d9b8b557a8..5e81c472b9 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "பூட்டுத் திரையில் செயலில் உள்ள ஏஜெண்டுகளைப் பார்க்க, அமைப்புகளில் நேரலைச் செயல்பாடுகளை இயக்கவும்.", "openSession": "திற", "approveFailed": "ஒப்புதல் அளிக்க முடியவில்லை. மீண்டும் முயற்சிக்க $t(common.approve) தட்டவும்.", - "scheduledWakes": "{{time}} விழிக்கும்", "checked": "சரிபார்க்கப்பட்டது", "lastKnown": "கடைசியாக அறியப்பட்ட நிலை", "awaitingUpdate": "புதுப்பிப்புக்குக் காத்திருக்கிறது", diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index af5912da04..adefb8715c 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "లాక్ స్క్రీన్‌పై పనిచేస్తున్న ఏజెంట్లను చూడటానికి సెట్టింగ్‌లలో లైవ్ యాక్టివిటీస్‌ను ఆన్ చేయండి.", "openSession": "తెరవండి", "approveFailed": "ఆమోదించలేకపోయాము. మళ్లీ ప్రయత్నించడానికి $t(common.approve) ని నొక్కండి.", - "scheduledWakes": "{{time}} మేల్కొంటుంది", "checked": "తనిఖీ చేయబడింది", "lastKnown": "చివరిగా తెలిసిన స్థితి", "awaitingUpdate": "అప్‌డేట్ కోసం వేచి ఉంది", diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index 52e5b66339..324e06cc8a 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "เปิดกิจกรรมสดในการตั้งค่าเพื่อดูเอเจนต์ที่กำลังทำงานบนหน้าจอล็อก", "openSession": "เปิด", "approveFailed": "อนุมัติไม่สำเร็จ แตะ $t(common.approve) เพื่อลองอีกครั้ง", - "scheduledWakes": "ตื่น {{time}}", "checked": "ตรวจสอบล่าสุด", "lastKnown": "สถานะล่าสุดที่ทราบ", "awaitingUpdate": "รออัปเดต", diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index 2c14ae3d9c..92e9367c73 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Etkin ajanları kilit ekranında görmek için ayarlardan Canlı Etkinlikler özelliğini aç.", "openSession": "Aç", "approveFailed": "Onaylanamadı. Tekrar denemek için $t(common.approve) öğesine dokun.", - "scheduledWakes": "{{time}} uyanır", "checked": "Kontrol edildi", "lastKnown": "Son bilinen", "awaitingUpdate": "Güncelleme bekleniyor", diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 349a4866f6..c06f1c4f0e 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -3238,7 +3238,6 @@ "activityKitDisabledBody": "Увімкніть «Дії наживо» в «Параметрах», щоб бачити активних агентів на замкненому екрані.", "openSession": "Відкрити", "approveFailed": "Не вдалося схвалити. Торкніться $t(common.approve), щоб спробувати ще раз.", - "scheduledWakes": "прокидається {{time}}", "checked": "Перевірено", "lastKnown": "Останній відомий стан", "awaitingUpdate": "Очікує оновлення", diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index 6faeb0d220..2817dba59c 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "لاک اسکرین پر فعال ایجنٹس دیکھنے کے لیے ترتیبات میں لائیو سرگرمیاں فعال کریں۔", "openSession": "کھولیں", "approveFailed": "منظور نہیں کیا جا سکا۔ دوبارہ کوشش کرنے کے لیے $t(common.approve) پر ٹیپ کریں۔", - "scheduledWakes": "{{time}} بیدار ہوگا", "checked": "آخری تصدیق", "lastKnown": "آخری معلوم حالت", "awaitingUpdate": "اپ ڈیٹ کا انتظار", diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index 50109a4775..5b752f0b48 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Qulflangan ekranda faol agentlarni ko'rish uchun sozlamalarda jonli faoliyatlarni yoqing.", "openSession": "Ochish", "approveFailed": "Tasdiqlab bo'lmadi. Qayta urinish uchun $t(common.approve) ga teging.", - "scheduledWakes": "{{time}} uyg'onadi", "checked": "Tekshirilgan", "lastKnown": "So‘nggi ma’lum holat", "awaitingUpdate": "Yangilanish kutilmoqda", diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 7207bf1d89..9960f7bf25 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Bật Hoạt động trực tiếp trong Cài đặt để xem các tác nhân đang hoạt động trên màn hình khóa.", "openSession": "Mở", "approveFailed": "Không thể phê duyệt. Nhấn $t(common.approve) để thử lại.", - "scheduledWakes": "thức dậy {{time}}", "checked": "Đã kiểm tra", "lastKnown": "Ghi nhận gần nhất", "awaitingUpdate": "Đang chờ cập nhật", diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index 8be8a9968c..b38dc7a762 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Tan àwọn ìgbòkègbodò ìsinsìnyí nínú àwọn ètò láti rí àwọn aṣojú tó ń ṣiṣẹ́ lórí ojú ìwé títìpa.", "openSession": "Ṣí", "approveFailed": "A kò lè fọwọ́ sí. Tẹ $t(common.approve) láti tún gbìyànjú.", - "scheduledWakes": "yoo ji {{time}}", "checked": "Ti ṣàyẹ̀wò", "lastKnown": "Tí a mọ̀ kẹ́yìn", "awaitingUpdate": "Ń dúró de ìmúdójúìwọ̀n", diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 4afe83d24a..ec13c5d46c 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "请在设置中开启实时活动,在锁定屏幕上查看运行中的智能体。", "openSession": "打开", "approveFailed": "无法批准。点按$t(common.approve)重试。", - "scheduledWakes": "{{time}}唤醒", "checked": "上次确认", "lastKnown": "上次已知状态", "awaitingUpdate": "等待更新", diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 689e362c66..002f71de34 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "請在「設定」中開啟「即時動態」,即可在鎖定畫面查看執行中的代理程式。", "openSession": "開啟", "approveFailed": "無法核准。點一下$t(common.approve)再試一次。", - "scheduledWakes": "{{time}}喚醒", "checked": "上次確認", "lastKnown": "最後已知", "awaitingUpdate": "等待更新", diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index a79b021fd8..490edb3e75 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -3156,7 +3156,6 @@ "activityKitDisabledBody": "Vula imisebenzi ebukhoma kuzilungiselelo ukuze ubone ama-ejenti asebenzayo esikrinini esikhiyiwe.", "openSession": "Vula", "approveFailed": "Asikwazanga ukugunyaza. Thepha $t(common.approve) ukuze uzame futhi.", - "scheduledWakes": "ivuka {{time}}", "checked": "Kuhloliwe", "lastKnown": "Okwagcina kwaziwa", "awaitingUpdate": "Kulinde isibuyekezo", diff --git a/apps/mobile/src/lib/glanceable/cross-platform-approve-parity.test.ts b/apps/mobile/src/lib/glanceable/cross-platform-approve-parity.test.ts index a053d94c5f..4701010e02 100644 --- a/apps/mobile/src/lib/glanceable/cross-platform-approve-parity.test.ts +++ b/apps/mobile/src/lib/glanceable/cross-platform-approve-parity.test.ts @@ -83,6 +83,7 @@ vi.mock('@/lib/hooks/use-language-preference', () => ({ vi.mock('@/glanceable-ios/ios-sink', () => ({ setGlanceableActionNotice: mocks.iosNotice, + setGlanceableActionApproving: vi.fn(), renderStoredSnapshotWithNotice: mocks.iosRender, })); vi.mock('@/glanceable-ios/active-agents-live-activity', () => ({ @@ -94,6 +95,7 @@ vi.mock('@/glanceable-ios/active-agents-live-activity', () => ({ vi.mock('@/glanceable-android/android-sink', () => ({ androidSink: { publish: vi.fn(), endImmediate: vi.fn(), startOrUpdate: vi.fn() }, setGlanceableActionNotice: mocks.androidNotice, + setGlanceableActionApproving: vi.fn(), renderStoredSnapshotWithNotice: mocks.androidRender, })); @@ -207,7 +209,6 @@ describe('activity Approve parity across platforms', () => { expect(mocks.recordWaitingAsk).toHaveBeenCalledWith(null); expect(harness.notice).not.toHaveBeenCalled(); - expect(harness.render).not.toHaveBeenCalled(); } ); @@ -218,7 +219,6 @@ describe('activity Approve parity across platforms', () => { expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(harness.notice).not.toHaveBeenCalled(); - expect(harness.render).not.toHaveBeenCalled(); }); it.each(PLATFORMS)( @@ -230,7 +230,6 @@ describe('activity Approve parity across platforms', () => { expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(harness.notice).not.toHaveBeenCalled(); - expect(harness.render).not.toHaveBeenCalled(); } ); @@ -248,7 +247,6 @@ describe('activity Approve parity across platforms', () => { expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.refreshGlanceableSnapshot).not.toHaveBeenCalled(); expect(harness.notice).not.toHaveBeenCalled(); - expect(harness.render).not.toHaveBeenCalled(); }); it.each(PLATFORMS)( diff --git a/apps/mobile/src/lib/glanceable/platform-parity.test.ts b/apps/mobile/src/lib/glanceable/platform-parity.test.ts index 3aeee2c24f..4ed4b6598e 100644 --- a/apps/mobile/src/lib/glanceable/platform-parity.test.ts +++ b/apps/mobile/src/lib/glanceable/platform-parity.test.ts @@ -70,7 +70,7 @@ function visible(snapshot: GlanceableAgentsSnapshot) { android.countLines, ], primaryLabel: [ios.primaryLabel, android.primaryLabel], - feedbackLine: [ios.actionLine, android.homeCopy?.detail ?? null], + feedback: [ios.actionFeedback, android.actionFeedback], actions: [ { approve: ios.actions.approve, newAgent: ios.actions.newAgent }, { approve: android.actions.approve, newAgent: android.actions.newAgent }, @@ -108,14 +108,14 @@ describe('iOS and Android widget props parity', () => { expect(seen.primaryLabel[1]).toBe(seen.primaryLabel[0]); }); - const FEEDBACK: { label: string; feedback: GlanceableActionFeedback; expected: string }[] = [ - { label: 'approving', feedback: 'approving', expected: 'Approving…' }, - { label: 'could not approve', feedback: 'couldNotApprove', expected: 'Could not approve' }, + const FEEDBACK: { label: string; feedback: GlanceableActionFeedback }[] = [ + { label: 'approving', feedback: 'approving' }, + { label: 'could not approve', feedback: 'couldNotApprove' }, ]; - it.each(FEEDBACK)('draws the same action feedback for $label', ({ feedback, expected }) => { + it.each(FEEDBACK)('carries the same action feedback for $label', ({ feedback }) => { setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: feedback }); const seen = visible(snapshotFor([{ status: 'busy' }, { status: 'idle' }])); - expect(seen.feedbackLine).toEqual([expected, expected]); + expect(seen.feedback).toEqual([feedback, feedback]); }); }); From 5c1a21c496f76f8858e07d4c3d0f9dc1b13dd64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:31:13 +0200 Subject: [PATCH 24/31] test(mobile): exercise a real session change behind the same visible ask --- apps/mobile/src/lib/glanceable/publisher.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index b1c0174dfe..6181364c79 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -1028,7 +1028,18 @@ describe('Home widget approval key retention', () => { approvalKey: KEY, }, }); - publisher.handleSessions([{ status: 'permission', title: 'Migrate billing' }], PUB_CTX); + publisher.handleSessions( + [{ id: 'ses_first', status: 'permission', title: 'Migrate billing' }], + PUB_CTX + ); + expect( + getHomeWidgetDataForSnapshot(lastSnapshot(calls, 'startOrUpdate')).details.approvalKey + ).toBe(KEY); + // A different session with the same visible title replaces the first one. + publisher.handleSessions( + [{ id: 'ses_second', status: 'permission', title: 'Migrate billing' }], + PUB_CTX + ); const published = lastSnapshot(calls, 'startOrUpdate'); expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBe(KEY); publisher.dispose(); From 4d61d3621563a58cb212534f8544e106d8824cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:31:14 +0200 Subject: [PATCH 25/31] chore(tools): let widget-process layouts format dates in-process --- tools/nativewind/check-classes.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/nativewind/check-classes.mjs b/tools/nativewind/check-classes.mjs index 2e0b8536a2..fa5b3d72f6 100644 --- a/tools/nativewind/check-classes.mjs +++ b/tools/nativewind/check-classes.mjs @@ -156,9 +156,19 @@ function checkRawText(relative, text) { */ const DIRECT_INTL = /new Intl\.\w+|\.toLocale(?:Date|Time|)String\s*\(/g; const DIRECT_INTL_DIR = join(ROOT, 'apps/mobile/src'); +// These layouts are stringified and run in the iOS widget extension, which +// cannot import @/lib/intl-cache; they normalise the locale tag themselves. +const WIDGET_PROCESS_LAYOUTS = new Set([ + 'apps/mobile/src/glanceable-ios/active-agents-widget.tsx', + 'apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx', +]); function checkDirectIntl(relative, absolute, text) { - if (!absolute.startsWith(DIRECT_INTL_DIR) || relative.endsWith('lib/intl-cache.ts')) { + if ( + !absolute.startsWith(DIRECT_INTL_DIR) || + relative.endsWith('lib/intl-cache.ts') || + WIDGET_PROCESS_LAYOUTS.has(relative.replace(/^\//, '')) + ) { return; } for (const [index, line] of text.split('\n').entries()) { From f1cb484bb45c7628fc6b6e348b6cb6b7bba18fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 01:40:33 +0200 Subject: [PATCH 26/31] test(mobile): expect the check time in the cold Live Activity state --- apps/mobile/src/lib/notifications.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index 8496deac8c..4df1f87fce 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -2743,6 +2743,8 @@ describe('cold iOS background delivery', () => { // No ask is recorded for this cold push, so the app-built state says so // explicitly; the layout gates Approve on this flag. canApprove: false, + // The header shows the check time, so the app-built state carries it. + updatedAt: '2026-01-02T00:00:00.000Z', }); expect(rows.has('scope-token')).toBe(true); }); From 8a6a863ca712188154f039b6a2fc7818fd18549f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 02:04:01 +0200 Subject: [PATCH 27/31] =?UTF-8?q?fix(mobile):=20show=20Approving=E2=80=A6?= =?UTF-8?q?=20on=20an=20Android=20approve=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/glanceable-android/action-notice.ts | 9 ++++++++- .../src/glanceable-android/android-sink.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/glanceable-android/action-notice.ts b/apps/mobile/src/glanceable-android/action-notice.ts index efffeadd25..2e8d1e960a 100644 --- a/apps/mobile/src/glanceable-android/action-notice.ts +++ b/apps/mobile/src/glanceable-android/action-notice.ts @@ -31,9 +31,16 @@ export function setGlanceableActionNotice(notice: string | null): void { noticeAskKey = notice === null ? null : askKey(getWaitingAsk()); } -/** Mark (or clear) the in-flight answer; it ends with the ask like the notice does. */ +/** + * Mark (or clear) the in-flight answer; it ends with the ask like the notice does. + * A new answer supersedes the last failure: the notice outranks "Approving…" on + * the card, so a retry would otherwise keep showing the old failure line. + */ export function setGlanceableActionApproving(value: boolean): void { approving = value; + if (value) { + setGlanceableActionNotice(null); + } } export function isActionApproving(): boolean { diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index 3f7a126ea8..179c82b9b7 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -21,6 +21,7 @@ import { getCurrentWidgetProps, handleAppStateActive, renderStoredSnapshotWithNotice, + setGlanceableActionApproving, setGlanceableActionNotice, } from './android-sink'; import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission'; @@ -1085,6 +1086,21 @@ describe('renderStoredSnapshotWithNotice', () => { }); }); + it('reads Approving… on a retry, not the previous failure line', async () => { + recordWaitingAsk(waitingAsk({ kiloSessionId: 'ses_retry' })); + mocks.native.setWidgetSnapshot(JSON.stringify(MIXED), 0); + setGlanceableActionNotice('Approval failed'); + + setGlanceableActionApproving(true); + await renderStoredSnapshotWithNotice(CTX); + + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: i18n.t('glanceable.approving'), + textIsError: false, + }); + }); + it('makes no native call when nothing is stored', async () => { setGlanceableActionNotice('Approval failed'); From 1de7a77830f10b106704fa82a6c02ddc4c094d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 02:04:04 +0200 Subject: [PATCH 28/31] test(mobile): republish after the session change behind the same ask --- apps/mobile/src/lib/glanceable/publisher.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index 6181364c79..62fada7e97 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -1018,7 +1018,8 @@ describe('Home widget approval key retention', () => { // deliberate: the press re-checks the digest, so a key that no longer matches // the visible request answers nothing and reports the stale outcome. const { sink, calls } = makeSink(); - const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + let now = NOW; + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => now }); rememberHomeWidgetData({ snapshot: snapshotFor([{ status: 'permission' }], NOW - 1000), details: { @@ -1032,15 +1033,19 @@ describe('Home widget approval key retention', () => { [{ id: 'ses_first', status: 'permission', title: 'Migrate billing' }], PUB_CTX ); - expect( - getHomeWidgetDataForSnapshot(lastSnapshot(calls, 'startOrUpdate')).details.approvalKey - ).toBe(KEY); + const first = lastSnapshot(calls, 'startOrUpdate'); + expect(getHomeWidgetDataForSnapshot(first).details.approvalKey).toBe(KEY); // A different session with the same visible title replaces the first one. + // The visible content is unchanged, so only a due renewal writes a frame: + // step past the margin so the second call really reaches the sink. + now += GLANCEABLE_RENEW_MARGIN_MS; publisher.handleSessions( [{ id: 'ses_second', status: 'permission', title: 'Migrate billing' }], PUB_CTX ); + expect(count(calls, 'startOrUpdate')).toBe(2); const published = lastSnapshot(calls, 'startOrUpdate'); + expect(published.revision).toBeGreaterThan(first.revision); expect(getHomeWidgetDataForSnapshot(published).details.approvalKey).toBe(KEY); publisher.dispose(); }); From ab5f8d9712c77ac7d91613bd22e5b773d32f3f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 02:04:04 +0200 Subject: [PATCH 29/31] test(notifications): drop the duplicate updatedAt content-state test --- .../notifications/src/lib/glanceable-delivery.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 46ab1ba23f..b6ff4557aa 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -2029,14 +2029,6 @@ describe('toGlanceableContentState', () => { ).toBe(0); }); - it('carries the confirmed time the card prints as "Checked <time>"', () => { - // A timestamp, like the wait and the wake: the card's header prints when - // the counts were confirmed, and an older card without it prints no time. - expect( - (JSON.parse(toGlanceableContentState(snapshot).props) as Record<string, unknown>).updatedAt - ).toBe(snapshot.updatedAt); - }); - it('never leaks snapshot bookkeeping, ids, or titles into the pushed content-state', () => { const contentState = toGlanceableContentState(snapshot); const raw = JSON.stringify(contentState); From 856cbf8df29fc591cf6dfe27a982477b03de6989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 02:04:05 +0200 Subject: [PATCH 30/31] fix(mobile): key the scheduled rows in the iOS Large widget --- .../glanceable-ios/active-agents-widget.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx index 742ea212d5..4441ee5e50 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-widget.tsx @@ -948,18 +948,25 @@ const layout = (props: WidgetProps, widgetEnvironment: WidgetEnvironment): React {shifted(gap(158, 19, 182, 13), scheduledLine(13, palette.muted))} {rule(205.5 - (182 + 0.241 * 13))} {shifted(232 - 0.952 * 12 - 206.5, sectionLabel(COPY.nextScheduled ?? 'Next scheduled'))} - {detailRows.map((row, index) => { - const detail = rowDetail(row, 12, 'long'); - return shifted( - index === 0 ? gap(232, 12, 258, 14) : gap(258, 14, 292, 14), - <HStack alignment="firstTextBaseline" spacing={8} key={index}> + {detailRows.map((row, index) => ( + <VStack + key={index} + alignment="leading" + spacing={0} + modifiers={[ + padding({ + top: index === 0 ? gap(232, 12, 258, 14) : gap(258, 14, 292, 14), + leading: 1, + }), + ]} + > + <HStack alignment="firstTextBaseline" spacing={8}> {titleRow(row, 14)} <Spacer minLength={0} /> - {detail} - </HStack>, - 1 - ); - })} + {rowDetail(row, 12, 'long')} + </HStack> + </VStack> + ))} </VStack> ); } else if (primaryKind === 'needsInput') { From 55039ceb12b2f125fdb2a837ff7f72648c1b0116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <iscekic@protonmail.com> Date: Sat, 10 Oct 2026 02:04:05 +0200 Subject: [PATCH 31/31] fix(mobile): clear actionFeedback on the iOS widget background rebuild --- .../home-widget-refresh/ios/HomeWidgetRefreshStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift index fd25c50cbe..3e43108a5b 100644 --- a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -274,7 +274,7 @@ enum HomeWidgetRefreshStore { props["needsInputSince"] = snapshot["needsInputSince"] props["scheduledAt"] = presentation["scheduledAt"] props["newestTitle"] = nil - props["actionLine"] = nil + props["actionFeedback"] = nil props["accessibilityLabel"] = counts.map { "\($0["count"] ?? 0) \(copy[$0["kind"] as? String ?? ""] ?? "")" }.joined(separator: ", ") // The rebuild owns the marker: strip every copy so only the carried // press below can put one back on the first entry while it is fresh.