diff --git a/CHANGELOG.md b/CHANGELOG.md index 41854e243..a2d66c8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Only write entries that are worth mentioning to users. - Core: Add OS and shell information to the system prompt — the model now knows which platform it is running on and receives a Windows-specific instruction to prefer built-in tools over Shell commands, preventing Linux command errors in PowerShell - Shell: Fix `command` parameter description saying "bash command" regardless of platform — the description is now platform-neutral - Web: Fix auto-title overwriting manual session rename — when a user renames a session through the web UI, the new title is now preserved and no longer replaced by the auto-generated title +- Web: Fix WebSocket reconnection causing message list scroll jump — messages are now preserved during reconnection instead of being cleared; `isReplayingHistory` state is maintained to prevent layout shifts; `history_complete` properly sets the replay state to prevent subsequent scrolling ## 1.28.0 (2026-03-30) - Core: Fix file write/replace tools freezing the event loop — diff computation (`build_diff_blocks`) is now offloaded to a thread via `asyncio.to_thread`, preventing the UI from hanging when editing large files diff --git a/pyproject.toml b/pyproject.toml index 7abaf7302..d2d91ae93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ build-backend = "uv_build" module-name = ["kimi_cli"] source-exclude = ["examples/**/*", "tests/**/*", "src/kimi_cli/deps/**/*"] +[tool.hatch.build.targets.wheel] +include = ["src/kimi_cli/web/static/**/*"] + [tool.uv.workspace] members = [ "packages/kosong", diff --git a/web/src/features/chat/components/virtualized-message-list.tsx b/web/src/features/chat/components/virtualized-message-list.tsx index 9e93dc3ad..6f0638121 100644 --- a/web/src/features/chat/components/virtualized-message-list.tsx +++ b/web/src/features/chat/components/virtualized-message-list.tsx @@ -174,6 +174,17 @@ function VirtualizedMessageListComponent( [filteredMessages], ); + // Memoize initial scroll position to prevent re-scrolling on re-renders + const initialTopMostItemIndex = useMemo( + () => ({ + index: Math.max(0, listItems.length - 1), + align: "end" as const, + }), + // Only recompute when conversation changes (new session), not on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [conversationKey], + ); + const handleAtBottomChange = useCallback( (atBottom: boolean) => { onAtBottomChange?.(atBottom); @@ -245,10 +256,7 @@ function VirtualizedMessageListComponent( overscan={200} minOverscanItemCount={4} atBottomStateChange={handleAtBottomChange} - initialTopMostItemIndex={{ - index: Math.max(0, listItems.length - 1), - align: "end", - }} + initialTopMostItemIndex={initialTopMostItemIndex} components={{ Scroller: VirtuosoScroller, List: VirtuosoList, diff --git a/web/src/hooks/useSessionStream.ts b/web/src/hooks/useSessionStream.ts index 8772d7aa4..8519376f3 100644 --- a/web/src/hooks/useSessionStream.ts +++ b/web/src/hooks/useSessionStream.ts @@ -190,6 +190,8 @@ type UseSessionStreamOptions = { onSessionStatus?: (status: SessionStatus) => void; /** Callback when first turn is complete (for auto-renaming) */ onFirstTurnComplete?: () => void; + /** Callback when reconnecting (to refresh external data like session list) */ + onReconnect?: () => void; }; type UseSessionStreamReturn = { @@ -842,7 +844,7 @@ export function useSessionStream( }, []); // Reset all state - const resetState = useCallback((preserveSlashCommands = false) => { + const resetState = useCallback((preserveSlashCommands = false, preserveMessages = false) => { resetStepState(); currentToolCallsRef.current?.clear(); currentToolCallIdRef.current = null; @@ -856,8 +858,6 @@ export function useSessionStream( setError(null); setSessionStatus(null); lastStatusSeqRef.current = null; - isReplayingRef.current = true; - setIsReplayingHistory(true); setAwaitingFirstResponse(false); // Reset first turn tracking hasTurnStartedRef.current = false; @@ -877,6 +877,15 @@ export function useSessionStream( } else if (slashCommandsLenRef.current > 0) { usingCachedCommandsRef.current = true; } + // Handle messages: preserve or clear + if (!preserveMessages) { + setMessages([]); + // Only reset replay state when clearing messages + isReplayingRef.current = true; + setIsReplayingHistory(true); + } + // Note: when preserveMessages=true (reconnect), we keep isReplayingHistory unchanged + // to avoid triggering scroll in VirtualizedMessageList }, [resetStepState, setAwaitingFirstResponse]); // Process a SubagentEvent: accumulate inner events into parent Agent tool's subagentSteps @@ -2081,6 +2090,8 @@ export function useSessionStream( "[SessionStream] History loaded, waiting for environment...", ); isReplayingRef.current = false; + // Set isReplayingHistory to false to prevent layout shifts on reconnect + setIsReplayingHistory(false); // Keep status as "submitted" - input stays disabled until session_status setStatus((current) => (current === "ready" ? current : "submitted")); @@ -2380,15 +2391,20 @@ export function useSessionStream( [updateMessageById], ); - // Connect to WebSocket - const connect = useCallback(() => { + // Connect options type +type ConnectOptions = { + isReconnect?: boolean; +}; + +// Connect to WebSocket + const connect = useCallback((options?: ConnectOptions) => { if (!sessionId) return; + const isReconnect = options?.isReconnect ?? false; initializeRetryCountRef.current = 0; // Reset retry count for new connection // Close existing connection if (wsRef.current) { - console.log("[SessionStream] Closing existing WebSocket"); wsRef.current.close(); wsRef.current = null; } @@ -2398,8 +2414,15 @@ export function useSessionStream( } awaitingIdleRef.current = false; - resetState(true); // preserve slashCommands on reconnect - setMessages([]); + + if (isReconnect) { + // Reconnect: preserve messages and slash commands + resetState(true, true); + } else { + // First connect: reset everything + resetState(true); + } + setStatus("submitted"); setAwaitingFirstResponse(Boolean(pendingMessageRef.current)); @@ -2748,9 +2771,11 @@ export function useSessionStream( disconnect(); // Small delay before reconnecting reconnectTimeoutRef.current = window.setTimeout(() => { - connect(); + connect({ isReconnect: true }); + // Trigger reconnect callback to refresh external data (e.g., session list) + options.onReconnect?.(); }, 100); - }, [disconnect, connect]); + }, [disconnect, connect, options.onReconnect]); // Keep refs in sync so useLayoutEffect can use stable references connectRef.current = connect;