{isLoading ? (
Loading...
) : conversations.length === 0 ? (
- No conversations yet
+ {newConversationUi.emptyText}
) : (
<>
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx
new file mode 100644
index 0000000000..cb064407ab
--- /dev/null
+++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatConversationPage.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { KiloChatApiError } from '@kilocode/kilo-chat';
+import { useKiloChatContext } from './kiloChatContext';
+import { useConversationDetail } from '../hooks/useConversations';
+import { MessageArea } from './MessageArea';
+import { KiloChatStatusError } from './KiloChatStatusError';
+import { conversationRouteDecision } from '../[conversationId]/conversation-route-guard';
+
+export function KiloChatConversationPage() {
+ const params = useParams<{ conversationId: string }>();
+ const router = useRouter();
+ const {
+ kiloChatClient,
+ leavingConversationId,
+ basePath,
+ sandboxId,
+ isInstanceError,
+ instanceErrorMessage,
+ isInstanceLoading,
+ noInstanceRedirect,
+ onRetryInstanceStatus,
+ } = useKiloChatContext();
+ const isLeaving = leavingConversationId === params.conversationId;
+ const conversationDetail = useConversationDetail(
+ kiloChatClient,
+ isLeaving || isInstanceError ? null : params.conversationId
+ );
+ const routeDecision = conversationRouteDecision({
+ conversationMembers: conversationDetail.data?.members,
+ isInstanceError,
+ isInstanceLoading,
+ isLeaving,
+ routeSandboxId: sandboxId,
+ });
+
+ useEffect(() => {
+ if (routeDecision === 'redirect-no-instance') {
+ router.replace(noInstanceRedirect);
+ return;
+ }
+ if (routeDecision === 'not-found') {
+ toast.error('Conversation not found');
+ router.replace(basePath);
+ return;
+ }
+ if (conversationDetail.isError && !isLeaving) {
+ const status =
+ conversationDetail.error instanceof KiloChatApiError
+ ? conversationDetail.error.status
+ : undefined;
+ const message =
+ status === 400 || status === 403 || status === 404
+ ? 'Conversation not found'
+ : 'Failed to load conversation';
+ toast.error(message);
+ router.replace(basePath);
+ }
+ }, [
+ conversationDetail.isError,
+ conversationDetail.error,
+ isLeaving,
+ router,
+ basePath,
+ noInstanceRedirect,
+ routeDecision,
+ ]);
+
+ if (isLeaving || routeDecision !== 'ready') {
+ if (routeDecision === 'status-error') {
+ return
;
+ }
+ return null;
+ }
+
+ if (conversationDetail.isError) {
+ return null;
+ }
+
+ return
;
+}
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx
index 8ab3d6b2eb..f62c314ece 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx
+++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx
@@ -5,191 +5,158 @@ import { useRouter, useParams } from 'next/navigation';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { formatKiloChatError } from '@kilocode/kilo-chat';
+import { usePresenceSubscription } from '@kilocode/kilo-chat-hooks';
import { ConversationList } from './ConversationList';
import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext';
-import { useEventService, useInstanceContext } from '../hooks/useEventService';
+import { kiloclawInstanceContext } from '@kilocode/event-service';
+import { useEventServiceClient } from '@/contexts/EventServiceContext';
+import { cn } from '@/lib/utils';
import {
useConversations,
useCreateConversation,
useRenameConversation,
useLeaveConversation,
- updateConversationPages,
- filterConversationPages,
- type ConversationListInfiniteData,
+ conversationsKey,
+ registerConversationListCacheHandlers,
} from '../hooks/useConversations';
// ── Layout component ────────────────────────────────────────────────
type KiloChatLayoutProps = {
- getToken: () => Promise
;
- currentUserId: string;
+ currentUserId: string | null;
sandboxId: string | null;
basePath: string;
noInstanceRedirect: string;
isInstanceLoading: boolean;
+ isInstanceError: boolean;
+ instanceErrorMessage: string | null;
+ onRetryInstanceStatus: () => void;
instanceStatus: string | null;
assistantName: string | null;
+ className?: string;
children: React.ReactNode;
};
export function KiloChatLayout({
- getToken,
currentUserId,
sandboxId,
basePath,
noInstanceRedirect,
isInstanceLoading,
+ isInstanceError,
+ instanceErrorMessage,
+ onRetryInstanceStatus,
instanceStatus,
assistantName,
+ className,
children,
}: KiloChatLayoutProps) {
const router = useRouter();
- const { eventService, kiloChatClient } = useEventService(getToken);
- useInstanceContext(eventService, sandboxId);
+ const { eventService, kiloChatClient } = useEventServiceClient();
+ usePresenceSubscription(
+ sandboxId ? kiloclawInstanceContext(sandboxId) : null,
+ Boolean(sandboxId)
+ );
const queryClient = useQueryClient();
const params = useParams<{ conversationId?: string }>();
const [leavingConversationId, setLeavingConversationId] = useState(null);
+ const conversationsQueryKey = useMemo(() => conversationsKey(sandboxId), [sandboxId]);
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversations(
kiloChatClient,
sandboxId
);
- // Update conversation list cache in-place when activity events arrive.
- // For cursor pagination, events targeting conversations outside page 1 are
- // ignored by an in-place patch, so the list appears stale. Invalidate the
- // cache so the affected conversation either appears at the top (new/active)
- // or re-sorts correctly once refetched.
+ // Update loaded conversation-list cache rows in-place when instance events arrive.
+ // Unknown conversations still invalidate so they can be fetched into the list.
useEffect(() => {
- const queryKey = ['kilo-chat', 'conversations'];
-
- function isOnFirstPage(conversationId: string): boolean {
- const entries = queryClient.getQueriesData({ queryKey });
- for (const [, data] of entries) {
- const firstPage = data?.pages[0];
- if (firstPage?.conversations.some(c => c.conversationId === conversationId)) {
- return true;
- }
- }
- return false;
- }
-
- const offs = [
- kiloChatClient.onConversationCreated((_ctx, e) => {
- if (isOnFirstPage(e.conversationId)) return;
- void queryClient.invalidateQueries({ queryKey });
- }),
- kiloChatClient.onConversationRenamed((_ctx, e) => {
- queryClient.setQueriesData({ queryKey }, old =>
- updateConversationPages(old, c =>
- c.conversationId === e.conversationId ? { ...c, title: e.title } : c
- )
- );
- // Also update the conversation detail cache if it's loaded
- void queryClient.invalidateQueries({
- queryKey: ['kilo-chat', 'conversation', e.conversationId],
- });
- }),
- kiloChatClient.onConversationLeft((_ctx, e) => {
- queryClient.setQueriesData({ queryKey }, old =>
- filterConversationPages(old, c => c.conversationId !== e.conversationId)
- );
- }),
- kiloChatClient.onConversationRead((_ctx, e) => {
- // `.read` is broadcast to every human in the conversation with the
- // `memberId` of whose read-marker moved. Only the actual reader
- // should see their own sidebar row's `lastReadAt` advance — without
- // this filter, Alice marking read would also move Bob's `lastReadAt`.
- if (e.memberId !== currentUserId) return;
- queryClient.setQueriesData({ queryKey }, old =>
- updateConversationPages(old, c =>
- c.conversationId === e.conversationId ? { ...c, lastReadAt: e.lastReadAt } : c
- )
- );
- }),
- kiloChatClient.onConversationActivity((_ctx, e) => {
- if (isOnFirstPage(e.conversationId)) {
- queryClient.setQueriesData({ queryKey }, old =>
- updateConversationPages(old, c =>
- c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c
- )
- );
- return;
- }
- void queryClient.invalidateQueries({ queryKey });
- }),
- ];
- return () => offs.forEach(off => off());
- }, [kiloChatClient, queryClient]);
-
- // Refetch conversations on WebSocket reconnect (events may have been missed)
- useEffect(() => {
- return eventService.onReconnect(() => {
- void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'conversations'] });
+ return registerConversationListCacheHandlers({
+ activeConversationId: params?.conversationId ?? null,
+ currentUserId,
+ eventService,
+ kiloChatClient,
+ queryClient,
+ queryKey: conversationsQueryKey,
+ sandboxId,
});
- }, [eventService, queryClient]);
+ }, [
+ currentUserId,
+ eventService,
+ kiloChatClient,
+ params?.conversationId,
+ queryClient,
+ conversationsQueryKey,
+ sandboxId,
+ ]);
const createConversation = useCreateConversation(kiloChatClient);
const renameConversation = useRenameConversation(kiloChatClient);
const leaveConversation = useLeaveConversation(kiloChatClient);
+ const [newConversationError, setNewConversationError] = useState(null);
const handleRename = useCallback(
(conversationId: string, title: string) => {
renameConversation.mutate(
- { conversationId, title },
+ { sandboxId, conversationId, title },
{ onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) }
);
},
- [renameConversation.mutate]
+ [sandboxId, renameConversation.mutate]
);
const handleLeave = useCallback(
(conversationId: string) => {
- // Mark as leaving so child queries disable themselves immediately
+ const isActiveConversation = params?.conversationId === conversationId;
setLeavingConversationId(conversationId);
- const queryKey = ['kilo-chat', 'conversations'];
- // Optimistically remove the row before the router.push fires. When the
- // user leaves the *active* conversation, router navigation concurrent
- // with the mutation's onSuccess invalidateQueries left the row stale
- // in the sidebar until a full page reload. Patching the cache up-front
- // mirrors what onConversationLeft does for other members.
- const previous = queryClient.getQueriesData({ queryKey });
- queryClient.setQueriesData({ queryKey }, old =>
- filterConversationPages(old, c => c.conversationId !== conversationId)
+ leaveConversation.mutate(
+ { sandboxId, conversationId },
+ {
+ onSettled: () => setLeavingConversationId(null),
+ onSuccess: () => {
+ if (isActiveConversation) {
+ router.push(basePath);
+ }
+ },
+ onError: err => {
+ toast.error(formatKiloChatError(err, 'Failed to leave conversation'));
+ },
+ }
);
- if (params?.conversationId === conversationId) {
- router.push(basePath);
- }
- leaveConversation.mutate(conversationId, {
- onSettled: () => setLeavingConversationId(null),
- onError: err => {
- // Restore the row on failure so the user can retry
- for (const [key, data] of previous) {
- queryClient.setQueryData(key, data);
- }
- toast.error(formatKiloChatError(err, 'Failed to leave conversation'));
- },
- });
},
- [leaveConversation.mutate, params?.conversationId, queryClient, router, basePath]
+ [sandboxId, leaveConversation.mutate, params?.conversationId, router, basePath]
);
const handleNewConversation = useCallback(() => {
- if (!sandboxId) return;
+ if (!sandboxId || createConversation.isPending) return;
+ setNewConversationError(null);
createConversation.mutate(
{ sandboxId },
{
onSuccess: res => {
+ setNewConversationError(null);
router.push(`${basePath}/${res.conversationId}`);
},
- onError: err => toast.error(formatKiloChatError(err, 'Failed to create conversation')),
+ onError: err => {
+ const message = formatKiloChatError(
+ err,
+ "Couldn't create conversation. Check your connection and try again."
+ );
+ setNewConversationError(message);
+ toast.error(message);
+ },
}
);
- }, [sandboxId, basePath, createConversation.mutate, router]);
+ }, [
+ sandboxId,
+ basePath,
+ createConversation.isPending,
+ createConversation.mutate,
+ router,
+ setNewConversationError,
+ ]);
const contextValue = useMemo(
() => ({
- getToken,
currentUserId,
instanceStatus,
leavingConversationId,
@@ -198,11 +165,13 @@ export function KiloChatLayout({
basePath,
noInstanceRedirect,
isInstanceLoading,
+ isInstanceError,
+ instanceErrorMessage,
+ onRetryInstanceStatus,
eventService,
kiloChatClient,
}),
[
- getToken,
currentUserId,
instanceStatus,
leavingConversationId,
@@ -211,6 +180,9 @@ export function KiloChatLayout({
basePath,
noInstanceRedirect,
isInstanceLoading,
+ isInstanceError,
+ instanceErrorMessage,
+ onRetryInstanceStatus,
eventService,
kiloChatClient,
]
@@ -218,7 +190,7 @@ export function KiloChatLayout({
return (
-
+
{/* Conversation sidebar */}
void fetchNextPage()}
onNewConversation={handleNewConversation}
onRename={handleRename}
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx
new file mode 100644
index 0000000000..a557562668
--- /dev/null
+++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatStatusError.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { AlertCircle, RefreshCw } from 'lucide-react';
+
+type KiloChatStatusErrorProps = {
+ message: string | null;
+ onRetry: () => void;
+};
+
+export function KiloChatStatusError({ message, onRetry }: KiloChatStatusErrorProps) {
+ return (
+
+
+
+
+
Failed to load status
+
{message ?? 'Please try again.'}
+
+
+
+
+ );
+}
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 66beb8b8c7..cef6ca9f0c 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
@@ -3,7 +3,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { ulid } from 'ulid';
-import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat';
+import type {
+ Message,
+ ContentBlock,
+ EditMessageRequest,
+ ExecApprovalDecision,
+} from '@kilocode/kilo-chat';
import {
useMessages,
useSendMessage,
@@ -13,13 +18,23 @@ import {
useAddReaction,
useRemoveReaction,
useExecuteAction,
+ latestMarkReadMessageId,
} from '../hooks/useMessages';
-import { useConversationContext } from '../hooks/useEventService';
+import {
+ kiloclawConversationContext,
+ presenceContextForConversation,
+} from '@kilocode/event-service';
+import { useDocumentVisible } from '@/hooks/useDocumentVisible';
import { useTypingSender, useTypingState } from '../hooks/useTyping';
import {
+ createMarkReadState,
+ finishMarkReadAttempt,
useConversationDetail,
useRenameConversation,
useMarkConversationRead,
+ shouldStartMarkReadAttempt,
+ startMarkReadAttempt,
+ succeedMarkReadAttempt,
} from '../hooks/useConversations';
import { useKiloChatContext } from './kiloChatContext';
import { toast } from 'sonner';
@@ -30,17 +45,47 @@ import { BotStatus, computeBotDisplay, useNowTicker } from './BotStatus';
import { ContextUsageRing } from './ContextUsageRing';
import { useBotStatus } from '../hooks/useBotStatus';
import { useConversationStatus } from '../hooks/useConversationStatus';
+import {
+ clearMarkReadRetry,
+ createMarkReadRetryState,
+ scheduleMarkReadRetry,
+ usePresenceSubscription,
+} from '@kilocode/kilo-chat-hooks';
import {
KiloChatApiError,
formatKiloChatError,
CONVERSATION_TITLE_MAX_CHARS,
} from '@kilocode/kilo-chat';
+import {
+ clearPendingAction,
+ pendingActionGroupIdForMessage,
+ tryStartPendingAction,
+ type PendingAction,
+} from '@kilocode/kilo-chat-hooks';
+import {
+ applyPrependScrollAnchor,
+ capturePrependScrollAnchor,
+ type PrependScrollAnchorSnapshot,
+} from './message-scroll-anchor';
import { MessageCircle, ArrowDown } from 'lucide-react';
type MessageAreaProps = {
conversationId: string;
};
+function toEditableContent(content: ContentBlock[]): EditMessageRequest['content'] {
+ return content.map(block => {
+ if (block.type === 'actions') {
+ return {
+ type: 'actions',
+ groupId: block.groupId,
+ actions: block.actions,
+ };
+ }
+ return block;
+ });
+}
+
export function MessageArea({ conversationId }: MessageAreaProps) {
const { currentUserId, instanceStatus, assistantName, sandboxId, eventService, kiloChatClient } =
useKiloChatContext();
@@ -59,35 +104,57 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
// which is a normal steady state. Only block sends once the bot is clearly
// `offline` (>90 s stale, explicitly offline, or instance not running) or
// `unknown` (no presence data at all).
- const canSend = botDisplay.state === 'online' || botDisplay.state === 'idle';
+ const botCanSend = botDisplay.state === 'online' || botDisplay.state === 'idle';
+ const canSend = currentUserId !== null && botCanSend;
const sendDisabledReason = canSend
? null
- : botDisplay.state === 'unknown'
- ? 'Waiting for bot status…'
- : 'Bot is offline — messages will resume when it reconnects';
+ : currentUserId === null
+ ? 'Loading user...'
+ : botDisplay.state === 'unknown'
+ ? 'Waiting for bot status…'
+ : 'Bot is offline — messages will resume when it reconnects';
const scrollRef = useRef(null);
const contentRef = useRef(null);
const autoScrollRef = useRef(true);
+ const pendingPrependScrollAnchorRef = useRef(null);
+ const wasFetchingNextPageRef = useRef(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [replyingTo, setReplyingTo] = useState(null);
const [pendingDeleteId, setPendingDeleteId] = useState(null);
const [isRenamingTitle, setIsRenamingTitle] = useState(false);
const [renameText, setRenameText] = useState('');
+ const [pendingAction, setPendingAction] = useState(null);
+ const pendingActionRef = useRef(null);
+
+ const visible = useDocumentVisible();
+
+ // Subscribe to this conversation's chat-event stream while the conversation
+ // is open. Not gated on visibility — we want incoming messages to land in
+ // the cache even when the tab is hidden.
+ usePresenceSubscription(
+ sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null,
+ Boolean(sandboxId && conversationId)
+ );
- // Subscribe to this conversation's events via the event-service WebSocket
- useConversationContext(eventService, sandboxId, conversationId);
+ // Signal our own presence on this conversation. Gated on visibility so we
+ // only appear "viewing" while the tab is actually in the foreground.
+ usePresenceSubscription(
+ sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null,
+ Boolean(sandboxId && conversationId) && visible
+ );
// Event Service delivers subscribed contexts to every handler, so each
// handler must validate the incoming `ctx` against this string before
// applying changes to the active conversation's state.
- const expectedContext = sandboxId ? `/kiloclaw/${sandboxId}/${conversationId}` : null;
+ const expectedContext = sandboxId ? kiloclawConversationContext(sandboxId, conversationId) : null;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useMessages(
kiloChatClient,
conversationId
);
const messages = data?.messages ?? [];
+ const latestMessageId = latestMarkReadMessageId(messages);
const conversationDetail = useConversationDetail(kiloChatClient, conversationId);
const renameConversation = useRenameConversation(kiloChatClient);
@@ -108,27 +175,95 @@ 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);
+ const handleActionFailed = useCallback(() => {
+ toast.error("Couldn't reach the bot — please try again");
+ }, []);
+ const handleMessageDeliveryFailed = useCallback(() => {
+ toast.error('Message could not be delivered to the bot');
+ }, []);
+ useMessageCacheUpdater(
+ kiloChatClient,
+ sandboxId,
+ conversationId,
+ clearTypingForMember,
+ handleActionFailed,
+ handleMessageDeliveryFailed
+ );
const sendTyping = useTypingSender(kiloChatClient, conversationId);
const markRead = useMarkConversationRead(kiloChatClient);
- const lastMarkedRef = useRef(null);
+ const markReadStateRef = useRef(createMarkReadState());
+ const markReadRetryStateRef = useRef(createMarkReadRetryState());
+ const currentMarkReadMarker =
+ latestMessageId === null ? null : `${conversationId}:${latestMessageId}`;
+ const currentMarkReadMarkerRef = useRef(currentMarkReadMarker);
+ const visibleRef = useRef(visible);
+ const markCurrentConversationReadRef = useRef<() => void>(() => {});
+ currentMarkReadMarkerRef.current = currentMarkReadMarker;
+ visibleRef.current = visible;
+
+ const markCurrentConversationRead = useCallback(() => {
+ if (latestMessageId === null || currentMarkReadMarker === null) {
+ return;
+ }
+ const marker = currentMarkReadMarker;
+ const state = markReadStateRef.current;
+ if (!shouldStartMarkReadAttempt(state, marker)) {
+ return;
+ }
+ startMarkReadAttempt(state, marker);
+ markRead.mutate(
+ { sandboxId, conversationId, lastSeenMessageId: latestMessageId },
+ {
+ onSuccess: () => {
+ succeedMarkReadAttempt(state, marker);
+ clearMarkReadRetry(markReadRetryStateRef.current);
+ },
+ onSettled: () => {
+ finishMarkReadAttempt(state, marker);
+ if (state.lastSucceededMarker !== marker) {
+ scheduleMarkReadRetry(markReadRetryStateRef.current, {
+ marker,
+ currentMarker: () => currentMarkReadMarkerRef.current,
+ isActive: () => visibleRef.current,
+ lastSucceededMarker: () => markReadStateRef.current.lastSucceededMarker,
+ retry: () => markCurrentConversationReadRef.current(),
+ });
+ }
+ },
+ }
+ );
+ }, [conversationId, currentMarkReadMarker, latestMessageId, markRead.mutate, sandboxId]);
+ markCurrentConversationReadRef.current = markCurrentConversationRead;
+
+ useEffect(() => {
+ if (!visible || currentMarkReadMarker === null) {
+ clearMarkReadRetry(markReadRetryStateRef.current);
+ return;
+ }
+ if (
+ markReadRetryStateRef.current.marker !== null &&
+ markReadRetryStateRef.current.marker !== currentMarkReadMarker
+ ) {
+ clearMarkReadRetry(markReadRetryStateRef.current);
+ }
+ }, [currentMarkReadMarker, visible]);
+
+ useEffect(() => {
+ return () => clearMarkReadRetry(markReadRetryStateRef.current);
+ }, []);
- // Mark conversation as read when opened. react-query's mutate is stable
- // across renders, so including it in deps is safe.
+ // Mark conversation as read when opened and whenever visible hydration or
+ // realtime receipt advances the newest message.
useEffect(() => {
- if (lastMarkedRef.current === conversationId) return;
- lastMarkedRef.current = conversationId;
- markRead.mutate(conversationId);
- }, [conversationId, markRead.mutate]);
+ if (!visible) return;
+ markCurrentConversationRead();
+ }, [markCurrentConversationRead, visible]);
// Register side-effect handlers that don't mutate the message cache
// (cache updates are handled by useMessageCacheUpdater).
useEffect(() => {
const offs = [
- kiloChatClient.onMessageDeliveryFailed(() => {
- toast.error('Message could not be delivered to the bot');
- }),
kiloChatClient.onTyping((ctx, data) => {
handleTypingEvent(ctx, data);
}),
@@ -143,8 +278,11 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
useEffect(() => {
return eventService.onReconnect(() => {
void queryClient.invalidateQueries({ queryKey: ['kilo-chat', 'messages', conversationId] });
+ if (visible) {
+ markCurrentConversationRead();
+ }
});
- }, [eventService, queryClient, conversationId]);
+ }, [conversationId, eventService, markCurrentConversationRead, queryClient, visible]);
// Auto-scroll whenever content height changes (new messages, streaming
// updates, image loads). A ResizeObserver on the inner content fires only
@@ -163,6 +301,29 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
return () => observer.disconnect();
}, []);
+ useEffect(() => {
+ const wasFetchingNextPage = wasFetchingNextPageRef.current;
+ wasFetchingNextPageRef.current = isFetchingNextPage;
+
+ if (!wasFetchingNextPage || isFetchingNextPage) {
+ return;
+ }
+
+ const snapshot = pendingPrependScrollAnchorRef.current;
+ pendingPrependScrollAnchorRef.current = null;
+ if (!snapshot) {
+ return;
+ }
+
+ const frameId = requestAnimationFrame(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ applyPrependScrollAnchor(el, snapshot);
+ });
+
+ return () => cancelAnimationFrame(frameId);
+ }, [isFetchingNextPage]);
+
// Track scroll position to detect user scrolling away from bottom
function handleScroll() {
const el = scrollRef.current;
@@ -170,6 +331,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
// Load more on scroll to top
if (el.scrollTop < 50 && hasNextPage && !isFetchingNextPage) {
+ pendingPrependScrollAnchorRef.current = capturePrependScrollAnchor(el);
void fetchNextPage();
}
@@ -192,38 +354,45 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
}
const handleSend = useCallback(
- (text: string, inReplyToMessageId?: string) => {
+ async (text: string, inReplyToMessageId?: string): Promise => {
autoScrollRef.current = true;
setShowScrollButton(false);
- sendMessage.mutate(
- {
+ try {
+ await sendMessage.mutateAsync({
conversationId,
content: [{ type: 'text', text }],
inReplyToMessageId,
clientId: ulid(),
- },
- { onError: err => toast.error(formatKiloChatError(err, 'Failed to send message')) }
- );
+ });
+ return true;
+ } catch (err) {
+ toast.error(formatKiloChatError(err, 'Failed to send message'));
+ return false;
+ }
},
- [sendMessage.mutate, conversationId]
+ [sendMessage.mutateAsync, conversationId]
);
const handleEdit = useCallback(
- (messageId: string, content: ContentBlock[]) => {
- editMessage.mutate(
- { messageId, conversationId, content, timestamp: Date.now() },
- {
- onError: err => {
- if (err instanceof KiloChatApiError && err.status === 409) {
- toast.error('Message was edited by someone else — please try again');
- return;
- }
- toast.error(formatKiloChatError(err, 'Failed to edit message'));
- },
+ async (messageId: string, content: ContentBlock[]): Promise => {
+ try {
+ await editMessage.mutateAsync({
+ messageId,
+ conversationId,
+ content: toEditableContent(content),
+ timestamp: Date.now(),
+ });
+ return true;
+ } catch (err) {
+ if (err instanceof KiloChatApiError && err.status === 409) {
+ toast.error('Message was edited by someone else — please try again');
+ return false;
}
- );
+ toast.error(formatKiloChatError(err, 'Failed to edit message'));
+ return false;
+ }
},
- [editMessage.mutate, conversationId]
+ [editMessage.mutateAsync, conversationId]
);
const handleDelete = useCallback((messageId: string) => {
@@ -269,9 +438,20 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
const handleExecuteAction = useCallback(
(messageId: string, groupId: string, value: ExecApprovalDecision) => {
+ const nextPendingAction = { messageId, groupId };
+ if (!tryStartPendingAction(pendingActionRef, nextPendingAction)) {
+ return;
+ }
+ setPendingAction(pendingActionRef.current);
executeAction.mutate(
{ messageId, groupId, value },
- { onError: err => toast.error(formatKiloChatError(err, 'Failed to execute action')) }
+ {
+ onError: err => toast.error(formatKiloChatError(err, 'Failed to execute action')),
+ onSettled: () => {
+ clearPendingAction(pendingActionRef, nextPendingAction);
+ setPendingAction(pendingActionRef.current);
+ },
+ }
);
},
[executeAction.mutate]
@@ -291,7 +471,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
const trimmed = renameText.trim();
if (trimmed) {
renameConversation.mutate(
- { conversationId, title: trimmed },
+ { sandboxId, conversationId, title: trimmed },
{ onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) }
);
}
@@ -306,7 +486,7 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
const trimmed = renameText.trim();
if (trimmed && trimmed !== title) {
renameConversation.mutate(
- { conversationId, title: trimmed },
+ { sandboxId, conversationId, title: trimmed },
{ onError: err => toast.error(formatKiloChatError(err, 'Failed to rename conversation')) }
);
}
@@ -382,9 +562,11 @@ export function MessageArea({ conversationId }: MessageAreaProps) {
))}
diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx
index cf24171c9d..0683c56943 100644
--- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx
+++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx
@@ -7,10 +7,23 @@ import { Pencil, Trash2, Reply, X, Check, AlertCircle, Smile, Copy } from 'lucid
import { EmojiQuickPick } from './EmojiQuickPick';
import { EmojiPicker } from './EmojiPicker';
import { ReactionPills } from './ReactionPills';
-import type { Message, ContentBlock, ExecApprovalDecision } from '@kilocode/kilo-chat';
-import { ulidToTimestamp, contentBlocksToText } from '@kilocode/kilo-chat';
+import type {
+ Message,
+ ContentBlock,
+ ExecApprovalDecision,
+ ReplyToMessageSnapshot,
+} from '@kilocode/kilo-chat';
+import {
+ buildMessageActionAvailability,
+ MESSAGE_TEXT_MAX_CHARS,
+ ulidToTimestamp,
+ contentBlocksToText,
+} from '@kilocode/kilo-chat';
import { useKiloChatContext } from './kiloChatContext';
import { toast } from 'sonner';
+import { isMessageEditOverLimit, submitMessageEdit } from './message-edit-state';
+
+const EDIT_COUNTER_SHOW_AT = Math.floor(MESSAGE_TEXT_MAX_CHARS * 0.8);
const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) {
return (
@@ -32,9 +45,9 @@ const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content:
type MessageBubbleProps = {
message: Message;
isOwn: boolean;
- replyToMessage?: Message | null;
+ replyToMessage?: Message | ReplyToMessageSnapshot | null;
pendingDeleteId: string | null;
- onEdit: (messageId: string, content: ContentBlock[]) => void;
+ onEdit: (messageId: string, content: ContentBlock[]) => Promise;
onDelete: (messageId: string) => void;
onConfirmDelete: (messageId: string) => void;
onCancelDelete: () => void;
@@ -42,10 +55,18 @@ type MessageBubbleProps = {
onAddReaction: (messageId: string, emoji: string) => void;
onRemoveReaction: (messageId: string, emoji: string) => void;
onExecuteAction: (messageId: string, groupId: string, value: ExecApprovalDecision) => void;
- actionPending?: boolean;
- currentUserId: string;
+ pendingActionGroupId: string | null;
+ currentUserId: string | null;
};
+function getReplyPreviewText(replyToMessage: Message | ReplyToMessageSnapshot): string {
+ const preview =
+ 'previewText' in replyToMessage
+ ? (replyToMessage.previewText ?? 'Message')
+ : contentBlocksToText(replyToMessage.content);
+ return preview.length > 60 ? `${preview.slice(0, 60)}...` : preview;
+}
+
export const MessageBubble = memo(function MessageBubble({
message,
isOwn,
@@ -59,12 +80,13 @@ export const MessageBubble = memo(function MessageBubble({
onAddReaction,
onRemoveReaction,
onExecuteAction,
- actionPending,
+ pendingActionGroupId,
currentUserId,
}: MessageBubbleProps) {
const { assistantName } = useKiloChatContext();
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState('');
+ const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showActions, setShowActions] = useState(false);
const [showQuickPick, setShowQuickPick] = useState(false);
const [showFullPicker, setShowFullPicker] = useState(false);
@@ -79,36 +101,63 @@ export const MessageBubble = memo(function MessageBubble({
});
const textContent = message.deleted ? '' : contentBlocksToText(message.content);
+ const editOverLimit = isMessageEditOverLimit(editText);
+ const showEditCounter = editText.length >= EDIT_COUNTER_SHOW_AT || editOverLimit;
+ const baseActionAvailability = buildMessageActionAvailability(message, isOwn);
+ const actionAvailability =
+ currentUserId === null
+ ? {
+ canReact: false,
+ canEdit: false,
+ canDelete: false,
+ canReply: false,
+ canExecuteAction: false,
+ }
+ : baseActionAvailability;
const myReactions = new Set(
- message.reactions.filter(r => r.memberIds.includes(currentUserId)).map(r => r.emoji)
+ currentUserId === null
+ ? []
+ : message.reactions.filter(r => r.memberIds.includes(currentUserId)).map(r => r.emoji)
);
function handleStartEdit() {
+ if (!actionAvailability.canEdit) return;
setEditText(textContent);
setIsEditing(true);
}
- function handleSaveEdit() {
- const trimmed = editText.trim();
- if (!trimmed) return;
- // Short-circuit no-op edits so we don't bump updatedAt and flash the
- // "(edited)" label when the user presses Enter without changes.
- if (trimmed === textContent.trim()) {
- setIsEditing(false);
- return;
+ const canSaveEdit =
+ actionAvailability.canEdit && !isSavingEdit && editText.trim().length > 0 && !editOverLimit;
+
+ async function handleSaveEdit() {
+ if (!canSaveEdit) return;
+ setIsSavingEdit(true);
+ try {
+ await submitMessageEdit({
+ messageId: message.id,
+ editText,
+ originalText: textContent,
+ onEdit,
+ closeEditor: () => {
+ setIsEditing(false);
+ setEditText('');
+ },
+ });
+ } finally {
+ setIsSavingEdit(false);
}
- onEdit(message.id, [{ type: 'text', text: trimmed }]);
- setIsEditing(false);
}
function handleCancelEdit() {
setIsEditing(false);
setEditText('');
+ setIsSavingEdit(false);
}
function handleQuickPickSelect(emoji: string) {
setShowQuickPick(false);
+ if (!actionAvailability.canReact) return;
if (myReactions.has(emoji)) {
onRemoveReaction(message.id, emoji);
} else {
@@ -119,6 +168,7 @@ export const MessageBubble = memo(function MessageBubble({
function handleFullPickerSelect(emoji: string) {
setShowFullPicker(false);
setShowQuickPick(false);
+ if (!actionAvailability.canReact) return;
if (myReactions.has(emoji)) {
onRemoveReaction(message.id, emoji);
} else {
@@ -134,13 +184,15 @@ export const MessageBubble = memo(function MessageBubble({
isOwn ? 'right-full mr-1' : 'left-full ml-1'
}`}
>
-
+ {actionAvailability.canReact && (
+
+ )}
- {isOwn && !message.deliveryFailed && (
+ {actionAvailability.canEdit && (
)}
- {isOwn && (
+ {actionAvailability.canDelete && (
)}
- {!message.deliveryFailed && (
+ {actionAvailability.canReply && (