Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
3ed7b94
refactor(db): rename channel_badge_counts to badge_counts (general pu…
iscekic Apr 29, 2026
e8d062c
feat(db): migration to rename badge_counts and reset rows
iscekic Apr 29, 2026
20b9b3b
feat(notifications): add badge-bucket key builders
iscekic Apr 29, 2026
1bb97c6
chore(notifications): add EVENT_SERVICE binding, drop STREAM_CHAT_API…
iscekic Apr 29, 2026
d87c0fb
chore(notifications): add vitest scaffold
iscekic Apr 29, 2026
2a621db
feat(notifications): rewrite NotificationChannelDO around dispatchPush
iscekic Apr 29, 2026
26fccf5
chore(notifications): drop orphan badgeBucketForInstance helper
iscekic Apr 29, 2026
7fad879
feat(notifications): add sendPushForConversation WorkerEntrypoint RPC
iscekic Apr 29, 2026
f6e1848
chore(notifications): delete Stream webhook route
iscekic Apr 29, 2026
3c7c82e
chore(notifications): type EVENT_SERVICE RPC and enable cloudflare:te…
iscekic Apr 29, 2026
227b90e
feat(event-service): add kiloclaw event-context helpers; migrate kilo…
iscekic Apr 29, 2026
87f0fab
feat(kilo-chat): add fetchSandboxLabel helper
iscekic Apr 29, 2026
822d327
chore(kilo-chat): add NOTIFICATIONS service binding
iscekic Apr 29, 2026
372f0a0
feat(kilo-chat): publish push on message.created via NOTIFICATIONS RPC
iscekic Apr 29, 2026
52fe8a6
chore(notifications): drop orphan stream-chat dep, refresh worker typ…
iscekic Apr 29, 2026
4e95291
fix(notifications): named entrypoint export, retry-safe badge, alarm-…
iscekic Apr 29, 2026
4faf0dd
fix(notifications): close two cleanup-alarm leaks
iscekic Apr 29, 2026
8d7b9d7
refactor(event-service): compose presence contexts from kiloclaw helpers
iscekic Apr 29, 2026
893b7f1
feat(web): add kiloChat.getToken tRPC procedure
iscekic Apr 29, 2026
a35c98c
refactor(web): use kiloclaw-context helpers for event subscriptions
iscekic Apr 29, 2026
a43585d
feat(web): lift EventServiceClient to global provider
iscekic Apr 29, 2026
e98f370
feat(web): add usePresenceSubscription primitive
iscekic Apr 29, 2026
6bfbf95
refactor(web): collapse kilo-chat event subscriptions into usePresenc…
iscekic Apr 29, 2026
832e2b7
feat(web): subscribe to /presence/web while tab is visible
iscekic Apr 29, 2026
99b52d5
feat(web): subscribe to /presence/kiloclaw/{sandboxId} on instance views
iscekic Apr 29, 2026
bdb99c6
refactor(web): extract useDocumentVisible primitive
iscekic Apr 29, 2026
405b185
feat(web): subscribe to conversation presence while tab visible
iscekic Apr 29, 2026
4429bdf
style(web): reflow useDocumentVisible useState init to one line
iscekic Apr 29, 2026
eca983e
refactor(web): tighten presence hook + kilo-chat router contract
iscekic Apr 29, 2026
7edca1a
fix(event-service): refcount subscribe/unsubscribe by context
iscekic Apr 29, 2026
67e0fe3
chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SE…
iscekic Apr 29, 2026
7b2d7fa
chore(mobile): add kilo-chat workspace deps
iscekic Apr 29, 2026
0735765
feat(mobile): add kilo-chat token getter with caching
iscekic Apr 29, 2026
4294250
feat(mobile): add useCurrentUserId from JWT sub
iscekic Apr 29, 2026
4081c29
feat(mobile): add KiloChatProvider
iscekic Apr 29, 2026
57448bd
feat(mobile): add useKiloChatClient and useEventServiceClient hooks
iscekic Apr 29, 2026
257f381
fix(mobile): fix lint errors in kilo-chat token getter
iscekic Apr 29, 2026
5844aaf
fix(mobile): fix lint errors in useCurrentUserId hook
iscekic Apr 29, 2026
825d1ac
fix(mobile): fix lint errors in useKiloChatClient hook
iscekic Apr 29, 2026
fe060dd
feat(mobile): mount KiloChatProvider in (app) layout
iscekic Apr 29, 2026
029c69a
fix(kilo-chat): assert non-null in base64urlEncode loop
iscekic Apr 29, 2026
f1eb38c
fix(mobile): share kilo-chat token cache + handle fetch errors
iscekic Apr 29, 2026
ecf29a1
fix(mobile): tie kilo-chat token cache to auth token, decode kiloUserId
iscekic Apr 30, 2026
d73befb
fix(mobile): read auth token at call time, not at hook render
iscekic Apr 30, 2026
b89c4d9
feat(mobile): add usePresenceSubscription primitive
iscekic Apr 30, 2026
87a74ed
feat(mobile): subscribe to /presence/app while app is active
iscekic Apr 30, 2026
7e20f1c
feat(mobile): add useInstancePresence hook
iscekic Apr 30, 2026
6d4e6c2
feat(mobile): add useConversationPresence hook
iscekic Apr 30, 2026
4898f43
fix(mobile): fix lint errors in presence hooks
iscekic Apr 30, 2026
1a6b241
feat(mobile): add useEventSubscription primitive
iscekic Apr 30, 2026
f0563e8
feat(mobile): add useInstanceEventSubscription
iscekic Apr 30, 2026
b939c3f
fix(mobile): apply curly/switch-case-braces lint rules to event hooks
iscekic Apr 30, 2026
d40b216
feat(kilo-chat-hooks): create shared package; extract useConversations
iscekic Apr 30, 2026
c829254
feat(kilo-chat-hooks): extract useMessages — base query + optimistic …
iscekic Apr 30, 2026
aa560af
feat(kilo-chat-hooks): useMessages adds edit/delete/react mutations
iscekic Apr 30, 2026
1dd065a
feat(kilo-chat-hooks): extract useMessageCacheUpdater into shared pac…
iscekic Apr 30, 2026
ad591d9
feat(mobile): wire shared kilo-chat-hooks + platform adapters
iscekic Apr 30, 2026
66713ba
fix(kilo-chat-hooks): centralize query keys; tighten event-subscripti…
iscekic Apr 30, 2026
ffeb4b6
fix(mobile): subscribe to conversation.* events on instance context
iscekic Apr 30, 2026
c259c95
chore(mobile): add @shopify/flash-list dependency
iscekic Apr 30, 2026
41bb565
chore(mobile): add EXPO_PUBLIC_KILO_CHAT_URL and EXPO_PUBLIC_EVENT_SE…
iscekic Apr 30, 2026
5fd8c69
feat(mobile): add EmptyConversationList
iscekic Apr 30, 2026
a4c1b99
feat(mobile): add ConversationHeader
iscekic Apr 30, 2026
e3b7d04
feat(mobile): add TypingIndicator placeholder
iscekic Apr 30, 2026
0f910d6
feat(mobile): add MessageInput
iscekic Apr 30, 2026
682e87b
feat(mobile): add MessageBubble
iscekic Apr 30, 2026
a0f8c84
feat(mobile): add MessageList
iscekic Apr 30, 2026
497613a
feat(mobile): add ConversationScreen
iscekic Apr 30, 2026
0d78ce7
feat(mobile): add ConversationListScreen
iscekic Apr 30, 2026
1bf1b34
fix(mobile): address review feedback on kilo-chat components
iscekic Apr 30, 2026
ce577c4
feat(mobile): add chat sandbox stack layout
iscekic Apr 30, 2026
a25a8df
feat(mobile): add conversation list route
iscekic Apr 30, 2026
4ed5cb8
feat(mobile): add conversation message route
iscekic Apr 30, 2026
381ad05
feat(mobile): wire chat deep links and active-conversation suppression
iscekic Apr 30, 2026
d4e68cc
fix(mobile): clear correct badge bucket on legacy chat foreground push
iscekic Apr 30, 2026
1ea1baf
chore(mobile): delete Stream-based chat components and routes
iscekic Apr 30, 2026
bc2af49
chore(mobile): remove useStreamChatCredentials hook
iscekic Apr 30, 2026
ce80381
chore: remove stream-chat deps and RN patch
iscekic Apr 30, 2026
24370b5
chore(web): remove Stream tRPC procedures
iscekic Apr 30, 2026
959c2bb
chore(web): delete Stream chat-credentials API route
iscekic Apr 30, 2026
365d72e
chore(web): strip Stream methods from kiloclaw clients
iscekic Apr 30, 2026
b0edac9
chore(web): replace ChatTab with redirect, drop Stream hooks
iscekic Apr 30, 2026
8db8959
chore(kiloclaw): delete src/stream-chat directory
iscekic Apr 30, 2026
8369c3e
chore(kiloclaw): remove Stream injections from instance DO and routes
iscekic Apr 30, 2026
fedb889
chore(kiloclaw): remove Stream from controller config-writer
iscekic Apr 30, 2026
dcd0271
chore(kiloclaw): drop STREAM_CHAT_* secret bindings
iscekic Apr 30, 2026
8fdbc1e
chore(web): remove residual Stream CSS and npm deps
iscekic Apr 30, 2026
687d703
chore(mobile): drop unused exports and deps flagged by knip
iscekic Apr 30, 2026
095de45
refactor(notifications): re-key DO per-user, move badge state to DO s…
iscekic Apr 30, 2026
4bcbef1
feat(notifications): JWT auth + badge HTTP routes
iscekic Apr 30, 2026
98bad80
fix(notifications): mount useWorkersLogger so auth setTags is effective
iscekic Apr 30, 2026
4f7a11e
refactor(web): drop badge_counts tRPC procedures
iscekic Apr 30, 2026
122d76f
feat(mobile): call notifications worker for badge counts
iscekic Apr 30, 2026
023d0fb
refactor(db): drop badge_counts table
iscekic Apr 30, 2026
5412ac3
chore(db): revert incidental NewSecurityAdvisorScan reorder
iscekic Apr 30, 2026
dec9db8
docs(notifications): update badge-bucket comment after table drop
iscekic Apr 30, 2026
a717ea4
chore: update env vars
iscekic Apr 30, 2026
c6ee11c
chore(mobile): drop expo public env prefix
iscekic Apr 30, 2026
6e2549d
chore(kilo-chat): remove redundant non-null assertion
iscekic Apr 30, 2026
af6fd77
fix(mobile): clear badge cache on mark read
iscekic Apr 30, 2026
7185c1b
Merge remote-tracking branch 'origin/feat/kilo-chat-migration-pr1' in…
iscekic Apr 30, 2026
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
5 changes: 3 additions & 2 deletions apps/mobile/.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ CLOUD_AGENT_WS_URL=wss://cloud-agent-next.kilosessions.ai
SESSION_INGEST_WS_URL=wss://ingest.kilosessions.ai
APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9
APPSFLYER_APP_ID=6761193135
EXPO_PUBLIC_KILO_CHAT_URL=https://kilo-chat.kilosessions.ai
EXPO_PUBLIC_EVENT_SERVICE_URL=wss://event-service.kilosessions.ai
KILO_CHAT_URL=https://chat.kiloapps.io
EVENT_SERVICE_URL=wss://events.kiloapps.io
NOTIFICATIONS_URL=https://notifications.kiloapps.io
80 changes: 65 additions & 15 deletions apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,78 @@
import { useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as Notifications from 'expo-notifications';
import { toast } from 'sonner-native';

import { badgeBucketForConversation } from '@kilocode/notifications';

import { useTRPC } from '@/lib/trpc';
import { NOTIFICATIONS_URL } from '@/lib/config';

import { useCurrentUserId } from './use-current-user-id';
import { useKiloChatTokenGetter } from './use-kilo-chat-token';

type BadgeBucket = { badgeBucket: string; badgeCount: number };
type MarkReadContext = {
previousBadges?: BadgeBucket[];
queryKey?: readonly ['badges', string];
};

export function useMarkRead() {
const trpc = useTRPC();
const mutation = useMutation(
trpc.user.markChatRead.mutationOptions({
onSuccess: result => {
if (typeof result.badgeCount === 'number') {
void Notifications.setBadgeCountAsync(result.badgeCount);
}
},
})
);
const queryClient = useQueryClient();
const userId = useCurrentUserId();
const getToken = useKiloChatTokenGetter();

const mutation = useMutation({
mutationFn: async (badgeBucket: string): Promise<{ badgeCount: number }> => {
const token = await getToken();
const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges/mark-read`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ badgeBucket }),
});
if (!response.ok) {
throw new Error(`Failed to mark badge read: ${response.status}`);
}
return (await response.json()) as { badgeCount: number };
},
onMutate: async (badgeBucket): Promise<MarkReadContext> => {
if (userId === null) {
return {};
}

const queryKey = ['badges', userId] as const;
await queryClient.cancelQueries({ queryKey });
const previousBadges = queryClient.getQueryData<BadgeBucket[]>(queryKey);

queryClient.setQueryData<BadgeBucket[]>(queryKey, badges =>
badges?.filter(row => row.badgeBucket !== badgeBucket)
);

return { previousBadges, queryKey };
},
onError: (error, _badgeBucket, context) => {
if (context?.queryKey && context.previousBadges) {
queryClient.setQueryData(context.queryKey, context.previousBadges);
}
toast.error(error.message);
},
onSuccess: result => {
Comment thread
iscekic marked this conversation as resolved.
if (typeof result.badgeCount === 'number') {
void Notifications.setBadgeCountAsync(result.badgeCount);
}
},
onSettled: () => {
if (userId !== null) {
void queryClient.invalidateQueries({ queryKey: ['badges', userId] });
}
},
});

return useCallback(
(sandboxId: string, conversationId: string) => {
mutation.mutate({
badgeBucket: badgeBucketForConversation(sandboxId, conversationId),
});
mutation.mutate(badgeBucketForConversation(sandboxId, conversationId));
},
[mutation]
);
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export const SESSION_INGEST_WS_URL: string = required('sessionIngestWsUrl');

export const KILO_CHAT_URL: string = required('kiloChatUrl');
export const EVENT_SERVICE_URL: string = required('eventServiceUrl');
export const NOTIFICATIONS_URL: string = required('notificationsUrl');
5 changes: 3 additions & 2 deletions apps/mobile/src/lib/env-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ENV_KEYS = {
sessionIngestWsUrl: 'SESSION_INGEST_WS_URL',
appsFlyerDevKey: 'APPSFLYER_DEV_KEY',
appsFlyerAppId: 'APPSFLYER_APP_ID',
kiloChatUrl: 'EXPO_PUBLIC_KILO_CHAT_URL',
eventServiceUrl: 'EXPO_PUBLIC_EVENT_SERVICE_URL',
kiloChatUrl: 'KILO_CHAT_URL',
eventServiceUrl: 'EVENT_SERVICE_URL',
notificationsUrl: 'NOTIFICATIONS_URL',
};
16 changes: 9 additions & 7 deletions apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';
import { AppState } from 'react-native';

import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id';
import { parseNotificationData } from '@/lib/notifications';
import { useTRPC } from '@/lib/trpc';

/**
* Keeps the `user.getUnreadCounts` cache in sync with real-time notification
* Keeps the `['badges', userId]` cache in sync with real-time notification
* traffic so per-instance badges on the dashboard reflect pushes received while
* the app is open or resumed from background.
*
Expand All @@ -20,14 +20,16 @@ import { useTRPC } from '@/lib/trpc';
*/
export function useUnreadCountsInvalidation() {
const queryClient = useQueryClient();
const trpc = useTRPC();
const userId = useCurrentUserId();

useEffect(() => {
// `trpc` is stable (memoized inside TRPCProvider) but `queryKey()` returns
// a fresh array on each call, so we resolve it inside each invalidation.
if (userId === null) {
return undefined;
}

const invalidate = () => {
void queryClient.invalidateQueries({
queryKey: trpc.user.getUnreadCounts.queryKey(),
queryKey: ['badges', userId],
});
};

Expand All @@ -48,5 +50,5 @@ export function useUnreadCountsInvalidation() {
received.remove();
appStateSubscription.remove();
};
}, [queryClient, trpc]);
}, [queryClient, userId]);
}
40 changes: 29 additions & 11 deletions apps/mobile/src/lib/hooks/use-unread-counts.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id';
import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token';
import { badgeBucketForInstance } from '@/lib/badge-buckets';
import { useTRPC } from '@/lib/trpc';
import { NOTIFICATIONS_URL } from '@/lib/config';

type Bucket = { badgeBucket: string; badgeCount: number };

/**
* Fetches unread message counts for the current user and returns a Map keyed by
* instance badge bucket for O(1) lookup from dashboard cards. Conversation
* buckets are summed into their parent instance bucket.
* Fetches unread message counts for the current user from the notifications
* worker and returns a Map keyed by instance badge bucket for O(1) lookup from
* dashboard cards. Conversation buckets are summed into their parent instance
* bucket.
*
* Freshness is driven by invalidations, not polling:
* - Foreground chat push → invalidate (see `use-unread-counts-invalidation`).
* - App returns to active → invalidate.
* - `markChatRead` optimistically clears the relevant row.
* - `useMarkRead` optimistically clears the relevant row.
*/
export function useUnreadCounts() {
const trpc = useTRPC();
const query = useQuery(
trpc.user.getUnreadCounts.queryOptions(undefined, {
staleTime: 30_000,
})
);
const userId = useCurrentUserId();
const getToken = useKiloChatTokenGetter();

const query = useQuery<Bucket[]>({
queryKey: ['badges', userId],
enabled: userId !== null,
staleTime: 30_000,
queryFn: async () => {
const token = await getToken();
const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch badges: ${response.status}`);
}
const body = (await response.json()) as { buckets: Bucket[] };
return body.buckets;
},
});

const byBadgeBucket = useMemo(() => {
const map = new Map<string, number>();
Expand Down
26 changes: 2 additions & 24 deletions apps/web/src/routers/user-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createCallerForUser } from '@/routers/test-utils';
import { db } from '@/lib/drizzle';
import { badge_counts, kilocode_users } from '@kilocode/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { kilocode_users } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';
import type { User } from '@kilocode/db/schema';

Expand Down Expand Up @@ -416,25 +416,3 @@ describe('session and API token reset mutations', () => {
expect(updated.api_token_pepper).toBe('api-pepper-before');
});
});

describe('user router - getUnreadCounts', () => {
it('does not return counts from other users', async () => {
const user = await insertTestUser({
google_user_email: `unread-counts-me-${crypto.randomUUID()}@example.com`,
});
const other = await insertTestUser({
google_user_email: `unread-counts-other-${crypto.randomUUID()}@example.com`,
});
await db.insert(badge_counts).values([
{ user_id: user.id, badge_bucket: 'sandbox-mine', badge_count: 4 },
{ user_id: other.id, badge_bucket: 'sandbox-theirs', badge_count: 9 },
]);

const caller = await createCallerForUser(user.id);
const result = await caller.user.getUnreadCounts();

expect(result).toEqual([{ badgeBucket: 'sandbox-mine', badgeCount: 4 }]);

await db.delete(badge_counts).where(inArray(badge_counts.user_id, [user.id, other.id]));
});
});
44 changes: 1 addition & 43 deletions apps/web/src/routers/user-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ import {
kiloclaw_instances,
kiloclaw_subscriptions,
user_push_tokens,
badge_counts,
} from '@kilocode/db/schema';
import { eq, and, isNull, inArray, sql, gt, gte, sum } from 'drizzle-orm';
import { eq, and, isNull, inArray, sql, gte } from 'drizzle-orm';
import crypto from 'crypto';
import { checkDiscordGuildMembership } from '@/lib/integrations/discord-guild-membership';
import { AuthProviderIdSchema } from '@/lib/auth/provider-metadata';
Expand Down Expand Up @@ -722,45 +721,4 @@ export const userRouter = createTRPCRouter({
.from(user_push_tokens)
.where(eq(user_push_tokens.user_id, ctx.user.id));
}),

// ─── Badge Counts ──────────────────────────────────────────────────

// Called by the mobile app when the user opens a chat. Resets the badge
// count for that channel to 0 and returns the new total across all
// channels, which the app applies as the OS badge count.
markChatRead: baseProcedure
.input(z.object({ badgeBucket: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await db
.update(badge_counts)
.set({ badge_count: 0 })
.where(
and(
eq(badge_counts.user_id, ctx.user.id),
eq(badge_counts.badge_bucket, input.badgeBucket)
)
);

const [totals] = await db
.select({ total: sum(badge_counts.badge_count) })
.from(badge_counts)
.where(eq(badge_counts.user_id, ctx.user.id));

return { badgeCount: Number(totals?.total ?? 0) };
}),

// Per-channel unread counts for showing badges on the mobile dashboard. For
// kiloclaw chats, `channelId` equals `sandbox_id` (see NotificationChannelDO).
// Destroyed instances are filtered implicitly on the client — the dashboard only
// renders cards for instances returned by `listAllInstances`, which already
// excludes destroyed ones.
getUnreadCounts: baseProcedure.query(async ({ ctx }) => {
return readDb
.select({
badgeBucket: badge_counts.badge_bucket,
badgeCount: badge_counts.badge_count,
})
.from(badge_counts)
.where(and(eq(badge_counts.user_id, ctx.user.id), gt(badge_counts.badge_count, 0)));
}),
});
1 change: 1 addition & 0 deletions packages/db/src/migrations/0108_drop_badge_counts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE "badge_counts" CASCADE;
Loading