From 258a79d6c4a3dc79a0e937a2d7d9208c63f9271d Mon Sep 17 00:00:00 2001 From: zhaoguoan Date: Tue, 31 Mar 2026 10:33:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20=E4=BC=98=E5=8C=96=20WebSocket?= =?UTF-8?q?=20=E9=87=8D=E8=BF=9E=E4=BD=93=E9=AA=8C=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重连时保留现有消息,不清空消息列表 - 重连时保持 isReplayingHistory 状态不变,避免触发滚动 - history_complete 时设置 isReplayingHistory(false) - 优化 VirtualizedMessageList 的 initialTopMostItemIndex 计算 - 添加静态文件到构建配置 --- pyproject.toml | 3 ++ .../components/virtualized-message-list.tsx | 16 +++++-- web/src/hooks/useSessionStream.ts | 45 ++++++++++++++----- 3 files changed, 50 insertions(+), 14 deletions(-) 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; From 6ed2c2c8b361b99cc4b916fd5037f46fed1d0af1 Mon Sep 17 00:00:00 2001 From: zhaoguoan Date: Tue, 31 Mar 2026 10:47:22 +0800 Subject: [PATCH 2/2] docs: update changelog for WebSocket reconnection fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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