Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
5c17ece
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
11 changes: 7 additions & 4 deletions apps/mobile/src/components/home/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { isTransitionalStatus } from '@/components/kiloclaw/status-badge';
import { ProfileAvatarButton } from '@/components/profile-avatar-button';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
import { badgeBucketForInstance } from '@/lib/badge-buckets';
import { useAgentSessions } from '@/lib/hooks/use-agent-sessions';
import { type ClawInstance, useAllKiloClawInstances } from '@/lib/hooks/use-instance-context';
import { useUnreadCounts } from '@/lib/hooks/use-unread-counts';
Expand Down Expand Up @@ -79,7 +80,7 @@ export function HomeScreen() {
isPending: instancesPending,
isError: instancesError,
} = useAllKiloClawInstances(pickListPollInterval);
const { byChannel: unreadByChannel } = useUnreadCounts();
const { byBadgeBucket: unreadByBadgeBucket } = useUnreadCounts();
const {
storedSessions,
activeSessions,
Expand Down Expand Up @@ -128,7 +129,7 @@ export function HomeScreen() {
{renderKiloClawSlot({
instances: instances ?? [],
instancesError,
unreadByChannel,
unreadByBadgeBucket,
})}

{renderSessionsOrPromo({
Expand All @@ -151,7 +152,7 @@ export function HomeScreen() {
function renderKiloClawSlot(params: {
instances: ClawInstance[];
instancesError: boolean;
unreadByChannel: Map<string, number>;
unreadByBadgeBucket: Map<string, number>;
}) {
if (params.instances.length > 0) {
return (
Expand All @@ -162,7 +163,9 @@ function renderKiloClawSlot(params: {
<KiloClawCard
key={instance.sandboxId}
instance={instance}
unreadCount={params.unreadByChannel.get(instance.sandboxId) ?? 0}
unreadCount={
params.unreadByBadgeBucket.get(badgeBucketForInstance(instance.sandboxId)) ?? 0
}
/>
))}
</View>
Expand Down
22 changes: 14 additions & 8 deletions apps/mobile/src/components/kiloclaw/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ import { ChatHeader, ChatShell } from '@/components/kiloclaw/chat-shell';
import { useBotOnlineStatus } from '@/components/kiloclaw/chat-hooks';
import { NotificationPrompt } from '@/components/kiloclaw/notification-prompt';
import { useStreamChatTheme } from '@/components/kiloclaw/chat-theme';
import { badgeBucketForInstance } from '@/lib/badge-buckets';
import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';
import { useStreamChatCredentials } from '@/lib/hooks/use-kiloclaw-queries';
import { setLastActiveInstance } from '@/lib/last-active-instance';
import { parseNotificationData, setActiveChatInstance } from '@/lib/notifications';
import {
getNotificationSandboxId,
parseNotificationData,
setActiveChatInstance,
} from '@/lib/notifications';
import { useTRPC } from '@/lib/trpc';

type KiloClawChatProps = {
Expand All @@ -28,7 +33,7 @@ type KiloClawChatProps = {
organizationId?: string | null;
};

type UnreadCountsData = { channelId: string; badgeCount: number }[];
type UnreadCountsData = { badgeBucket: string; badgeCount: number }[];

export function KiloClawChat({
instanceId,
Expand All @@ -46,11 +51,11 @@ export function KiloClawChat({

const { mutate: markChatRead } = useMutation(
trpc.user.markChatRead.mutationOptions({
onMutate: async ({ channelId }) => {
onMutate: async ({ badgeBucket }) => {
await queryClient.cancelQueries({ queryKey: unreadCountsKey });
const previous = queryClient.getQueryData<UnreadCountsData>(unreadCountsKey);
queryClient.setQueryData<UnreadCountsData>(unreadCountsKey, old =>
(old ?? []).filter(row => row.channelId !== channelId)
(old ?? []).filter(row => row.badgeBucket !== badgeBucket)
);
return { previous };
},
Expand All @@ -71,18 +76,19 @@ export function KiloClawChat({

useFocusEffect(
useCallback(() => {
const badgeBucket = badgeBucketForInstance(instanceId);
isFocusedRef.current = true;
setActiveChatInstance(instanceId);
setLastActiveInstance(instanceId);
markChatRead({ channelId: instanceId });
markChatRead({ badgeBucket });

// If a notification for this chat arrives while the screen is already open it is
// visually suppressed, but the DO still incremented the server-side count. Clear
// it immediately so the badge never drifts above 0 while the user is reading.
const subscription = Notifications.addNotificationReceivedListener(notification => {
const data = parseNotificationData(notification.request.content.data);
if (data?.type === 'chat' && data.instanceId === instanceId) {
markChatRead({ channelId: instanceId });
if (data && getNotificationSandboxId(data) === instanceId) {
markChatRead({ badgeBucket });
}
});

Expand All @@ -100,7 +106,7 @@ export function KiloClawChat({
// not an app-state one), so without this the badge stays stuck after backgrounding.
useEffect(() => {
if (isActive && isFocusedRef.current) {
markChatRead({ channelId: instanceId });
markChatRead({ badgeBucket: badgeBucketForInstance(instanceId) });
}
}, [isActive, instanceId, markChatRead]);

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/badge-buckets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const badgeBucketForInstance = (sandboxId: string) => `kiloclaw:${sandboxId}` as const;
11 changes: 5 additions & 6 deletions apps/mobile/src/lib/hooks/use-unread-counts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { useMemo } from 'react';
import { useTRPC } from '@/lib/trpc';

/**
* Fetches per-channel unread message counts for the current user and returns
* a Map keyed by channelId for O(1) lookup from dashboard cards. For kiloclaw
* chats, `channelId` equals the instance's `sandboxId`.
* Fetches unread message counts for the current user and returns a Map keyed
* by badge bucket for O(1) lookup from dashboard cards.
*
* Freshness is driven by invalidations, not polling:
* - Foreground chat push → invalidate (see `use-unread-counts-invalidation`).
Expand All @@ -21,13 +20,13 @@ export function useUnreadCounts() {
})
);

const byChannel = useMemo(() => {
const byBadgeBucket = useMemo(() => {
const map = new Map<string, number>();
for (const row of query.data ?? []) {
map.set(row.channelId, row.badgeCount);
map.set(row.badgeBucket, row.badgeCount);
}
return map;
}, [query.data]);

return { byChannel, query };
return { byBadgeBucket, query };
}
27 changes: 19 additions & 8 deletions apps/mobile/src/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ export function setActiveChatInstance(instanceId: string | null) {
activeChatInstanceId = instanceId;
}

// Keep in sync with data field in services/notifications/src/dos/NotificationChannelDO.ts
const notificationDataSchema = z.object({
type: z.literal('chat'),
instanceId: z.string().min(1),
});
const notificationDataSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('chat'),
instanceId: z.string().min(1),
}),
z.object({
type: z.literal('chat.message'),
sandboxId: z.string().min(1),
conversationId: z.string().min(1),
messageId: z.string().min(1),
}),
]);

type NotificationData = z.infer<typeof notificationDataSchema>;

Expand All @@ -40,6 +47,10 @@ export function parseNotificationData(data: unknown): NotificationData | null {
return parsed.success ? parsed.data : null;
}

export function getNotificationSandboxId(data: NotificationData): string {
return data.type === 'chat' ? data.instanceId : data.sandboxId;
}

const shown = {
shouldShowAlert: true,
shouldPlaySound: true,
Expand All @@ -63,7 +74,7 @@ export function setupNotificationHandler() {
const data = parseNotificationData(notification.request.content.data);

// Suppress only if the user is already viewing this exact chat
if (data && data.instanceId === activeChatInstanceId) {
if (data && getNotificationSandboxId(data) === activeChatInstanceId) {
return suppressed;
}

Expand All @@ -87,7 +98,7 @@ export function setupNotificationResponseHandler() {
const data = parseNotificationData(response.notification.request.content.data);

if (data) {
const path = `/(app)/chat/${data.instanceId}`;
const path = `/(app)/chat/${getNotificationSandboxId(data)}`;
// If the router is ready (has segments), navigate immediately.
// Otherwise store as pending for consumption after auth completes.
try {
Expand All @@ -109,7 +120,7 @@ export function checkInitialNotification(): void {
}
const data = parseNotificationData(response.notification.request.content.data);
if (data) {
pendingNotificationLink = `/(app)/chat/${data.instanceId}`;
pendingNotificationLink = `/(app)/chat/${getNotificationSandboxId(data)}`;
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/event-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { EventServiceClient, WebSocketAuthError, HandshakeTimeoutError } from './client';
export * from './presence';
export * from './kiloclaw-contexts';
export * from './schemas';
export type * from './types';
13 changes: 13 additions & 0 deletions packages/event-service/src/kiloclaw-contexts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Event-context path builders for kiloclaw event subscriptions.
*
* These are the contexts on which kilo-chat publishes events (message
* created, typing, etc.) and to which clients subscribe to receive
* those events. Distinct from `/presence/*` contexts, which signal
* whether the user is actively on a surface.
*/

export const kiloclawInstanceContext = (sandboxId: string) => `/kiloclaw/${sandboxId}` as const;

export const kiloclawConversationContext = (sandboxId: string, conversationId: string) =>
`/kiloclaw/${sandboxId}/${conversationId}` as const;
9 changes: 7 additions & 2 deletions packages/event-service/src/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
* and are subscribed by clients only when the user is *actively* on the
* matching surface. The notifications pipeline queries them via
* event-service.isUserInContext to skip pushes when the user is in-context.
*
* The kiloclaw-scoped variants compose `/presence` with the corresponding
* event-context paths so the segment shape is defined in exactly one place.
*/

import { kiloclawConversationContext, kiloclawInstanceContext } from './kiloclaw-contexts';

export type Platform = 'app' | 'web';

export const presenceContextForPlatform = (platform: Platform) => `/presence/${platform}` as const;

export const presenceContextForInstance = (sandboxId: string) =>
`/presence/kiloclaw/${sandboxId}` as const;
`/presence${kiloclawInstanceContext(sandboxId)}` as const;

export const presenceContextForConversation = (sandboxId: string, conversationId: string) =>
`/presence/kiloclaw/${sandboxId}/${conversationId}` as const;
`/presence${kiloclawConversationContext(sandboxId, conversationId)}` as const;
50 changes: 44 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions services/kilo-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"dependencies": {
"@kilocode/db": "workspace:*",
"@kilocode/encryption": "workspace:*",
"@kilocode/event-service": "workspace:*",
"@kilocode/kilo-chat": "workspace:*",
"@kilocode/notifications": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"drizzle-orm": "catalog:",
"hono": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:test';
import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
import { kiloclawConversationContext } from '@kilocode/event-service';
import type { AuthContext } from '../auth';
import { botAuthMiddleware } from '../auth-bot';
import { registerBotRoutes } from '../routes/bot-messages';
Expand Down Expand Up @@ -246,7 +247,7 @@ describe('POST /bot/v1/sandboxes/:sandboxId/conversations/:cid/conversation-stat
expect(pushEvent).toHaveBeenCalledTimes(1);
expect(pushEvent).toHaveBeenCalledWith(
userId,
`/kiloclaw/${sandboxId}/${conversationId}`,
kiloclawConversationContext(sandboxId, conversationId),
'conversation.status',
{
conversationId,
Expand Down
Loading