From fd6035a3522900b595f6e00b0253105e335d206e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 28 Apr 2026 14:54:29 +0800 Subject: [PATCH 1/4] fix(web): allow state-only session edits while busy --- src/kimi_cli/web/api/sessions.py | 50 +++++++++++---- tests/web/test_sessions_api.py | 104 +++++++++++++++++++++++++++++++ web/src/hooks/useSessions.ts | 14 +++++ 3 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 tests/web/test_sessions_api.py diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 205d423305..008db368b1 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -106,18 +106,30 @@ def get_runner_ws(ws: WebSocket) -> KimiCLIRunner: return ws.app.state.runner -def get_editable_session( - session_id: UUID, - runner: KimiCLIRunner, -) -> JointSession: - """Get a session and verify it's not busy.""" +def get_session_or_404(session_id: UUID) -> JointSession: + """Load a session by id, raising 404 if it doesn't exist.""" session = load_session_by_id(session_id) if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", ) - # Check if session is busy + return session + + +def get_editable_session( + session_id: UUID, + runner: KimiCLIRunner, +) -> JointSession: + """Get a session and verify it's not busy. + + Use for operations that cannot run concurrently with a live worker + (delete, fork, upload). For state-only edits (rename, archive, + generate-title) the worker merges externally-mutable fields back + via ``Session.save_state``; those endpoints can use + :func:`get_session_or_404` instead. + """ + session = get_session_or_404(session_id) session_process = runner.get_session(session_id) if session_process and session_process.is_busy: raise HTTPException( @@ -586,12 +598,16 @@ async def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_r async def update_session( session_id: UUID, request: UpdateSessionRequest, - runner: KimiCLIRunner = Depends(get_runner), ) -> Session: - """Update a session (e.g., rename title or archive/unarchive).""" + """Update a session (e.g., rename title or archive/unarchive). + + Safe to invoke while a worker is running: only externally-mutable + fields are touched, and ``Session.save_state`` reloads them from + disk before each worker write. + """ from kimi_cli.session_state import load_session_state, save_session_state - session = get_editable_session(session_id, runner) + session = get_session_or_404(session_id) session_dir = session.kimi_cli_session.dir state = load_session_state(session_dir) @@ -749,14 +765,16 @@ async def fork_session_endpoint( async def generate_session_title( session_id: UUID, request: GenerateTitleRequest | None = None, - runner: KimiCLIRunner = Depends(get_runner), ) -> GenerateTitleResponse: """Generate a concise session title using AI based on the first conversation turn. If request body is empty or parameters are missing, the backend will automatically read the first turn from wire.jsonl. + + Safe to invoke while a worker is running: the final write reloads + state from disk to merge concurrent worker changes. """ - session = get_editable_session(session_id, runner) + session = get_session_or_404(session_id) session_dir = session.kimi_cli_session.dir from kimi_cli.session_state import load_session_state, save_session_state @@ -789,6 +807,11 @@ async def generate_session_title( # If AI generation failed too many times, use fallback and mark as generated if state.title_generate_attempts >= 3: fresh = load_session_state(session_dir) + # Respect a title finalized by another request/user action while we + # were preparing a fallback. + if fresh.title_generated: + invalidate_sessions_cache() + return GenerateTitleResponse(title=fresh.custom_title or "Untitled") fresh.custom_title = fallback_title fresh.title_generated = True save_session_state(fresh, session_dir) @@ -856,6 +879,11 @@ async def generate_session_title( # Read-modify-write: reload fresh state to avoid overwriting # worker changes made during the LLM call fresh = load_session_state(session_dir) + # Another request or manual rename may have finalized the title while the + # LLM call was in flight. Preserve that newer title instead of clobbering it. + if fresh.title_generated: + invalidate_sessions_cache() + return GenerateTitleResponse(title=fresh.custom_title or "Untitled") fresh.custom_title = title if ai_generated: fresh.title_generated = True diff --git a/tests/web/test_sessions_api.py b/tests/web/test_sessions_api.py new file mode 100644 index 0000000000..4a609c9d19 --- /dev/null +++ b/tests/web/test_sessions_api.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from uuid import UUID + +import pytest +from kaos.path import KaosPath + +from kimi_cli.session import Session +from kimi_cli.session_state import load_session_state, save_session_state +from kimi_cli.web.api import sessions as sessions_api +from kimi_cli.web.models import GenerateTitleRequest + + +@pytest.fixture +def isolated_share_dir(monkeypatch, tmp_path: Path) -> Path: + share_dir = tmp_path / "share" + share_dir.mkdir() + + def _get_share_dir() -> Path: + share_dir.mkdir(parents=True, exist_ok=True) + return share_dir + + monkeypatch.setattr("kimi_cli.share.get_share_dir", _get_share_dir) + monkeypatch.setattr("kimi_cli.metadata.get_share_dir", _get_share_dir) + return share_dir + + +@pytest.fixture +def work_dir(tmp_path: Path) -> KaosPath: + path = tmp_path / "work" + path.mkdir() + return KaosPath.unsafe_from_local_path(path) + + +class _FakeOAuthManager: + def __init__(self, _config: object) -> None: + pass + + async def ensure_fresh(self) -> None: + return None + + +class _FakeLLM: + chat_provider = object() + + +class _FakeMessage: + def __init__(self, text: str) -> None: + self._text = text + + def extract_text(self) -> str: + return self._text + + +class _FakeResult: + def __init__(self, text: str) -> None: + self.message = _FakeMessage(text) + + +@pytest.mark.anyio +async def test_generate_title_preserves_concurrent_manual_title( + isolated_share_dir: Path, + work_dir: KaosPath, + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = await Session.create(work_dir) + + config = SimpleNamespace( + default_model="test-model", + models={"test-model": SimpleNamespace(provider="test-provider")}, + providers={"test-provider": object()}, + ) + + monkeypatch.setattr("kimi_cli.config.load_config", lambda: config) + monkeypatch.setattr( + "kimi_cli.llm.create_llm", + lambda provider_config, model_config, oauth=None: _FakeLLM(), + ) + monkeypatch.setattr("kimi_cli.auth.oauth.OAuthManager", _FakeOAuthManager) + + async def fake_generate(*, chat_provider, system_prompt, tools, history): + state = load_session_state(session.dir) + state.custom_title = "Manual Title" + state.title_generated = True + save_session_state(state, session.dir) + return _FakeResult("AI Title") + + monkeypatch.setattr("kosong.generate", fake_generate) + + response = await sessions_api.generate_session_title( + UUID(session.id), + GenerateTitleRequest( + user_message="debug the flaky web session rename issue", + assistant_response="I'll inspect the session state writes.", + ), + ) + + state = load_session_state(session.dir) + assert response.title == "Manual Title" + assert state.custom_title == "Manual Title" + assert state.title_generated is True + assert state.title_generate_attempts == 0 diff --git a/web/src/hooks/useSessions.ts b/web/src/hooks/useSessions.ts index 8a47d3aa26..78f2193814 100644 --- a/web/src/hooks/useSessions.ts +++ b/web/src/hooks/useSessions.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; import type { Session, UploadSessionFileResponse, @@ -512,6 +513,7 @@ export function useSessions(): UseSessionsReturn { const message = err instanceof Error ? err.message : "Failed to delete session"; setError(message); + toast.error(message); return false; } finally { setIsLoading(false); @@ -703,7 +705,10 @@ export function useSessions(): UseSessionsReturn { await refreshSession(sessionId); return true; } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to rename session"; console.error("Failed to rename session:", err); + toast.error(message); return false; } }, @@ -740,7 +745,10 @@ export function useSessions(): UseSessionsReturn { await refreshSession(sessionId); return result.title; } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to generate title"; console.error("Failed to generate title:", err); + toast.error(message); return null; } }, @@ -790,7 +798,10 @@ export function useSessions(): UseSessionsReturn { return true; } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to archive session"; console.error("Failed to archive session:", err); + toast.error(message); return false; } }, @@ -831,7 +842,10 @@ export function useSessions(): UseSessionsReturn { return true; } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to unarchive session"; console.error("Failed to unarchive session:", err); + toast.error(message); return false; } }, From f4a7afecf8c5a3de282fae312c1f59574dad2d54 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 28 Apr 2026 15:02:36 +0800 Subject: [PATCH 2/4] docs(changelog): update release notes for busy session edits --- CHANGELOG.md | 2 ++ docs/en/release-notes/changelog.md | 2 ++ docs/zh/release-notes/changelog.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc077ff1d..140f1ba33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight +- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures - Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response - Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary - Shell: Fix `/usage` remaining quota rendering — the progress bar, warning colors, and `% left` label now all use the remaining quota ratio consistently, so high remaining quota shows as green/full and near-exhausted quota shows as yellow or red diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 15598d1e42..7c918e924b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight +- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures - Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response - Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary - Shell: Fix `/usage` remaining quota rendering — the progress bar, warning colors, and `% left` label now all use the remaining quota ratio consistently, so high remaining quota shows as green/full and near-exhausted quota shows as yellow or red diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 36c1424cd9..510bc8d90a 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Web:修复会话处于忙碌状态时重命名、归档和生成标题被错误拒绝的问题——这些仅修改状态的操作现在会绕过忙碌检查,并通过从磁盘重新加载状态来安全合并并发 worker 的变更。标题生成现在也会保留在 LLM 调用过程中被其它请求最终确定的标题 +- Web:在 Web UI 中为会话重命名、归档、取消归档、删除和标题生成失败添加 toast 错误提示 - Core:修复审批请求 5 分钟自动超时并被误报为 `Rejected by user` 的问题;现在活跃的前台和子 Agent 审批请求都会无限等待用户响应 - Core:修复上下文压缩后 yolo 模式提示词丢失的问题——当 yolo 模式处于激活状态时,非交互模式下的指导提示(不要调用 AskUserQuestion、计划模式切换自动批准等)现在会在每次上下文压缩后的第一个 LLM 步骤重新注入,而不是在原始提示被折叠进压缩摘要后静默消失 - Shell:修复 `/usage` 剩余额度渲染错误——进度条、告警颜色和 `% left` 文案现在都统一基于剩余额度比例计算,剩余额度充足时显示为绿色满格,接近耗尽时显示为黄色或红色 From 0a3924d909af860b12b4df7256c6040583f0d6cf Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 28 Apr 2026 16:16:26 +0800 Subject: [PATCH 3/4] fix(web): preserve manual session titles --- CHANGELOG.md | 4 +-- docs/en/release-notes/changelog.md | 4 +-- docs/zh/release-notes/changelog.md | 4 +-- src/kimi_cli/web/api/sessions.py | 40 ++++++++---------------------- tests/web/test_sessions_api.py | 12 +++++++++ web/src/hooks/useSessions.ts | 1 - 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dedec558d..9ba6522401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased -- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight -- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures +- Web: Fix AI title generation overwriting a manually-set title when the LLM call finishes after the user has already renamed the session — the final write now reloads state and yields to a `title_generated` flag set by another request +- Web: Surface session rename, archive, unarchive, and title generation failures as toast notifications instead of only logging to the console - Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error - Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response - Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 73dc587cad..2e00a3db06 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,8 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased -- Web: Fix session rename, archive, and title generation being incorrectly rejected when the session is busy — these state-only edits now bypass the busy check and safely merge concurrent worker changes via on-disk state reloads. Title generation also preserves a title finalized by another request while the LLM call was in flight -- Web: Show toast error notifications in the web UI for session rename, archive, unarchive, delete, and title generation failures +- Web: Fix AI title generation overwriting a manually-set title when the LLM call finishes after the user has already renamed the session — the final write now reloads state and yields to a `title_generated` flag set by another request +- Web: Surface session rename, archive, unarchive, and title generation failures as toast notifications instead of only logging to the console - Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error - Core: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response - Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index fd00c7952c..d0c5d28c1c 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,8 +4,8 @@ ## 未发布 -- Web:修复会话处于忙碌状态时重命名、归档和生成标题被错误拒绝的问题——这些仅修改状态的操作现在会绕过忙碌检查,并通过从磁盘重新加载状态来安全合并并发 worker 的变更。标题生成现在也会保留在 LLM 调用过程中被其它请求最终确定的标题 -- Web:在 Web UI 中为会话重命名、归档、取消归档、删除和标题生成失败添加 toast 错误提示 +- Web:修复 AI 标题生成在用户已手动重命名后才返回时覆盖手动标题的问题——最终写入前会重新读取状态,若另一请求已将 `title_generated` 标记为完成,则尊重新标题不再覆盖 +- Web:会话重命名、归档、取消归档、生成标题失败时弹出 toast 提示,而不仅仅是记录到 console - Kosong:修复 Kimi 供应商在 OAuth 令牌刷新后仍使用过期的 API 密钥的问题——`on_retryable_error` 现在从当前 client 读取 `api_key`,而不是缓存的 `_api_key`,因此在可重试错误后重建 client 时会保留通过 `client.api_key` 应用的 OAuth 令牌刷新 - Core:修复审批请求 5 分钟自动超时并被误报为 `Rejected by user` 的问题;现在活跃的前台和子 Agent 审批请求都会无限等待用户响应 - Core:修复上下文压缩后 yolo 模式提示词丢失的问题——当 yolo 模式处于激活状态时,非交互模式下的指导提示(不要调用 AskUserQuestion、计划模式切换自动批准等)现在会在每次上下文压缩后的第一个 LLM 步骤重新注入,而不是在原始提示被折叠进压缩摘要后静默消失 diff --git a/src/kimi_cli/web/api/sessions.py b/src/kimi_cli/web/api/sessions.py index 008db368b1..b582f28a0b 100644 --- a/src/kimi_cli/web/api/sessions.py +++ b/src/kimi_cli/web/api/sessions.py @@ -106,30 +106,18 @@ def get_runner_ws(ws: WebSocket) -> KimiCLIRunner: return ws.app.state.runner -def get_session_or_404(session_id: UUID) -> JointSession: - """Load a session by id, raising 404 if it doesn't exist.""" +def get_editable_session( + session_id: UUID, + runner: KimiCLIRunner, +) -> JointSession: + """Get a session and verify it's not busy.""" session = load_session_by_id(session_id) if session is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", ) - return session - - -def get_editable_session( - session_id: UUID, - runner: KimiCLIRunner, -) -> JointSession: - """Get a session and verify it's not busy. - - Use for operations that cannot run concurrently with a live worker - (delete, fork, upload). For state-only edits (rename, archive, - generate-title) the worker merges externally-mutable fields back - via ``Session.save_state``; those endpoints can use - :func:`get_session_or_404` instead. - """ - session = get_session_or_404(session_id) + # Check if session is busy session_process = runner.get_session(session_id) if session_process and session_process.is_busy: raise HTTPException( @@ -598,16 +586,12 @@ async def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_r async def update_session( session_id: UUID, request: UpdateSessionRequest, + runner: KimiCLIRunner = Depends(get_runner), ) -> Session: - """Update a session (e.g., rename title or archive/unarchive). - - Safe to invoke while a worker is running: only externally-mutable - fields are touched, and ``Session.save_state`` reloads them from - disk before each worker write. - """ + """Update a session (e.g., rename title or archive/unarchive).""" from kimi_cli.session_state import load_session_state, save_session_state - session = get_session_or_404(session_id) + session = get_editable_session(session_id, runner) session_dir = session.kimi_cli_session.dir state = load_session_state(session_dir) @@ -765,16 +749,14 @@ async def fork_session_endpoint( async def generate_session_title( session_id: UUID, request: GenerateTitleRequest | None = None, + runner: KimiCLIRunner = Depends(get_runner), ) -> GenerateTitleResponse: """Generate a concise session title using AI based on the first conversation turn. If request body is empty or parameters are missing, the backend will automatically read the first turn from wire.jsonl. - - Safe to invoke while a worker is running: the final write reloads - state from disk to merge concurrent worker changes. """ - session = get_session_or_404(session_id) + session = get_editable_session(session_id, runner) session_dir = session.kimi_cli_session.dir from kimi_cli.session_state import load_session_state, save_session_state diff --git a/tests/web/test_sessions_api.py b/tests/web/test_sessions_api.py index 4a609c9d19..c7bbe984b2 100644 --- a/tests/web/test_sessions_api.py +++ b/tests/web/test_sessions_api.py @@ -2,6 +2,7 @@ from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING, cast from uuid import UUID import pytest @@ -12,6 +13,9 @@ from kimi_cli.web.api import sessions as sessions_api from kimi_cli.web.models import GenerateTitleRequest +if TYPE_CHECKING: + from kimi_cli.web.runner.process import KimiCLIRunner + @pytest.fixture def isolated_share_dir(monkeypatch, tmp_path: Path) -> Path: @@ -42,6 +46,13 @@ async def ensure_fresh(self) -> None: return None +class _FakeRunner: + """Stand-in for ``KimiCLIRunner`` for tests that bypass FastAPI dependency injection.""" + + def get_session(self, _session_id: UUID) -> None: + return None + + class _FakeLLM: chat_provider = object() @@ -95,6 +106,7 @@ async def fake_generate(*, chat_provider, system_prompt, tools, history): user_message="debug the flaky web session rename issue", assistant_response="I'll inspect the session state writes.", ), + runner=cast("KimiCLIRunner", _FakeRunner()), ) state = load_session_state(session.dir) diff --git a/web/src/hooks/useSessions.ts b/web/src/hooks/useSessions.ts index 78f2193814..3f8f122852 100644 --- a/web/src/hooks/useSessions.ts +++ b/web/src/hooks/useSessions.ts @@ -513,7 +513,6 @@ export function useSessions(): UseSessionsReturn { const message = err instanceof Error ? err.message : "Failed to delete session"; setError(message); - toast.error(message); return false; } finally { setIsLoading(false); From 07f039d226d37e8697358c37555ae8bd4ffa0268 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 28 Apr 2026 17:22:34 +0800 Subject: [PATCH 4/4] fix(web): guard rename save against onBlur/Enter re-entry Pressing Enter in the rename input fires handleSaveEdit while the PATCH is in flight; if the user then clicks the resulting toast or otherwise shifts focus, onBlur fires the same handler again, sending a second PATCH and producing a duplicate failure toast. Track an in-flight ref and short-circuit re-entrant calls. --- web/src/features/sessions/sessions.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/web/src/features/sessions/sessions.tsx b/web/src/features/sessions/sessions.tsx index 6b55d7f33d..003f506b16 100644 --- a/web/src/features/sessions/sessions.tsx +++ b/web/src/features/sessions/sessions.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useMemo, + useRef, type ReactElement, useEffect, useState, @@ -200,6 +201,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ const [isRefreshing, setIsRefreshing] = useState(false); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(""); + // Guard against re-entry: pressing Enter and the resulting blur (e.g. when + // the user clicks the toast to dismiss it) both call handleSaveEdit. + const isSavingRenameRef = useRef(false); // Session search state const [sessionSearch, setSessionSearch] = useState(searchQuery); @@ -437,6 +441,9 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ }; const handleSaveEdit = async () => { + if (isSavingRenameRef.current) { + return; + } if (!(editingSessionId && onRenameSession)) { handleCancelEdit(); return; @@ -448,9 +455,14 @@ export const SessionsSidebar = memo(function SessionsSidebarComponent({ return; } - const success = await onRenameSession(editingSessionId, trimmedTitle); - if (success) { - handleCancelEdit(); + isSavingRenameRef.current = true; + try { + const success = await onRenameSession(editingSessionId, trimmedTitle); + if (success) { + handleCancelEdit(); + } + } finally { + isSavingRenameRef.current = false; } };