Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions web/src/features/chat/components/virtualized-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 35 additions & 10 deletions web/src/hooks/useSessionStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Comment on lines +883 to +885
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset replay flag when preserving messages on reconnect

When reconnect calls resetState(true, true), this branch skips isReplayingRef.current = true, even though resetState still clears first-turn tracking refs. That makes replayed history events go through processEvent as non-replay events, so TurnBegin sets hasTurnStartedRef and a later idle session_status can fire onFirstTurnComplete again. In this repo, chat-workspace-container wires that callback to generateTitle, so a reconnect on any non-empty session can trigger extra title-generation calls and other non-replay side effects.

Useful? React with 👍 / 👎.

}
// 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
Expand Down Expand Up @@ -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"));

Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Comment on lines +2418 to +2421
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear or dedupe messages before reconnect history replay

This reconnect path preserves messages (resetState(true, true)), but the backend always replays persisted wire history on every WebSocket attach (src/kimi_cli/web/api/sessions.py calls replay_history when history exists). Because processEvent rebuilds chat entries with fresh client-side IDs, replayed turns are appended again instead of merged, so any reconnect (watchdog/network blip/manual reconnect) can duplicate the entire transcript and tool outputs in the UI.

Useful? React with 👍 / 👎.

// First connect: reset everything
resetState(true);
}
Comment on lines +2418 to +2424
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 WebSocket reconnect duplicates all messages because history replay generates new IDs against preserved messages

On reconnect, connect({ isReconnect: true }) at web/src/hooks/useSessionStream.ts:2774 calls resetState(true, true) which preserves existing messages and does NOT reset isReplayingRef.current to true (lines 880-888). When the new WebSocket connects, the server replays all history events. Since isReplayingRef.current remains false, processEvent at line 2166 passes isReplay=false for every replayed event. Each event handler (TurnBegin at line 1083, ContentPart at line 1122, ToolCall at line 1222) calls getNextMessageId() which generates a fresh UUID via createMessageId (web/src/hooks/utils.ts:42). The upsertMessage function (web/src/hooks/useSessionStream.ts:808-816) searches by ID, finds no match against the preserved messages, and appends each replayed event as a brand-new message. The result is every message appearing twice in the UI after any WebSocket reconnection (triggered by network issues, watchdog timeout at line 2468, history_complete timeout at line 2097, etc.).

Before vs after this PR

The old code correctly handled reconnect by clearing messages:

resetState(true);  // old: always reset isReplayingRef to true
setMessages([]);   // old: clear messages before replay

The new code preserves messages but doesn't account for the server replaying history:

resetState(true, true); // preserveMessages=true: skips setMessages([]) AND skips isReplayingRef=true
Prompt for agents
In web/src/hooks/useSessionStream.ts, the reconnect path at lines 2418-2424 calls resetState(true, true) which preserves messages but does NOT set isReplayingRef.current = true. When the server replays history on the new WebSocket, all events generate new client-side message IDs (via createMessageId/uuidV4) that don't match preserved messages, causing upsertMessage to append duplicates.

To fix this, you need one of these approaches:

1. (Simplest) In the resetState function around lines 880-888, when preserveMessages=true, still set isReplayingRef.current = true so that the server's history replay is processed with isReplay=true. Then after history_complete arrives, clear the old preserved messages and replace them with the freshly replayed ones. This requires tracking which messages came from the new replay vs the old preserved set.

2. (Alternative) Keep clearing messages on reconnect (revert to old behavior) and instead fix the scroll jump issue in VirtualizedMessageList separately — for example, by saving and restoring the scroll position after replay completes rather than trying to preserve the message array.

3. (Alternative) On reconnect, set isReplayingRef.current = true AND clear messages in resetState (like the old code), but immediately re-set isReplayingHistory to false after clearing so the VirtualizedMessageList doesn't trigger a scroll reset. The key={conversationKey} on Virtuoso won't change (same session), so scroll position may be naturally preserved.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


setStatus("submitted");
setAwaitingFirstResponse(Boolean(pendingMessageRef.current));

Expand Down Expand Up @@ -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;
Expand Down