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: {}