diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 75cea06b0..a59df0c47 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -25,6 +25,7 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@kilocode/event-service": "workspace:*",
"@kilocode/kilo-chat": "workspace:*",
+ "@kilocode/kilo-chat-hooks": "workspace:*",
"@kilocode/notifications": "workspace:*",
"@kilocode/trpc": "workspace:*",
"@react-native-community/netinfo": "11.5.2",
diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx
index d47c26095..97bbe8abd 100644
--- a/apps/mobile/src/app/(app)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/_layout.tsx
@@ -1,5 +1,8 @@
+import { type ReactNode } from 'react';
+
import { Stack } from 'expo-router';
+import { useAppPresence } from '@/components/kilo-chat/hooks/use-app-presence';
import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
@@ -8,71 +11,78 @@ export default function AppLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
+
-
+ >
+
+
+
+
+
+
+
+
+
+
+
+
);
}
+
+function PresenceMount({ children }: { children: ReactNode }) {
+ useAppPresence();
+ return <>{children}>;
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts
new file mode 100644
index 000000000..f1066b1b0
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-active-and-focused.ts
@@ -0,0 +1,33 @@
+import { useCallback, useEffect, useState } from 'react';
+import { AppState } from 'react-native';
+import { useFocusEffect } from 'expo-router';
+
+/**
+ * True only when the app is in the foreground AND the current expo-router
+ * route is focused. Used to gate presence subscriptions so we hold them only
+ * while the user is genuinely on a surface.
+ */
+export function useAppActiveAndFocused(): boolean {
+ const [appActive, setAppActive] = useState(AppState.currentState === 'active');
+ const [focused, setFocused] = useState(false);
+
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', state => {
+ setAppActive(state === 'active');
+ });
+ return () => {
+ sub.remove();
+ };
+ }, []);
+
+ useFocusEffect(
+ useCallback(() => {
+ setFocused(true);
+ return () => {
+ setFocused(false);
+ };
+ }, [])
+ );
+
+ return appActive && focused;
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts
new file mode 100644
index 000000000..79389de2a
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-app-presence.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from 'react';
+import { AppState } from 'react-native';
+
+import { presenceContextForPlatform } from '@kilocode/event-service';
+
+import { usePresenceSubscription } from './use-presence-subscription';
+
+export function useAppPresence() {
+ const [active, setActive] = useState(AppState.currentState === 'active');
+
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', state => {
+ setActive(state === 'active');
+ });
+ return () => {
+ sub.remove();
+ };
+ }, []);
+
+ usePresenceSubscription(presenceContextForPlatform('app'), active);
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts
new file mode 100644
index 000000000..ed2b68dac
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversation-presence.ts
@@ -0,0 +1,15 @@
+import { presenceContextForConversation } from '@kilocode/event-service';
+
+import { useAppActiveAndFocused } from './use-app-active-and-focused';
+import { usePresenceSubscription } from './use-presence-subscription';
+
+export function useConversationPresence(
+ sandboxId: string | undefined,
+ conversationId: string | undefined
+) {
+ const activeAndFocused = useAppActiveAndFocused();
+ usePresenceSubscription(
+ sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null,
+ Boolean(sandboxId && conversationId) && activeAndFocused
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts
new file mode 100644
index 000000000..221f56e39
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-conversations.ts
@@ -0,0 +1,11 @@
+export {
+ useConversations,
+ useConversationDetail,
+ useCreateConversation,
+ useRenameConversation,
+ useLeaveConversation,
+ useMarkConversationRead,
+ updateConversationPages,
+ filterConversationPages,
+} from '@kilocode/kilo-chat-hooks';
+export type { ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks';
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts
new file mode 100644
index 000000000..e3b783bda
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-event-subscription.ts
@@ -0,0 +1,30 @@
+import { useEffect } from 'react';
+
+import { useEventServiceClient } from './use-kilo-chat-client';
+
+/**
+ * Subscribe to a single event-service event for one context. Call this hook
+ * once per event name when you need multiple events on the same context.
+ */
+export function useEventSubscription(
+ context: string | null,
+ eventName: string,
+ onEvent: (payload: unknown) => void
+) {
+ const eventService = useEventServiceClient();
+ useEffect(() => {
+ if (!context) {
+ return undefined;
+ }
+ eventService.subscribe([context]);
+ const off = eventService.on(eventName, (ctx, payload) => {
+ if (ctx === context) {
+ onEvent(payload);
+ }
+ });
+ return () => {
+ off();
+ eventService.unsubscribe([context]);
+ };
+ }, [eventService, context, eventName, onEvent]);
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts
new file mode 100644
index 000000000..8b6f57449
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-event-subscription.ts
@@ -0,0 +1,31 @@
+import { useCallback } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+
+import { kiloclawInstanceContext } from '@kilocode/event-service';
+import { botStatusKey, conversationsKey } from '@kilocode/kilo-chat-hooks';
+
+import { useEventSubscription } from './use-event-subscription';
+
+export function useInstanceEventSubscription(sandboxId: string | undefined) {
+ const qc = useQueryClient();
+ const ctx = sandboxId ? kiloclawInstanceContext(sandboxId) : null;
+
+ // conversation.* events are published on the instance context to keep the
+ // conversation list (last-activity, unread, title, membership) current while
+ // the user is on the list. message.* events fire on conversation contexts,
+ // not here.
+ const invalidateConversations = useCallback(() => {
+ void qc.invalidateQueries({ queryKey: conversationsKey(sandboxId ?? null) });
+ }, [qc, sandboxId]);
+
+ const invalidateBotStatus = useCallback(() => {
+ void qc.invalidateQueries({ queryKey: botStatusKey(sandboxId ?? null) });
+ }, [qc, sandboxId]);
+
+ useEventSubscription(ctx, 'conversation.created', invalidateConversations);
+ useEventSubscription(ctx, 'conversation.left', invalidateConversations);
+ useEventSubscription(ctx, 'conversation.renamed', invalidateConversations);
+ useEventSubscription(ctx, 'conversation.read', invalidateConversations);
+ useEventSubscription(ctx, 'conversation.activity', invalidateConversations);
+ useEventSubscription(ctx, 'bot.status', invalidateBotStatus);
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts
new file mode 100644
index 000000000..1c04d9eac
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-instance-presence.ts
@@ -0,0 +1,12 @@
+import { presenceContextForInstance } from '@kilocode/event-service';
+
+import { useAppActiveAndFocused } from './use-app-active-and-focused';
+import { usePresenceSubscription } from './use-presence-subscription';
+
+export function useInstancePresence(sandboxId: string | undefined) {
+ const activeAndFocused = useAppActiveAndFocused();
+ usePresenceSubscription(
+ sandboxId ? presenceContextForInstance(sandboxId) : null,
+ Boolean(sandboxId) && activeAndFocused
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
index 32e7402aa..cef730a6d 100644
--- a/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-kilo-chat-client.ts
@@ -1,20 +1 @@
-import { type EventServiceClient } from '@kilocode/event-service';
-import { type KiloChatClient } from '@kilocode/kilo-chat';
-
-import { useKiloChatContext } from '../kilo-chat-provider';
-
-/**
- * Returns the {@link KiloChatClient} instance from the nearest
- * {@link KiloChatProvider}. Throws if called outside a provider.
- */
-export function useKiloChatClient(): KiloChatClient {
- return useKiloChatContext().kiloChatClient;
-}
-
-/**
- * Returns the {@link EventServiceClient} instance from the nearest
- * {@link KiloChatProvider}. Throws if called outside a provider.
- */
-export function useEventServiceClient(): EventServiceClient {
- return useKiloChatContext().eventService;
-}
+export { useKiloChatClient, useEventServiceClient } from '@kilocode/kilo-chat-hooks';
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
new file mode 100644
index 000000000..4123d9697
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import * as Notifications from 'expo-notifications';
+
+import { badgeBucketForConversation } from '@kilocode/notifications';
+
+import { useTRPC } from '@/lib/trpc';
+
+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);
+ }
+ },
+ })
+ );
+
+ return useCallback(
+ (sandboxId: string, conversationId: string) => {
+ mutation.mutate({
+ badgeBucket: badgeBucketForConversation(sandboxId, conversationId),
+ });
+ },
+ [mutation]
+ );
+}
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts
new file mode 100644
index 000000000..2f5fb31ec
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-messages.ts
@@ -0,0 +1,11 @@
+export {
+ useMessages,
+ useSendMessage,
+ useEditMessage,
+ useDeleteMessage,
+ useAddReaction,
+ useRemoveReaction,
+ useExecuteAction,
+ useMessageCacheUpdater,
+} from '@kilocode/kilo-chat-hooks';
+export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks';
diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts
new file mode 100644
index 000000000..b3c860bb8
--- /dev/null
+++ b/apps/mobile/src/components/kilo-chat/hooks/use-presence-subscription.ts
@@ -0,0 +1,16 @@
+import { useEffect } from 'react';
+
+import { useEventServiceClient } from './use-kilo-chat-client';
+
+export function usePresenceSubscription(context: string | null, active: boolean) {
+ const eventService = useEventServiceClient();
+ useEffect(() => {
+ if (!active || !context) {
+ return undefined;
+ }
+ eventService.subscribe([context]);
+ return () => {
+ eventService.unsubscribe([context]);
+ };
+ }, [eventService, context, active]);
+}
diff --git a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
index f255f96a2..95ed84f64 100644
--- a/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
+++ b/apps/mobile/src/components/kilo-chat/kilo-chat-provider.tsx
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react';
import { EventServiceClient } from '@kilocode/event-service';
import { KiloChatClient } from '@kilocode/kilo-chat';
+import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks';
import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/config';
@@ -49,5 +50,13 @@ export function KiloChatProvider({ children }: KiloChatProviderProps) {
};
}, [value]);
- return {children};
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 15a3dd7aa..eb8afda60 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -45,6 +45,7 @@
"@kilocode/encryption": "workspace:*",
"@kilocode/event-service": "workspace:*",
"@kilocode/kilo-chat": "workspace:*",
+ "@kilocode/kilo-chat-hooks": "workspace:*",
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"@lottiefiles/dotlottie-react": "^0.17.15",
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx
index e8d373a23..2789beeb0 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx
+++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx
@@ -127,7 +127,9 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
// Bots are excluded inside the hook because their streaming uses
// message.created for every token chunk and relies on typing.stopped to
// signal stream completion.
- useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember);
+ useMessageCacheUpdater(kiloChatClient, sandboxId, conversationId, clearTypingForMember, () =>
+ toast.error("Couldn't reach the bot — please try again")
+ );
const sendTyping = useTypingSender(kiloChatClient, conversationId);
const markRead = useMarkConversationRead(kiloChatClient);
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts
index 1ed055352..c4ca080e6 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts
+++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useBotStatus.ts
@@ -3,10 +3,9 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { BotStatusRecord, KiloChatEventOf } from '@kilocode/kilo-chat';
+import { botStatusKey } from '@kilocode/kilo-chat-hooks';
import { useKiloChatContext } from '../components/kiloChatContext';
-const botKey = (sandboxId: string) => ['kilo-chat', 'bot-status', sandboxId] as const;
-
// Matches the bot's old heartbeat cadence so UI staleness thresholds keep
// working unchanged. Server-side dedupe absorbs multi-tab / multi-device
// polling so this stays at ~1 webhook per sandbox per interval.
@@ -20,7 +19,7 @@ export function useBotStatus(): BotStatusRecord | null {
if (!sandboxId) return;
return kiloChatClient.onBotStatus((_ctx: string, e: KiloChatEventOf<'bot.status'>) => {
if (e.sandboxId !== sandboxId) return;
- queryClient.setQueryData(botKey(sandboxId), prev =>
+ queryClient.setQueryData(botStatusKey(sandboxId), prev =>
prev && prev.at >= e.at ? prev : { online: e.online, at: e.at, updatedAt: e.at }
);
});
@@ -48,7 +47,7 @@ export function useBotStatus(): BotStatusRecord | null {
}, [kiloChatClient, sandboxId]);
const { data } = useQuery({
- queryKey: botKey(sandboxId ?? ''),
+ queryKey: botStatusKey(sandboxId),
queryFn: async () => {
if (!sandboxId) return null;
const res = await kiloChatClient.getBotStatus(sandboxId);
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts
index faaf06f75..221f56e39 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts
+++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useConversations.ts
@@ -1,124 +1,11 @@
-import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
-import type { InfiniteData } from '@tanstack/react-query';
-import type { KiloChatClient } from '@kilocode/kilo-chat';
-import type { CreateConversationRequest, ConversationListResponse } from '@kilocode/kilo-chat';
-
-const CONVERSATIONS_PAGE_SIZE = 50;
-
-export function useConversations(client: KiloChatClient, sandboxId: string | null) {
- return useInfiniteQuery({
- queryKey: ['kilo-chat', 'conversations', sandboxId],
- queryFn: ({ pageParam }) =>
- client.listConversations({
- sandboxId: sandboxId ?? undefined,
- limit: CONVERSATIONS_PAGE_SIZE,
- cursor: pageParam ?? undefined,
- }),
- initialPageParam: null as string | null,
- getNextPageParam: lastPage => lastPage.nextCursor,
- enabled: !!sandboxId,
- select: data => ({
- ...data,
- conversations: data.pages.flatMap(p => p.conversations),
- }),
- });
-}
-
-export function useConversationDetail(client: KiloChatClient, conversationId: string | null) {
- return useQuery({
- queryKey: ['kilo-chat', 'conversation', conversationId],
- queryFn: () => client.getConversation(conversationId ?? ''),
- enabled: !!conversationId,
- });
-}
-
-export function useCreateConversation(client: KiloChatClient) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (req: CreateConversationRequest) => client.createConversation(req),
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] });
- },
- });
-}
-
-export function useRenameConversation(client: KiloChatClient) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ conversationId, title }: { conversationId: string; title: string }) =>
- client.renameConversation(conversationId, { title }),
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] });
- },
- });
-}
-
-export function useLeaveConversation(client: KiloChatClient) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (conversationId: string) => client.leaveConversation(conversationId),
- onSuccess: (_data, conversationId) => {
- queryClient.removeQueries({ queryKey: ['kilo-chat', 'conversation', conversationId] });
- queryClient.removeQueries({ queryKey: ['kilo-chat', 'messages', conversationId] });
- void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] });
- },
- });
-}
-
-export type ConversationListInfiniteData = InfiniteData;
-
-export function updateConversationPages(
- data: ConversationListInfiniteData | undefined,
- mapItem: (
- c: ConversationListResponse['conversations'][number]
- ) => ConversationListResponse['conversations'][number]
-): ConversationListInfiniteData | undefined {
- if (!data) return data;
- return {
- ...data,
- pages: data.pages.map(page => ({
- ...page,
- conversations: page.conversations.map(mapItem),
- })),
- };
-}
-
-export function filterConversationPages(
- data: ConversationListInfiniteData | undefined,
- predicate: (c: ConversationListResponse['conversations'][number]) => boolean
-): ConversationListInfiniteData | undefined {
- if (!data) return data;
- return {
- ...data,
- pages: data.pages.map(page => ({
- ...page,
- conversations: page.conversations.filter(predicate),
- })),
- };
-}
-
-export function useMarkConversationRead(client: KiloChatClient) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (conversationId: string) => client.markConversationRead(conversationId),
- onMutate: conversationId => {
- // Optimistically set lastReadAt = now in all cached conversation lists
- const now = Date.now();
- const queryKey = ['kilo-chat', 'conversations'];
- const previous = queryClient.getQueriesData({ queryKey });
- queryClient.setQueriesData({ queryKey }, old =>
- updateConversationPages(old, c =>
- c.conversationId === conversationId ? { ...c, lastReadAt: now } : c
- )
- );
- return { previous };
- },
- onError: (_err, _variables, context) => {
- if (context?.previous) {
- for (const [key, data] of context.previous) {
- queryClient.setQueryData(key, data);
- }
- }
- },
- });
-}
+export {
+ useConversations,
+ useConversationDetail,
+ useCreateConversation,
+ useRenameConversation,
+ useLeaveConversation,
+ useMarkConversationRead,
+ updateConversationPages,
+ filterConversationPages,
+} from '@kilocode/kilo-chat-hooks';
+export type { ConversationListInfiniteData } from '@kilocode/kilo-chat-hooks';
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts
index de70a427c..2f5fb31ec 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts
+++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts
@@ -1,567 +1,11 @@
-import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
-import type { InfiniteData } from '@tanstack/react-query';
-import type { KiloChatClient } from '@kilocode/kilo-chat';
-import type {
- Message,
- ReactionSummary,
- CreateMessageRequest,
- EditMessageRequest,
- MessageCreatedEvent,
- MessageUpdatedEvent,
- MessageDeletedEvent,
- MessageDeliveryFailedEvent,
- ActionDeliveryFailedEvent,
- ReactionAddedEvent,
- ReactionRemovedEvent,
- ExecApprovalDecision,
-} from '@kilocode/kilo-chat';
-import { useEffect } from 'react';
-import { kiloclawConversationContext } from '@kilocode/event-service';
-import { toast } from 'sonner';
-
-const PAGE_SIZE = 50;
-
-function applyReactionAdded(
- reactions: ReactionSummary[],
- emoji: string,
- memberId: string
-): ReactionSummary[] {
- const existing = reactions.find(r => r.emoji === emoji);
- if (existing) {
- if (existing.memberIds.includes(memberId)) return reactions;
- return reactions.map(r =>
- r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r
- );
- }
- return [...reactions, { emoji, count: 1, memberIds: [memberId] }];
-}
-
-function applyReactionRemoved(
- reactions: ReactionSummary[],
- emoji: string,
- memberId: string
-): ReactionSummary[] {
- return reactions
- .map(r => {
- if (r.emoji !== emoji) return r;
- const memberIds = r.memberIds.filter(id => id !== memberId);
- return { ...r, count: memberIds.length, memberIds };
- })
- .filter(r => r.count > 0);
-}
-
-/**
- * Splice a snapshotted message back into the current cache state. If the
- * message no longer exists in any page (e.g. a concurrent delete event), the
- * cache is left unchanged so we do not resurrect it.
- */
-function restoreMessageInCache(
- queryClient: ReturnType,
- queryKey: readonly unknown[],
- snapshot: Message
-): void {
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- let replaced = false;
- const pages = old.pages.map(page =>
- page.map(msg => {
- if (msg.id !== snapshot.id) return msg;
- replaced = true;
- return snapshot;
- })
- );
- if (!replaced) return old;
- return { ...old, pages };
- });
-}
-
-/**
- * Remove a message by id from the current cache state. Used to roll back the
- * optimistic insert performed by `useSendMessage` without touching any other
- * concurrently-optimistic messages.
- */
-function removeMessageFromCache(
- queryClient: ReturnType,
- queryKey: readonly unknown[],
- messageId: string
-): void {
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page => page.filter(msg => msg.id !== messageId)),
- };
- });
-}
-
-function findMessageInCache(
- queryClient: ReturnType,
- queryKey: readonly unknown[],
- messageId: string
-): Message | undefined {
- const data = queryClient.getQueryData>(queryKey);
- if (!data) return undefined;
- for (const page of data.pages) {
- const match = page.find(msg => msg.id === messageId);
- if (match) return match;
- }
- return undefined;
-}
-
-export function useMessages(client: KiloChatClient, conversationId: string | null) {
- return useInfiniteQuery({
- queryKey: ['kilo-chat', 'messages', conversationId],
- queryFn: async ({ pageParam }) => {
- return client.listMessages(conversationId ?? '', { before: pageParam, limit: PAGE_SIZE });
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: lastPage => {
- if (lastPage.length < PAGE_SIZE) return undefined;
- return lastPage[lastPage.length - 1]?.id;
- },
- enabled: !!conversationId,
- select: data => ({
- ...data,
- messages: data.pages.flatMap(p => p).reverse(),
- }),
- });
-}
-
-export type SendMessageVariables = CreateMessageRequest & { clientId: string };
-
-export function useSendMessage(
- client: KiloChatClient,
- conversationId: string | null,
- currentUserId: string
-) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (req: SendMessageVariables) => client.sendMessage(req),
- onMutate: async (variables: SendMessageVariables) => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const pendingId = `pending-${variables.clientId}`;
- const optimisticMessage: Message = {
- id: pendingId,
- senderId: currentUserId,
- content: variables.content,
- inReplyToMessageId: variables.inReplyToMessageId ?? null,
- updatedAt: null,
- clientUpdatedAt: null,
- deleted: false,
- deliveryFailed: false,
- reactions: [],
- };
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- const firstPage = old.pages[0] ?? [];
- return { ...old, pages: [[optimisticMessage, ...firstPage], ...old.pages.slice(1)] };
- });
- return { queryKey, pendingId };
- },
- onSuccess: (response, _variables, context) => {
- if (!context) return;
- const { queryKey, pendingId } = context;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => (msg.id === pendingId ? { ...msg, id: response.messageId } : msg))
- ),
- };
- });
- },
- onError: (_err, _variables, context) => {
- if (!context) return;
- removeMessageFromCache(queryClient, context.queryKey, context.pendingId);
- },
- });
-}
-
-export function useEditMessage(client: KiloChatClient, conversationId: string | null) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ messageId, ...req }: EditMessageRequest & { messageId: string }) =>
- client.editMessage(messageId, req),
- onMutate: async variables => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id === variables.messageId
- ? { ...msg, content: variables.content, clientUpdatedAt: variables.timestamp }
- : msg
- )
- ),
- };
- });
- return { queryKey, snapshot };
- },
- onError: (_err, _variables, context) => {
- if (!context?.snapshot) return;
- restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
- },
- });
-}
-
-export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ messageId, conversationId }: { messageId: string; conversationId: string }) =>
- client.deleteMessage(messageId, { conversationId }),
- onMutate: async variables => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => (msg.id === variables.messageId ? { ...msg, deleted: true } : msg))
- ),
- };
- });
- return { queryKey, snapshot };
- },
- onError: (_err, _variables, context) => {
- if (!context?.snapshot) return;
- restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
- },
- });
-}
-
-export function useAddReaction(
- client: KiloChatClient,
- conversationId: string | null,
- currentUserId: string
-) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) =>
- client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }),
- onMutate: async variables => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id !== variables.messageId
- ? msg
- : {
- ...msg,
- reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId),
- }
- )
- ),
- };
- });
- return { queryKey, snapshot };
- },
- onError: (_err, _variables, context) => {
- if (!context?.snapshot) return;
- restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
- },
- });
-}
-
-export function useRemoveReaction(
- client: KiloChatClient,
- conversationId: string | null,
- currentUserId: string
-) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) =>
- client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }),
- onMutate: async variables => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id !== variables.messageId
- ? msg
- : {
- ...msg,
- reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId),
- }
- )
- ),
- };
- });
- return { queryKey, snapshot };
- },
- onError: (_err, _variables, context) => {
- if (!context?.snapshot) return;
- restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
- },
- });
-}
-
-export function useExecuteAction(
- client: KiloChatClient,
- conversationId: string | null,
- currentUserId: string
-) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({
- messageId,
- groupId,
- value,
- }: {
- messageId: string;
- groupId: string;
- value: ExecApprovalDecision;
- }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }),
- onMutate: async variables => {
- if (!conversationId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- await queryClient.cancelQueries({ queryKey });
- const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
- // Optimistically mark the action as resolved
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => {
- if (msg.id !== variables.messageId) return msg;
- return {
- ...msg,
- content: msg.content.map(block => {
- if (block.type !== 'actions') return block;
- if (block.groupId !== variables.groupId) return block;
- return {
- ...block,
- resolved: {
- value: variables.value,
- resolvedBy: currentUserId,
- resolvedAt: Date.now(),
- },
- };
- }),
- };
- })
- ),
- };
- });
- return { queryKey, snapshot };
- },
- onError: (_err, _variables, context) => {
- if (!context?.snapshot) return;
- restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
- },
- });
-}
-
-/**
- * Subscribes to real-time kilo-chat events on the shared client and applies
- * them to the React Query message cache for the active conversation.
- *
- * Each subscription receives the fully validated typed payload from the
- * client (Zod-checked inside `KiloChatClient.on`), so no casts are needed.
- *
- * Event Service delivers every subscribed context to every handler, so we
- * also validate `ctx` against the expected conversation context before
- * mutating the cache. This protects against stale subscriptions, context
- * leaks, or server-side routing drift.
- */
-export function useMessageCacheUpdater(
- client: KiloChatClient,
- sandboxId: string | null,
- conversationId: string | null,
- // Called with the event context and sender id when a human sender's
- // message lands. Bots stream tokens through message.created events and
- // end their own typing state via explicit typing.stopped, so we must not
- // clear on bot messages or the indicator disappears mid-stream.
- onHumanMessageCreated?: (ctx: string, senderId: string) => void
-): void {
- const queryClient = useQueryClient();
-
- useEffect(() => {
- if (!conversationId || !sandboxId) return;
- const queryKey = ['kilo-chat', 'messages', conversationId];
- const expectedContext = kiloclawConversationContext(sandboxId, conversationId);
-
- const onCreated = (ctx: string, e: MessageCreatedEvent) => {
- if (ctx !== expectedContext) return;
- if (!e.senderId.startsWith('bot:')) {
- onHumanMessageCreated?.(ctx, e.senderId);
- }
- const newMessage: Message = {
- id: e.messageId,
- senderId: e.senderId,
- content: e.content,
- inReplyToMessageId: e.inReplyToMessageId,
- updatedAt: null,
- clientUpdatedAt: null,
- deleted: false,
- deliveryFailed: false,
- reactions: [],
- };
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- // Skip if this messageId already exists
- for (const page of old.pages) {
- if (page.some(msg => msg.id === e.messageId)) return old;
- }
- // Replace the matching pending optimistic message if clientId correlates
- if (e.clientId) {
- const pendingId = `pending-${e.clientId}`;
- for (const page of old.pages) {
- if (page.some(msg => msg.id === pendingId)) {
- return {
- ...old,
- pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))),
- };
- }
- }
- }
- const firstPage = old.pages[0] ?? [];
- return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] };
- });
- };
-
- const onUpdated = (ctx: string, e: MessageUpdatedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id === e.messageId
- ? {
- ...msg,
- content: e.content,
- clientUpdatedAt: e.clientUpdatedAt,
- }
- : msg
- )
- ),
- };
- });
- };
-
- const onDeleted = (ctx: string, e: MessageDeletedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => (msg.id === e.messageId ? { ...msg, deleted: true } : msg))
- ),
- };
- });
- };
-
- const onDeliveryFailed = (ctx: string, e: MessageDeliveryFailedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => (msg.id === e.messageId ? { ...msg, deliveryFailed: true } : msg))
- ),
- };
- });
- };
-
- const onActionFailed = (ctx: string, e: ActionDeliveryFailedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg => {
- if (msg.id !== e.messageId) return msg;
- return {
- ...msg,
- content: msg.content.map(block => {
- if (block.type !== 'actions') return block;
- if (block.groupId !== e.groupId) return block;
- return { ...block, resolved: undefined };
- }),
- };
- })
- ),
- };
- });
- toast.error("Couldn't reach the bot — please try again");
- };
-
- const onReactionAdded = (ctx: string, e: ReactionAddedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id !== e.messageId
- ? msg
- : { ...msg, reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId) }
- )
- ),
- };
- });
- };
-
- const onReactionRemoved = (ctx: string, e: ReactionRemovedEvent) => {
- if (ctx !== expectedContext) return;
- queryClient.setQueryData>(queryKey, old => {
- if (!old) return old;
- return {
- ...old,
- pages: old.pages.map(page =>
- page.map(msg =>
- msg.id !== e.messageId
- ? msg
- : {
- ...msg,
- reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId),
- }
- )
- ),
- };
- });
- };
-
- const offs = [
- client.onMessageCreated(onCreated),
- client.onMessageUpdated(onUpdated),
- client.onMessageDeleted(onDeleted),
- client.onMessageDeliveryFailed(onDeliveryFailed),
- client.onActionDeliveryFailed(onActionFailed),
- client.onReactionAdded(onReactionAdded),
- client.onReactionRemoved(onReactionRemoved),
- ];
- return () => {
- for (const off of offs) off();
- };
- }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated]);
-}
+export {
+ useMessages,
+ useSendMessage,
+ useEditMessage,
+ useDeleteMessage,
+ useAddReaction,
+ useRemoveReaction,
+ useExecuteAction,
+ useMessageCacheUpdater,
+} from '@kilocode/kilo-chat-hooks';
+export type { SendMessageVariables } from '@kilocode/kilo-chat-hooks';
diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx
index 03bc458fa..31b0318c6 100644
--- a/apps/web/src/contexts/EventServiceContext.tsx
+++ b/apps/web/src/contexts/EventServiceContext.tsx
@@ -3,6 +3,7 @@
import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
import { EventServiceClient } from '@kilocode/event-service';
import { KiloChatClient } from '@kilocode/kilo-chat';
+import { KiloChatHooksProvider } from '@kilocode/kilo-chat-hooks';
import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/constants';
import { getKiloChatToken, clearKiloChatToken } from '@/app/(app)/claw/kilo-chat/token';
@@ -60,7 +61,13 @@ export function EventServiceProvider({ children }: EventServiceProviderProps) {
[eventService, kiloChatClient]
);
- return {children};
+ return (
+
+
+ {children}
+
+
+ );
}
export function useEventServiceClient(): EventServiceContextValue {
diff --git a/packages/kilo-chat-hooks/package.json b/packages/kilo-chat-hooks/package.json
new file mode 100644
index 000000000..9d61da112
--- /dev/null
+++ b/packages/kilo-chat-hooks/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@kilocode/kilo-chat-hooks",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsgo --noEmit"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "@tanstack/react-query": "*"
+ },
+ "dependencies": {
+ "@kilocode/kilo-chat": "workspace:*",
+ "@kilocode/event-service": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "react": "^19.2.4",
+ "@tanstack/react-query": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/kilo-chat-hooks/src/context.tsx b/packages/kilo-chat-hooks/src/context.tsx
new file mode 100644
index 000000000..55c017fbd
--- /dev/null
+++ b/packages/kilo-chat-hooks/src/context.tsx
@@ -0,0 +1,26 @@
+import { createContext, useContext, type ReactNode } from 'react';
+import type { KiloChatClient } from '@kilocode/kilo-chat';
+import type { EventServiceClient } from '@kilocode/event-service';
+
+type Value = {
+ kiloChatClient: KiloChatClient;
+ eventService: EventServiceClient;
+};
+
+const Ctx = createContext(null);
+
+export function KiloChatHooksProvider(props: { value: Value; children: ReactNode }) {
+ return {props.children};
+}
+
+export function useKiloChatClient(): KiloChatClient {
+ const v = useContext(Ctx);
+ if (!v) throw new Error('useKiloChatClient: missing KiloChatHooksProvider');
+ return v.kiloChatClient;
+}
+
+export function useEventServiceClient(): EventServiceClient {
+ const v = useContext(Ctx);
+ if (!v) throw new Error('useEventServiceClient: missing KiloChatHooksProvider');
+ return v.eventService;
+}
diff --git a/packages/kilo-chat-hooks/src/index.ts b/packages/kilo-chat-hooks/src/index.ts
new file mode 100644
index 000000000..599b34314
--- /dev/null
+++ b/packages/kilo-chat-hooks/src/index.ts
@@ -0,0 +1,4 @@
+export * from './context';
+export * from './query-keys';
+export * from './use-conversations';
+export * from './use-messages';
diff --git a/packages/kilo-chat-hooks/src/query-keys.ts b/packages/kilo-chat-hooks/src/query-keys.ts
new file mode 100644
index 000000000..08a7c24b1
--- /dev/null
+++ b/packages/kilo-chat-hooks/src/query-keys.ts
@@ -0,0 +1,17 @@
+// Shared React Query key builders so subscribers (event handlers, mutations)
+// invalidate exactly the keys the queries register under. Drift here silently
+// breaks live updates — keep all kilo-chat keys in this file.
+
+export const conversationsKey = (sandboxId: string | null) =>
+ ['kilo-chat', 'conversations', sandboxId] as const;
+
+export const conversationsKeyAll = () => ['kilo-chat', 'conversations'] as const;
+
+export const conversationKey = (conversationId: string | null) =>
+ ['kilo-chat', 'conversation', conversationId] as const;
+
+export const messagesKey = (conversationId: string | null) =>
+ ['kilo-chat', 'messages', conversationId] as const;
+
+export const botStatusKey = (sandboxId: string | null) =>
+ ['kilo-chat', 'bot-status', sandboxId] as const;
diff --git a/packages/kilo-chat-hooks/src/use-conversations.ts b/packages/kilo-chat-hooks/src/use-conversations.ts
new file mode 100644
index 000000000..430e797fc
--- /dev/null
+++ b/packages/kilo-chat-hooks/src/use-conversations.ts
@@ -0,0 +1,126 @@
+import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
+import type { InfiniteData } from '@tanstack/react-query';
+import type { KiloChatClient } from '@kilocode/kilo-chat';
+import type { CreateConversationRequest, ConversationListResponse } from '@kilocode/kilo-chat';
+
+import { conversationKey, conversationsKey, conversationsKeyAll, messagesKey } from './query-keys';
+
+const CONVERSATIONS_PAGE_SIZE = 50;
+
+export function useConversations(client: KiloChatClient, sandboxId: string | null) {
+ return useInfiniteQuery({
+ queryKey: conversationsKey(sandboxId),
+ queryFn: ({ pageParam }) =>
+ client.listConversations({
+ sandboxId: sandboxId ?? undefined,
+ limit: CONVERSATIONS_PAGE_SIZE,
+ cursor: pageParam ?? undefined,
+ }),
+ initialPageParam: null as string | null,
+ getNextPageParam: lastPage => lastPage.nextCursor,
+ enabled: !!sandboxId,
+ select: data => ({
+ ...data,
+ conversations: data.pages.flatMap(p => p.conversations),
+ }),
+ });
+}
+
+export function useConversationDetail(client: KiloChatClient, conversationId: string | null) {
+ return useQuery({
+ queryKey: conversationKey(conversationId),
+ queryFn: () => client.getConversation(conversationId ?? ''),
+ enabled: !!conversationId,
+ });
+}
+
+export function useCreateConversation(client: KiloChatClient) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (req: CreateConversationRequest) => client.createConversation(req),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() });
+ },
+ });
+}
+
+export function useRenameConversation(client: KiloChatClient) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ conversationId, title }: { conversationId: string; title: string }) =>
+ client.renameConversation(conversationId, { title }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() });
+ },
+ });
+}
+
+export function useLeaveConversation(client: KiloChatClient) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (conversationId: string) => client.leaveConversation(conversationId),
+ onSuccess: (_data, conversationId) => {
+ queryClient.removeQueries({ queryKey: conversationKey(conversationId) });
+ queryClient.removeQueries({ queryKey: messagesKey(conversationId) });
+ void queryClient.invalidateQueries({ queryKey: conversationsKeyAll() });
+ },
+ });
+}
+
+export type ConversationListInfiniteData = InfiniteData;
+
+export function updateConversationPages(
+ data: ConversationListInfiniteData | undefined,
+ mapItem: (
+ c: ConversationListResponse['conversations'][number]
+ ) => ConversationListResponse['conversations'][number]
+): ConversationListInfiniteData | undefined {
+ if (!data) return data;
+ return {
+ ...data,
+ pages: data.pages.map(page => ({
+ ...page,
+ conversations: page.conversations.map(mapItem),
+ })),
+ };
+}
+
+export function filterConversationPages(
+ data: ConversationListInfiniteData | undefined,
+ predicate: (c: ConversationListResponse['conversations'][number]) => boolean
+): ConversationListInfiniteData | undefined {
+ if (!data) return data;
+ return {
+ ...data,
+ pages: data.pages.map(page => ({
+ ...page,
+ conversations: page.conversations.filter(predicate),
+ })),
+ };
+}
+
+export function useMarkConversationRead(client: KiloChatClient) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (conversationId: string) => client.markConversationRead(conversationId),
+ onMutate: conversationId => {
+ // Optimistically set lastReadAt = now in all cached conversation lists
+ const now = Date.now();
+ const queryKey = conversationsKeyAll();
+ const previous = queryClient.getQueriesData({ queryKey });
+ queryClient.setQueriesData({ queryKey }, old =>
+ updateConversationPages(old, c =>
+ c.conversationId === conversationId ? { ...c, lastReadAt: now } : c
+ )
+ );
+ return { previous };
+ },
+ onError: (_err, _variables, context) => {
+ if (context?.previous) {
+ for (const [key, data] of context.previous) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ },
+ });
+}
diff --git a/packages/kilo-chat-hooks/src/use-messages.ts b/packages/kilo-chat-hooks/src/use-messages.ts
new file mode 100644
index 000000000..b2302e190
--- /dev/null
+++ b/packages/kilo-chat-hooks/src/use-messages.ts
@@ -0,0 +1,573 @@
+import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
+import type { InfiniteData } from '@tanstack/react-query';
+import type { KiloChatClient } from '@kilocode/kilo-chat';
+import type {
+ Message,
+ ReactionSummary,
+ CreateMessageRequest,
+ EditMessageRequest,
+ ExecApprovalDecision,
+ MessageCreatedEvent,
+ MessageUpdatedEvent,
+ MessageDeletedEvent,
+ MessageDeliveryFailedEvent,
+ ActionDeliveryFailedEvent,
+ ReactionAddedEvent,
+ ReactionRemovedEvent,
+} from '@kilocode/kilo-chat';
+import { useEffect } from 'react';
+import { kiloclawConversationContext } from '@kilocode/event-service';
+
+import { messagesKey } from './query-keys';
+
+export const PAGE_SIZE = 50;
+
+export function applyReactionAdded(
+ reactions: ReactionSummary[],
+ emoji: string,
+ memberId: string
+): ReactionSummary[] {
+ const existing = reactions.find(r => r.emoji === emoji);
+ if (existing) {
+ if (existing.memberIds.includes(memberId)) return reactions;
+ return reactions.map(r =>
+ r.emoji === emoji ? { ...r, count: r.count + 1, memberIds: [...r.memberIds, memberId] } : r
+ );
+ }
+ return [...reactions, { emoji, count: 1, memberIds: [memberId] }];
+}
+
+export function applyReactionRemoved(
+ reactions: ReactionSummary[],
+ emoji: string,
+ memberId: string
+): ReactionSummary[] {
+ return reactions
+ .map(r => {
+ if (r.emoji !== emoji) return r;
+ const memberIds = r.memberIds.filter(id => id !== memberId);
+ return { ...r, count: memberIds.length, memberIds };
+ })
+ .filter(r => r.count > 0);
+}
+
+/**
+ * Splice a snapshotted message back into the current cache state. If the
+ * message no longer exists in any page (e.g. a concurrent delete event), the
+ * cache is left unchanged so we do not resurrect it.
+ */
+export function restoreMessageInCache(
+ queryClient: ReturnType,
+ queryKey: readonly unknown[],
+ snapshot: Message
+): void {
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ let replaced = false;
+ const pages = old.pages.map(page =>
+ page.map(msg => {
+ if (msg.id !== snapshot.id) return msg;
+ replaced = true;
+ return snapshot;
+ })
+ );
+ if (!replaced) return old;
+ return { ...old, pages };
+ });
+}
+
+/**
+ * Remove a message by id from the current cache state. Used to roll back the
+ * optimistic insert performed by `useSendMessage` without touching any other
+ * concurrently-optimistic messages.
+ */
+export function removeMessageFromCache(
+ queryClient: ReturnType,
+ queryKey: readonly unknown[],
+ messageId: string
+): void {
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page => page.filter(msg => msg.id !== messageId)),
+ };
+ });
+}
+
+export function findMessageInCache(
+ queryClient: ReturnType,
+ queryKey: readonly unknown[],
+ messageId: string
+): Message | undefined {
+ const data = queryClient.getQueryData>(queryKey);
+ if (!data) return undefined;
+ for (const page of data.pages) {
+ const match = page.find(msg => msg.id === messageId);
+ if (match) return match;
+ }
+ return undefined;
+}
+
+export function useMessages(client: KiloChatClient, conversationId: string | null) {
+ return useInfiniteQuery({
+ queryKey: messagesKey(conversationId),
+ queryFn: async ({ pageParam }) => {
+ return client.listMessages(conversationId ?? '', { before: pageParam, limit: PAGE_SIZE });
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: lastPage => {
+ if (lastPage.length < PAGE_SIZE) return undefined;
+ return lastPage[lastPage.length - 1]?.id;
+ },
+ enabled: !!conversationId,
+ select: data => ({
+ ...data,
+ messages: data.pages.flatMap(p => p).reverse(),
+ }),
+ });
+}
+
+export type SendMessageVariables = CreateMessageRequest & { clientId: string };
+
+export function useSendMessage(
+ client: KiloChatClient,
+ conversationId: string | null,
+ currentUserId: string
+) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (req: SendMessageVariables) => client.sendMessage(req),
+ onMutate: async (variables: SendMessageVariables) => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const pendingId = `pending-${variables.clientId}`;
+ const optimisticMessage: Message = {
+ id: pendingId,
+ senderId: currentUserId,
+ content: variables.content,
+ inReplyToMessageId: variables.inReplyToMessageId ?? null,
+ updatedAt: null,
+ clientUpdatedAt: null,
+ deleted: false,
+ deliveryFailed: false,
+ reactions: [],
+ };
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ const firstPage = old.pages[0] ?? [];
+ return { ...old, pages: [[optimisticMessage, ...firstPage], ...old.pages.slice(1)] };
+ });
+ return { queryKey, pendingId };
+ },
+ onSuccess: (response, _variables, context) => {
+ if (!context) return;
+ const { queryKey, pendingId } = context;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => (msg.id === pendingId ? { ...msg, id: response.messageId } : msg))
+ ),
+ };
+ });
+ },
+ onError: (_err, _variables, context) => {
+ if (!context) return;
+ removeMessageFromCache(queryClient, context.queryKey, context.pendingId);
+ },
+ });
+}
+
+export function useEditMessage(client: KiloChatClient, conversationId: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ messageId, ...req }: EditMessageRequest & { messageId: string }) =>
+ client.editMessage(messageId, req),
+ onMutate: async variables => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id === variables.messageId
+ ? { ...msg, content: variables.content, clientUpdatedAt: variables.timestamp }
+ : msg
+ )
+ ),
+ };
+ });
+ return { queryKey, snapshot };
+ },
+ onError: (_err, _variables, context) => {
+ if (!context?.snapshot) return;
+ restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
+ },
+ });
+}
+
+export function useDeleteMessage(client: KiloChatClient, conversationId: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ messageId, conversationId }: { messageId: string; conversationId: string }) =>
+ client.deleteMessage(messageId, { conversationId }),
+ onMutate: async variables => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => (msg.id === variables.messageId ? { ...msg, deleted: true } : msg))
+ ),
+ };
+ });
+ return { queryKey, snapshot };
+ },
+ onError: (_err, _variables, context) => {
+ if (!context?.snapshot) return;
+ restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
+ },
+ });
+}
+
+export function useAddReaction(
+ client: KiloChatClient,
+ conversationId: string | null,
+ currentUserId: string
+) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) =>
+ client.addReaction(messageId, { conversationId: conversationId ?? '', emoji }),
+ onMutate: async variables => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id !== variables.messageId
+ ? msg
+ : {
+ ...msg,
+ reactions: applyReactionAdded(msg.reactions, variables.emoji, currentUserId),
+ }
+ )
+ ),
+ };
+ });
+ return { queryKey, snapshot };
+ },
+ onError: (_err, _variables, context) => {
+ if (!context?.snapshot) return;
+ restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
+ },
+ });
+}
+
+export function useRemoveReaction(
+ client: KiloChatClient,
+ conversationId: string | null,
+ currentUserId: string
+) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ messageId, emoji }: { messageId: string; emoji: string }) =>
+ client.removeReaction(messageId, { conversationId: conversationId ?? '', emoji }),
+ onMutate: async variables => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id !== variables.messageId
+ ? msg
+ : {
+ ...msg,
+ reactions: applyReactionRemoved(msg.reactions, variables.emoji, currentUserId),
+ }
+ )
+ ),
+ };
+ });
+ return { queryKey, snapshot };
+ },
+ onError: (_err, _variables, context) => {
+ if (!context?.snapshot) return;
+ restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
+ },
+ });
+}
+
+export function useExecuteAction(
+ client: KiloChatClient,
+ conversationId: string | null,
+ currentUserId: string
+) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ messageId,
+ groupId,
+ value,
+ }: {
+ messageId: string;
+ groupId: string;
+ value: ExecApprovalDecision;
+ }) => client.executeAction(conversationId ?? '', messageId, { groupId, value }),
+ onMutate: async variables => {
+ if (!conversationId) return;
+ const queryKey = messagesKey(conversationId);
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = findMessageInCache(queryClient, queryKey, variables.messageId);
+ // Optimistically mark the action as resolved
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => {
+ if (msg.id !== variables.messageId) return msg;
+ return {
+ ...msg,
+ content: msg.content.map(block => {
+ if (block.type !== 'actions') return block;
+ if (block.groupId !== variables.groupId) return block;
+ return {
+ ...block,
+ resolved: {
+ value: variables.value,
+ resolvedBy: currentUserId,
+ resolvedAt: Date.now(),
+ },
+ };
+ }),
+ };
+ })
+ ),
+ };
+ });
+ return { queryKey, snapshot };
+ },
+ onError: (_err, _variables, context) => {
+ if (!context?.snapshot) return;
+ restoreMessageInCache(queryClient, context.queryKey, context.snapshot);
+ },
+ });
+}
+
+/**
+ * Subscribes to real-time kilo-chat events on the shared client and applies
+ * them to the React Query message cache for the active conversation.
+ *
+ * Each subscription receives the fully validated typed payload from the
+ * client (Zod-checked inside `KiloChatClient.on`), so no casts are needed.
+ *
+ * Event Service delivers every subscribed context to every handler, so we
+ * also validate `ctx` against the expected conversation context before
+ * mutating the cache. This protects against stale subscriptions, context
+ * leaks, or server-side routing drift.
+ */
+export function useMessageCacheUpdater(
+ client: KiloChatClient,
+ sandboxId: string | null,
+ conversationId: string | null,
+ // Called with the event context and sender id when a human sender's
+ // message lands. Bots stream tokens through message.created events and
+ // end their own typing state via explicit typing.stopped, so we must not
+ // clear on bot messages or the indicator disappears mid-stream.
+ onHumanMessageCreated?: (ctx: string, senderId: string) => void,
+ // Fires when the server reports an action.delivery_failed for a message in
+ // this conversation, after the optimistic resolved-state has been rolled
+ // back. The shared package is platform-agnostic, so the user-visible
+ // message lives at the call site (web: sonner toast; mobile: native toast).
+ onActionFailed?: () => void
+): void {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!conversationId || !sandboxId) return;
+ const queryKey = messagesKey(conversationId);
+ const expectedContext = kiloclawConversationContext(sandboxId, conversationId);
+
+ const onCreated = (ctx: string, e: MessageCreatedEvent) => {
+ if (ctx !== expectedContext) return;
+ if (!e.senderId.startsWith('bot:')) {
+ onHumanMessageCreated?.(ctx, e.senderId);
+ }
+ const newMessage: Message = {
+ id: e.messageId,
+ senderId: e.senderId,
+ content: e.content,
+ inReplyToMessageId: e.inReplyToMessageId,
+ updatedAt: null,
+ clientUpdatedAt: null,
+ deleted: false,
+ deliveryFailed: false,
+ reactions: [],
+ };
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ // Skip if this messageId already exists
+ for (const page of old.pages) {
+ if (page.some(msg => msg.id === e.messageId)) return old;
+ }
+ // Replace the matching pending optimistic message if clientId correlates
+ if (e.clientId) {
+ const pendingId = `pending-${e.clientId}`;
+ for (const page of old.pages) {
+ if (page.some(msg => msg.id === pendingId)) {
+ return {
+ ...old,
+ pages: old.pages.map(p => p.map(msg => (msg.id === pendingId ? newMessage : msg))),
+ };
+ }
+ }
+ }
+ const firstPage = old.pages[0] ?? [];
+ return { ...old, pages: [[newMessage, ...firstPage], ...old.pages.slice(1)] };
+ });
+ };
+
+ const onUpdated = (ctx: string, e: MessageUpdatedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id === e.messageId
+ ? {
+ ...msg,
+ content: e.content,
+ clientUpdatedAt: e.clientUpdatedAt,
+ }
+ : msg
+ )
+ ),
+ };
+ });
+ };
+
+ const onDeleted = (ctx: string, e: MessageDeletedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => (msg.id === e.messageId ? { ...msg, deleted: true } : msg))
+ ),
+ };
+ });
+ };
+
+ const onDeliveryFailed = (ctx: string, e: MessageDeliveryFailedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => (msg.id === e.messageId ? { ...msg, deliveryFailed: true } : msg))
+ ),
+ };
+ });
+ };
+
+ const onActionDeliveryFailed = (ctx: string, e: ActionDeliveryFailedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg => {
+ if (msg.id !== e.messageId) return msg;
+ return {
+ ...msg,
+ content: msg.content.map(block => {
+ if (block.type !== 'actions') return block;
+ if (block.groupId !== e.groupId) return block;
+ return { ...block, resolved: undefined };
+ }),
+ };
+ })
+ ),
+ };
+ });
+ onActionFailed?.();
+ };
+
+ const onReactionAdded = (ctx: string, e: ReactionAddedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id !== e.messageId
+ ? msg
+ : { ...msg, reactions: applyReactionAdded(msg.reactions, e.emoji, e.memberId) }
+ )
+ ),
+ };
+ });
+ };
+
+ const onReactionRemoved = (ctx: string, e: ReactionRemovedEvent) => {
+ if (ctx !== expectedContext) return;
+ queryClient.setQueryData>(queryKey, old => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map(page =>
+ page.map(msg =>
+ msg.id !== e.messageId
+ ? msg
+ : {
+ ...msg,
+ reactions: applyReactionRemoved(msg.reactions, e.emoji, e.memberId),
+ }
+ )
+ ),
+ };
+ });
+ };
+
+ const offs = [
+ client.onMessageCreated(onCreated),
+ client.onMessageUpdated(onUpdated),
+ client.onMessageDeleted(onDeleted),
+ client.onMessageDeliveryFailed(onDeliveryFailed),
+ client.onActionDeliveryFailed(onActionDeliveryFailed),
+ client.onReactionAdded(onReactionAdded),
+ client.onReactionRemoved(onReactionRemoved),
+ ];
+ return () => {
+ for (const off of offs) off();
+ };
+ }, [client, sandboxId, conversationId, queryClient, onHumanMessageCreated, onActionFailed]);
+}
diff --git a/packages/kilo-chat-hooks/tsconfig.json b/packages/kilo-chat-hooks/tsconfig.json
new file mode 100644
index 000000000..e480ae353
--- /dev/null
+++ b/packages/kilo-chat-hooks/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es2017",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3a7e7b96b..b252a8a27 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,6 +152,9 @@ importers:
'@kilocode/kilo-chat':
specifier: workspace:*
version: link:../../packages/kilo-chat
+ '@kilocode/kilo-chat-hooks':
+ specifier: workspace:*
+ version: link:../../packages/kilo-chat-hooks
'@kilocode/notifications':
specifier: workspace:*
version: link:../../packages/notifications
@@ -514,6 +517,9 @@ importers:
'@kilocode/kilo-chat':
specifier: workspace:*
version: link:../../packages/kilo-chat
+ '@kilocode/kilo-chat-hooks':
+ specifier: workspace:*
+ version: link:../../packages/kilo-chat-hooks
'@kilocode/kiloclaw-secret-catalog':
specifier: workspace:*
version: link:../../packages/kiloclaw-secret-catalog
@@ -1005,6 +1011,31 @@ importers:
specifier: ~3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ packages/kilo-chat-hooks:
+ dependencies:
+ '@kilocode/event-service':
+ specifier: workspace:*
+ version: link:../event-service
+ '@kilocode/kilo-chat':
+ specifier: workspace:*
+ version: link:../kilo-chat
+ devDependencies:
+ '@tanstack/react-query':
+ specifier: 'catalog:'
+ version: 5.90.21(react@19.2.4)
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@typescript/native-preview':
+ specifier: 'catalog:'
+ version: 7.0.0-dev.20260319.1
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
packages/kiloclaw-secret-catalog:
dependencies:
zod:
@@ -1570,7 +1601,7 @@ importers:
version: 7.0.0-dev.20260319.1
jest:
specifier: ^30.3.0
- version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4))
+ version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4))
typescript:
specifier: 'catalog:'
version: 5.9.3
@@ -16759,7 +16790,7 @@ snapshots:
cjs-module-lexer: 1.4.3
esbuild: 0.27.4
miniflare: 4.20260310.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
wrangler: 4.72.0(@cloudflare/workers-types@4.20260313.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- '@cloudflare/workers-types'
@@ -22267,7 +22298,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -25698,25 +25729,6 @@ snapshots:
- supports-color
- ts-node
- jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)):
- dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
- '@jest/test-result': 30.3.0
- '@jest/types': 30.3.0
- chalk: 4.1.2
- exit-x: 0.2.2
- import-local: 3.2.0
- jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4))
- jest-util: 30.3.0
- jest-validate: 30.3.0
- yargs: 17.7.2
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
jest-config@29.7.0(@types/node@24.12.0):
dependencies:
'@babel/core': 7.29.0
@@ -26368,19 +26380,6 @@ snapshots:
- supports-color
- ts-node
- jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)):
- dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
- '@jest/types': 30.3.0
- import-local: 3.2.0
- jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4))
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
jimp-compact@0.16.1: {}
jiti@2.6.1: {}