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
100 changes: 98 additions & 2 deletions apps/mobile/src/components/notifications-card.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
/* eslint-disable max-lines -- S4 adds the Agent notifications row (query + optimistic mutation + state), pushing this card past the 300-line cap; an extracted subcomponent would re-encode the same hooks. The card stays a single rendered surface. */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bell, MessageSquare, RefreshCw } from 'lucide-react-native';
import { Bell, Bot, MessageSquare, RefreshCw } from 'lucide-react-native';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Linking, Pressable, Switch, View } from 'react-native';
import { toast } from 'sonner-native';

import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { useAuth } from '@/lib/auth/auth-context';
import {
applyAgentPushOptimistic,
deriveAgentPushEditable,
readAgentPushPreference,
rollbackAgentPushOptimistic,
} from '@/lib/hooks/agent-push-preference';
import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import * as Notifications from 'expo-notifications';
Expand Down Expand Up @@ -87,9 +94,22 @@ export function NotificationsCard() {
const serverRegistered =
deviceToken != null && (pushTokens ?? []).some(t => t.token === deviceToken);

const {
data: agentPushPreference,
isLoading: agentPushLoading,
isError: agentPushError,
refetch: refetchAgentPush,
} = useQuery({
...trpc.user.getNotificationPreferences.queryOptions(),
enabled: isAuthenticated,
});

const agentPushQueryKey = trpc.user.getNotificationPreferences.queryOptions().queryKey;

const invalidateAll = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: pushTokensQueryKey });
}, [queryClient, pushTokensQueryKey]);
void queryClient.invalidateQueries({ queryKey: agentPushQueryKey });
}, [queryClient, pushTokensQueryKey, agentPushQueryKey]);

const registerToken = useMutation(
trpc.user.registerPushToken.mutationOptions({
Expand Down Expand Up @@ -140,6 +160,42 @@ export function NotificationsCard() {

const chatTogglePending = registerToken.isPending || unregisterToken.isPending;

const setAgentPushPreference = useMutation(
trpc.user.setNotificationPreferences.mutationOptions({
onMutate: async ({ agentPushEnabled }) => {
const { previous } = await applyAgentPushOptimistic({
queryClient,
queryKey: agentPushQueryKey,
next: agentPushEnabled,
});
return { previous };
},
onError: (error, _vars, context) => {
rollbackAgentPushOptimistic({
queryClient,
queryKey: agentPushQueryKey,
previous: context?.previous,
});
toast.error(error.message);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: agentPushQueryKey });
},
})
);

const agentPushTogglePending = setAgentPushPreference.isPending;
const agentPushEditable = deriveAgentPushEditable({
hasData: agentPushPreference != null,
isPending: agentPushTogglePending,
});
// Reflects the optimistic value while a mutation is in flight; falls back to
// the default-ON semantics (no row ⇒ enabled) when the cache is empty.
const agentPushDisplayedValue = agentPushTogglePending
? readAgentPushPreference(queryClient, agentPushQueryKey)
: (agentPushPreference?.agentPushEnabled ??
readAgentPushPreference(queryClient, agentPushQueryKey));

// Re-check permission on foreground resume
const { isActive } = useAppLifecycle();
const wasActiveRef = useRef(isActive);
Expand Down Expand Up @@ -286,6 +342,46 @@ export function NotificationsCard() {
</>
)}
</View>

{/* Agent notifications — per-user preference; editable regardless of
this device's OS permission or push-token registration (the card's
existing rows above already convey those states). */}
<View className="min-h-11 flex-row items-center gap-3 rounded-lg bg-secondary p-3">
<Bot size={18} color={colors.secondaryForeground} />
<View className="flex-1">
<Text className="text-sm font-medium">Agent notifications</Text>
<Text variant="muted" className="mt-0.5 text-xs">
Pings and milestone alerts your agent sends mid-run
</Text>
</View>
{agentPushLoading && <Skeleton className="h-8 w-12 rounded-full" />}
{!agentPushLoading && agentPushError && (
<InlineRetry
label="Retry loading agent notification preference"
color={colors.destructive}
onPress={() => void refetchAgentPush()}
/>
)}
{!agentPushLoading && !agentPushError && (
<>
{agentPushTogglePending && (
<ActivityIndicator size="small" color={colors.mutedForeground} />
)}
<Switch
value={agentPushDisplayedValue}
disabled={!agentPushEditable}
accessibilityLabel="Agent notifications"
accessibilityState={{ disabled: !agentPushEditable, busy: agentPushTogglePending }}
onValueChange={value => {
if (!agentPushEditable) {
return;
}
setAgentPushPreference.mutate({ agentPushEnabled: value });
}}
/>
</>
)}
</View>
</View>
);
}
107 changes: 107 additions & 0 deletions apps/mobile/src/lib/hooks/agent-push-preference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { QueryClient } from '@tanstack/react-query';
import { describe, expect, it } from 'vitest';

import {
applyAgentPushOptimistic,
DEFAULT_AGENT_PUSH_ENABLED,
deriveAgentPushEditable,
readAgentPushPreference,
rollbackAgentPushOptimistic,
} from './agent-push-preference';

const key = ['user', 'getNotificationPreferences'] as const;

function makeQueryClient(): QueryClient {
return new QueryClient();
}

describe('DEFAULT_AGENT_PUSH_ENABLED', () => {
it('is true (default ON per plan §4.5)', () => {
expect(DEFAULT_AGENT_PUSH_ENABLED).toBe(true);
});
});

describe('deriveAgentPushEditable', () => {
it('is true when the preference query has data and no mutation is pending', () => {
expect(deriveAgentPushEditable({ hasData: true, isPending: false })).toBe(true);
});

it('is false while a mutation is pending even if the query has data', () => {
expect(deriveAgentPushEditable({ hasData: true, isPending: true })).toBe(false);
});

it('is false when the preference query has not loaded', () => {
expect(deriveAgentPushEditable({ hasData: false, isPending: false })).toBe(false);
});

it('is false while a mutation is pending without loaded data', () => {
expect(deriveAgentPushEditable({ hasData: false, isPending: true })).toBe(false);
});
});

describe('readAgentPushPreference', () => {
it('returns the default when the cache has no snapshot', () => {
const qc = makeQueryClient();
expect(readAgentPushPreference(qc, key)).toBe(DEFAULT_AGENT_PUSH_ENABLED);
});

it('returns the cached value when present (true)', () => {
const qc = makeQueryClient();
qc.setQueryData(key, { agentPushEnabled: true });
expect(readAgentPushPreference(qc, key)).toBe(true);
});

it('returns the cached value when present (false)', () => {
const qc = makeQueryClient();
qc.setQueryData(key, { agentPushEnabled: false });
expect(readAgentPushPreference(qc, key)).toBe(false);
});
});

describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic', () => {
it('writes the new value and returns the previous snapshot for rollback', async () => {
const qc = makeQueryClient();
qc.setQueryData(key, { agentPushEnabled: true });

const { previous } = await applyAgentPushOptimistic({
queryClient: qc,
queryKey: key,
next: false,
});

expect(previous).toEqual({ agentPushEnabled: true });
expect(qc.getQueryData(key)).toEqual({ agentPushEnabled: false });
});

it('rolls back to the previous snapshot on error', async () => {
const qc = makeQueryClient();
qc.setQueryData(key, { agentPushEnabled: true });

const { previous } = await applyAgentPushOptimistic({
queryClient: qc,
queryKey: key,
next: false,
});
expect(qc.getQueryData(key)).toEqual({ agentPushEnabled: false });

rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, previous });
expect(qc.getQueryData(key)).toEqual({ agentPushEnabled: true });
});

it('rolls back from a default-ON starting state (no prior cache entry)', async () => {
const qc = makeQueryClient();
expect(qc.getQueryData(key)).toBeUndefined();

const { previous } = await applyAgentPushOptimistic({
queryClient: qc,
queryKey: key,
next: false,
});
expect(previous).toBeUndefined();
expect(qc.getQueryData(key)).toEqual({ agentPushEnabled: false });

rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, previous });
// No prior snapshot => cache restored to the absent state, not a fabricated true.
expect(qc.getQueryData(key)).toBeUndefined();
});
});
63 changes: 63 additions & 0 deletions apps/mobile/src/lib/hooks/agent-push-preference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type QueryClient } from '@tanstack/react-query';

/**
* Pure logic for the "Agent notifications" settings row (S4).
*
* The preference is per-user, cross-device, and server-resolved: a successful
* query with no row means enabled (default ON), and the switch is editable
* whenever the preference query has resolved — independent of this device's
* OS permission or push-token registration (those are communicated by the
* card's existing permission + Chat-registration rows).
*/
export const DEFAULT_AGENT_PUSH_ENABLED = true as const;

type AgentPushPreferenceSnapshot = Readonly<{ agentPushEnabled: boolean }>;

/** Can the user flip the switch from the rendered row right now? */
export function deriveAgentPushEditable(args: { hasData: boolean; isPending: boolean }): boolean {
return args.hasData && !args.isPending;
}

/** Read the optimistic value currently in cache; falls back to the default. */
export function readAgentPushPreference(
queryClient: Pick<QueryClient, 'getQueryData'>,
queryKey: readonly unknown[]
): boolean {
const data = queryClient.getQueryData(queryKey) as AgentPushPreferenceSnapshot | undefined;
return data?.agentPushEnabled ?? DEFAULT_AGENT_PUSH_ENABLED;
}

/**
* Apply the optimistic flip. Returns the previous snapshot for rollback.
* Mirrors the existing registerToken / unregisterToken onMutate shape so the
* row's `useMutation` can spread the result into its context.
*/
export async function applyAgentPushOptimistic(args: {
queryClient: Pick<QueryClient, 'cancelQueries' | 'getQueryData' | 'setQueryData'>;
queryKey: readonly unknown[];
next: boolean;
}): Promise<{ previous: AgentPushPreferenceSnapshot | undefined }> {
await args.queryClient.cancelQueries({ queryKey: args.queryKey });
const previous = args.queryClient.getQueryData(args.queryKey) as
| AgentPushPreferenceSnapshot
| undefined;
args.queryClient.setQueryData(args.queryKey, { agentPushEnabled: args.next });
return { previous };
}

/**
* Restore the previous snapshot. When there was none, remove the cache entry
* so the next read falls back to the default (no row ⇒ enabled) instead of
* leaving the optimistic value in place.
*/
export function rollbackAgentPushOptimistic(args: {
queryClient: Pick<QueryClient, 'setQueryData' | 'removeQueries'>;
queryKey: readonly unknown[];
previous: AgentPushPreferenceSnapshot | undefined;
}): void {
if (args.previous) {
args.queryClient.setQueryData(args.queryKey, args.previous);
return;
}
args.queryClient.removeQueries({ queryKey: args.queryKey });
}
17 changes: 17 additions & 0 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
kiloclaw_scheduled_action_stages,
kiloclaw_scheduled_action_targets,
user_push_tokens,
user_notification_preferences,
security_advisor_scans,
credit_campaigns,
agent_environment_profiles,
Expand Down Expand Up @@ -3096,6 +3097,22 @@ describe('User', () => {
expect(tokens).toHaveLength(0);
});

it('should delete user_notification_preferences', async () => {
const user = await insertTestUser();
await db.insert(user_notification_preferences).values({
user_id: user.id,
agent_push_enabled: false,
});

await softDeleteUser(user.id);

const rows = await db
.select()
.from(user_notification_preferences)
.where(eq(user_notification_preferences.user_id, user.id));
expect(rows).toHaveLength(0);
});

it('should delete Coding Plan availability notification intents', async () => {
const user = await insertTestUser();
await db.insert(coding_plan_availability_intents).values({
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
kiloclaw_admin_audit_logs,
kiloclaw_cli_runs,
user_push_tokens,
user_notification_preferences,
contributor_champion_events,
contributor_champion_memberships,
contributor_champion_contributors,
Expand Down Expand Up @@ -878,7 +879,8 @@ export class SoftDeletePreconditionError extends Error {
* cloud_agent_code_reviews, review memory feedback/proposals,
* device_auth_requests, auto_top_up_configs,
* user_github_app_tokens, kiloclaw_instances/inbound_email_aliases/access_codes,
* user_period_cache, kilo_pass_scheduled_changes, coding_plan_availability_intents)
* user_period_cache, kilo_pass_scheduled_changes, coding_plan_availability_intents,
* user_notification_preferences)
* - kiloclaw_instances.admin_size_override JSONB (contains admin actorEmail
* + free-form reason; cleared on the deleted user's retained destroyed
* instances, AND on any other instances where this user was the admin
Expand Down Expand Up @@ -1265,6 +1267,9 @@ export async function softDeleteUser(userId: string) {
)
);
await tx.delete(user_push_tokens).where(eq(user_push_tokens.user_id, userId));
await tx
.delete(user_notification_preferences)
.where(eq(user_notification_preferences.user_id, userId));
await tx.delete(user_period_cache).where(eq(user_period_cache.kilo_user_id, userId));
await tx
.delete(kilo_pass_scheduled_changes)
Expand Down
Loading