Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ BITBUCKET_CODE_REVIEW_WEBHOOK_BASE_URL=
WEBHOOK_AGENT_URL=http://localhost:8793
MODEL_EVAL_INGEST_URL=http://localhost:8798
SESSION_INGEST_WORKER_URL=
NOTIFICATIONS_WORKER_URL=
CODE_REVIEW_WORKER_URL=mock-url
CODE_REVIEW_WORKER_AUTH_TOKEN=mock-token
AUTO_TRIAGE_URL='mock-url'
Expand Down
1 change: 1 addition & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `WEBHOOK_AGENT_URL` - URL for the webhook agent worker. [SERVER]
- `MODEL_EVAL_INGEST_URL` - URL for model evaluation ingest worker. [SERVER]
- `SESSION_INGEST_WORKER_URL` - URL for the session ingest worker. [SERVER]
- `NOTIFICATIONS_WORKER_URL` - URL for the push-notifications worker internal dispatch endpoint. [SERVER]
- `NEXT_PUBLIC_SESSION_INGEST_WS_URL` - WebSocket URL for session ingest from the browser. [PUBLIC]
- `CODE_REVIEW_WORKER_URL` - URL for the code review worker. [SERVER]
- `CODE_REVIEW_WORKER_AUTH_TOKEN` - Auth token for the code review worker. `[SECRET]`
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/components/notifications-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- The dedicated Notifications screen composes the master
* OS-permission gate, the push-token registration flow, and 5 per-category toggles
* OS-permission gate, the push-token registration flow, and 7 per-category toggles
* with their optimistic-mutation + retry + loading patterns. Extracting subcomponents
* would re-encode the same hooks. The screen stays a single rendered surface. */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
Expand All @@ -14,7 +14,9 @@ import {
type LucideIcon,
MessageSquare,
RefreshCw,
ShieldAlert,
Sparkles,
Wallet,
} from 'lucide-react-native';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Linking, Pressable, Switch, View } from 'react-native';
Expand Down Expand Up @@ -114,6 +116,18 @@ const CATEGORY_META: readonly CategoryMeta[] = [
subtitle: 'instance ready/failed, scheduled actions',
icon: Sparkles,
},
{
key: 'balanceAlerts',
title: 'Balance alerts',
subtitle: 'low organization balance warnings',
icon: Wallet,
},
{
key: 'securityFindings',
title: 'Security findings',
subtitle: 'new findings and SLA reminders',
icon: ShieldAlert,
},
] as const;

type CategoryRowProps = Readonly<{
Expand Down
64 changes: 57 additions & 7 deletions apps/mobile/src/components/organization/credit-activity-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { formatDollars, fromMicrodollars } from '@kilocode/app-shared/utils';
import { useLocalSearchParams } from 'expo-router';
import { Receipt } from 'lucide-react-native';
import { type ReactNode } from 'react';
import { type ReactNode, useEffect } from 'react';
import { FlatList, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

Expand All @@ -16,6 +17,8 @@ import {
useOrgBoundary,
useOrgCreditTransactions,
} from '@/lib/hooks/use-organization-queries';
import { useOrganization } from '@/lib/organization-context';
import { reconcileOrgDeepLink } from '@/lib/org-deep-link';
import { cn, firstNonEmpty, formatDate, parseTimestamp } from '@/lib/utils';

function humanizeCategory(category: string): string {
Expand Down Expand Up @@ -66,17 +69,64 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }>
);
}

function firstSearchParam(value: string | string[] | undefined): string | undefined {
if (typeof value === 'string' && value.length > 0) {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) {
return value[0];
}
return undefined;
}

export function OrganizationCreditActivityScreen() {
const { organizationId, org, isResolving } = useOrgBoundary();
const query = useOrgCreditTransactions(organizationId);
const { org: orgParamRaw } = useLocalSearchParams<{ org?: string | string[] }>();
const orgParam = firstSearchParam(orgParamRaw);

const { organizationId: contextOrganizationId, isLoaded, setOrganizationId } = useOrganization();
// When `org` is present, resolve role/membership against the param only — never
// the pre-tap context selection — so wrong-org data cannot render.
const { org, orgs, isLoading: orgsLoading, isResolving, isError } = useOrgBoundary(orgParam);

// While the list is loading, pass `undefined` so reconcile marks `isResolving`.
// Once settled (success or error), pass the array (or empty) so a foreign param
// cannot fall through to the context org's transactions.
const reconcile = reconcileOrgDeepLink({
orgParam,
contextOrganizationId,
orgs: orgsLoading ? undefined : (orgs ?? []),
});

useEffect(() => {
// Wait for SecureStore hydration so the deep-link selection always wins over stored org.
if (isLoaded && reconcile.shouldPersistOverride && reconcile.effectiveOrganizationId != null) {
setOrganizationId(reconcile.effectiveOrganizationId);
}
}, [
isLoaded,
reconcile.shouldPersistOverride,
reconcile.effectiveOrganizationId,
setOrganizationId,
]);

// Key transactions only on the reconcile query id — never the pre-tap context
// org while a deep-link param is present and unvalidated/invalid.
const query = useOrgCreditTransactions(reconcile.queryOrganizationId);
const paddingBottom = useTabBarBottomPadding();

if (isResolving || organizationId == null || org == null) {
return <OrganizationBoundary title="Credit activity" />;
const showBoundary =
isResolving ||
reconcile.isResolving ||
isError ||
reconcile.queryOrganizationId == null ||
org == null;

if (showBoundary) {
return <OrganizationBoundary title="Credit activity" organizationIdOverride={orgParam} />;
}

const isLoading = query.isLoading;
const isError = query.isError && !query.data;
const isQueryError = query.isError && !query.data;
const transactions = query.data ?? [];

let body: ReactNode = null;
Expand All @@ -88,7 +138,7 @@ export function OrganizationCreditActivityScreen() {
<CreditRowSkeleton />
</Animated.View>
);
} else if (isError) {
} else if (isQueryError) {
body = (
<Animated.View entering={FadeIn.duration(200)} className="flex-1" style={{ paddingBottom }}>
<QueryError onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const PROFILE_HREF = '/(app)/(tabs)/(3_profile)' as Href;
type OrganizationBoundaryProps = Readonly<{
/** Full-screen callers pass a title for the `ScreenHeader`; sheets omit it and own no chrome. */
title?: string;
/** Explicit org id (e.g. deep-link `?org=`) replacing the persisted selection for this render. */
organizationIdOverride?: string;
}>;

/**
Expand All @@ -32,10 +34,14 @@ type OrganizationBoundaryProps = Readonly<{
* Calls `useOrgBoundary()` itself — cheap, context/query-cache backed — so
* callers only need to pass a `title` for full screens (sheets omit it).
*/
export function OrganizationBoundary({ title }: OrganizationBoundaryProps = {}) {
export function OrganizationBoundary({
title,
organizationIdOverride,
}: OrganizationBoundaryProps = {}) {
const router = useRouter();
const colors = useThemeColors();
const { organizationId, isResolving, isError, isFetching, refetch } = useOrgBoundary();
const { organizationId, isResolving, isError, isFetching, refetch } =
useOrgBoundary(organizationIdOverride);

let content: ReactNode = null;
if (isResolving) {
Expand Down
26 changes: 24 additions & 2 deletions apps/mobile/src/lib/hooks/agent-push-preference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ function fullRow(overrides: Partial<NotificationPreferences> = {}): Notification
agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE,
sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE,
kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE,
balanceAlerts: DEFAULT_NOTIFICATION_PREFERENCE,
securityFindings: DEFAULT_NOTIFICATION_PREFERENCE,
agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE,
...overrides,
};
Expand All @@ -46,13 +48,15 @@ describe('DEFAULT_NOTIFICATION_PREFERENCE', () => {
});

describe('NOTIFICATION_CATEGORY_KEYS', () => {
it('lists the 5 categories rendered on the dedicated screen', () => {
it('lists the categories rendered on the dedicated screen including balance and security', () => {
expect([...NOTIFICATION_CATEGORY_KEYS]).toEqual([
'chatMessages',
'agentAttention',
'agentUpdates',
'sessionStatus',
'kiloclawActivity',
'balanceAlerts',
'securityFindings',
]);
});
});
Expand Down Expand Up @@ -101,13 +105,17 @@ describe('readAgentPushPreference', () => {
agentUpdates: true,
sessionStatus: false,
kiloclawActivity: true,
balanceAlerts: false,
securityFindings: true,
agentPushEnabled: true,
});
expect(readAgentPushPreference(qc, key, 'chatMessages')).toBe(true);
expect(readAgentPushPreference(qc, key, 'agentAttention')).toBe(false);
expect(readAgentPushPreference(qc, key, 'agentUpdates')).toBe(true);
expect(readAgentPushPreference(qc, key, 'sessionStatus')).toBe(false);
expect(readAgentPushPreference(qc, key, 'kiloclawActivity')).toBe(true);
expect(readAgentPushPreference(qc, key, 'balanceAlerts')).toBe(false);
expect(readAgentPushPreference(qc, key, 'securityFindings')).toBe(true);
});

it('maps the legacy `agentPushEnabled` snapshot to the agentUpdates category', () => {
Expand All @@ -117,6 +125,10 @@ describe('readAgentPushPreference', () => {
// Non-agentUpdates categories fall back to the default when only the
// legacy field is present.
expect(readAgentPushPreference(qc, key, 'chatMessages')).toBe(DEFAULT_NOTIFICATION_PREFERENCE);
expect(readAgentPushPreference(qc, key, 'balanceAlerts')).toBe(DEFAULT_NOTIFICATION_PREFERENCE);
expect(readAgentPushPreference(qc, key, 'securityFindings')).toBe(
DEFAULT_NOTIFICATION_PREFERENCE
);
});

it('defaults to the agentUpdates category when no category is passed', () => {
Expand Down Expand Up @@ -146,14 +158,16 @@ describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)'
expect(context.previousWasLegacy).toBe(false);
});

it('flips a non-agentUpdates category in a real 6-key snapshot without corrupting the other keys', async () => {
it('flips a non-agentUpdates category in a real full snapshot without corrupting the other keys', async () => {
const qc = makeQueryClient();
const original = {
chatMessages: true,
agentAttention: true,
agentUpdates: true,
sessionStatus: false,
kiloclawActivity: true,
balanceAlerts: true,
securityFindings: true,
agentPushEnabled: true,
} as const satisfies NotificationPreferences;
qc.setQueryData(key, original);
Expand All @@ -171,6 +185,8 @@ describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)'
expect(after.agentAttention).toBe(true);
expect(after.agentUpdates).toBe(true);
expect(after.kiloclawActivity).toBe(true);
expect(after.balanceAlerts).toBe(true);
expect(after.securityFindings).toBe(true);
expect(after.agentPushEnabled).toBe(true);

expect(context.previous).toEqual(original);
Expand Down Expand Up @@ -211,6 +227,8 @@ describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)'
// consistently while the mutation is in flight.
expect(context.previous).toBeUndefined();
expect(readRow(qc).agentUpdates).toBe(false);
expect(readRow(qc).balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE);
expect(readRow(qc).securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE);

rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context });
// No prior snapshot => cache restored to the absent state, not a fabricated true.
Expand Down Expand Up @@ -251,6 +269,8 @@ describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)'
const after = readRow(qc);
expect(after.agentAttention).toBe(false);
expect(after.agentUpdates).toBe(true);
expect(after.balanceAlerts).toBe(DEFAULT_NOTIFICATION_PREFERENCE);
expect(after.securityFindings).toBe(DEFAULT_NOTIFICATION_PREFERENCE);
expect(context.previous).toBe(legacy);
expect(context.previousWasLegacy).toBe(true);

Expand All @@ -277,6 +297,8 @@ describe('per-category flip flow (each category in turn)', () => {
{ category: 'agentUpdates', next: false },
{ category: 'sessionStatus', next: true },
{ category: 'kiloclawActivity', next: false },
{ category: 'balanceAlerts', next: false },
{ category: 'securityFindings', next: true },
];

for (const { category, next } of scenarios) {
Expand Down
51 changes: 26 additions & 25 deletions apps/mobile/src/lib/hooks/agent-push-preference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { type QueryClient } from '@tanstack/react-query';

/**
* Pure logic for the per-user notification-preference settings (S3 — the
* dedicated Notifications screen with 5 category toggles + master gate).
* dedicated Notifications screen with category toggles + master gate).
*
* The preferences are per-user, cross-device, and server-resolved: a successful
* query with no row means every category is enabled (default ON). The same row
* also exposes a `agentUpdates` value that mirrors the legacy `agentPushEnabled`
* column, so this module accepts any one of the six keys when applying a
* column, so this module accepts any one of the category keys when applying a
* per-category optimistic flip.
*/
export const DEFAULT_NOTIFICATION_PREFERENCE = true as const;
Expand All @@ -19,6 +19,8 @@ export const NOTIFICATION_CATEGORY_KEYS = [
'agentUpdates',
'sessionStatus',
'kiloclawActivity',
'balanceAlerts',
'securityFindings',
] as const;

export type NotificationCategoryKey = (typeof NOTIFICATION_CATEGORY_KEYS)[number];
Expand All @@ -30,6 +32,8 @@ export type NotificationPreferences = Readonly<{
agentUpdates: boolean;
sessionStatus: boolean;
kiloclawActivity: boolean;
balanceAlerts: boolean;
securityFindings: boolean;
agentPushEnabled: boolean;
}>;

Expand Down Expand Up @@ -64,26 +68,31 @@ export function deriveShowEnableCta(notificationsEnabled: boolean): boolean {
}

/** Map the legacy single-key cache shape to the new per-category shape. */
function defaultPreferences(
overrides: Partial<NotificationPreferences> = {}
): NotificationPreferences {
return {
chatMessages: DEFAULT_NOTIFICATION_PREFERENCE,
agentAttention: DEFAULT_NOTIFICATION_PREFERENCE,
agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE,
sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE,
kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE,
balanceAlerts: DEFAULT_NOTIFICATION_PREFERENCE,
securityFindings: DEFAULT_NOTIFICATION_PREFERENCE,
agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE,
...overrides,
};
}

function readFromSnapshot(snapshot: NotificationPreferencesSnapshot): NotificationPreferences {
if (!snapshot) {
return {
chatMessages: DEFAULT_NOTIFICATION_PREFERENCE,
agentAttention: DEFAULT_NOTIFICATION_PREFERENCE,
agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE,
sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE,
kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE,
agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE,
};
return defaultPreferences();
}
if (isLegacySnapshot(snapshot)) {
return {
chatMessages: DEFAULT_NOTIFICATION_PREFERENCE,
agentAttention: DEFAULT_NOTIFICATION_PREFERENCE,
return defaultPreferences({
agentUpdates: snapshot.agentPushEnabled,
sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE,
kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE,
agentPushEnabled: snapshot.agentPushEnabled,
};
});
}
return snapshot;
}
Expand Down Expand Up @@ -131,16 +140,8 @@ export async function applyAgentPushOptimistic(args: OptimisticArgs): Promise<Op
if (rawPrevious === undefined) {
// Empty cache: seed the full row with the flipped value so the optimistic
// read is consistent across categories. The next refetch will replace it.
const seeded: NotificationPreferences = {
chatMessages: DEFAULT_NOTIFICATION_PREFERENCE,
agentAttention: DEFAULT_NOTIFICATION_PREFERENCE,
agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE,
sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE,
kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE,
agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE,
};
args.queryClient.setQueryData(args.queryKey, {
...seeded,
...defaultPreferences(),
[args.category]: args.next,
});
return { previous: undefined, previousWasLegacy: false };
Expand Down
Loading
Loading