Skip to content

fix(sidebar): deleting the open conversation lands on welcome with a fresh session#161

Merged
cnjack merged 3 commits into
mainfrom
fix/delete-open-conversation
Jul 20, 2026
Merged

fix(sidebar): deleting the open conversation lands on welcome with a fresh session#161
cnjack merged 3 commits into
mainfrom
fix/delete-open-conversation

Conversation

@cnjack

@cnjack cnjack commented Jul 20, 2026

Copy link
Copy Markdown
Owner

需求

允许删除当前已经打开的 conversation,删除之后跳转到 welcome 页面。

stacked on #160(base 为 feat/project-last-updated),#160 合并后请将本 PR 的 base 改为 main。

问题

之前删除当前打开的会话只是清空 UI(clearChat + setCurrentSession('')),后端仍停留在被删任务的 engine 上:

  1. 僵尸 engine:下一条消息会落到这个旧 engine 上运行,而所有事件都打着已删除的 task id 戳,被 WS 客户端过滤器丢弃 → 新对话看起来像「死了」。
  2. 行复活:setTasks/setSessions reducer 有「保护尚未入索引的当前会话」的防竞态逻辑,删除时该会话仍是 current session → 刚删掉的行被重新加回列表。
  3. 队列复活:被取消运行的 agent_done 先于 DELETE 响应到达客户端,会把该会话的排队消息(type-ahead queue)重新发送进已删除的会话 → 磁盘上文件 + 索引条目复活。
  4. 后端 drain 窗口:删除正在运行的当前任务时,若 1s 内 run 未排空,旧代码直接关闭 recorder → 存活的 writer 会重建刚删除的 JSONL 文件。

修复

前端(Sidebar.tsx deleteItem)

  • 删除当前会话成功后 dispatch startNewChat():创建一致的新 engine/task(旧 engine 因 recorder 已被重置而被回收),UI 落到 welcome 页
  • 新增 removeSession reducer:在 reducer 内对最新 state 过滤(避免 await 期间闭包过期覆盖列表更新;且不会触发 current-session 保护逻辑)
  • 当前会话的 type-ahead 队列在 DELETE 往返之前清空(阻断 agent_done 复活路径)
  • await 之后通过 ref 复查 currentSession:若用户在 DELETE 期间已切到别的会话,则尊重用户的导航,不再抢占前台

后端(sessions.go handleDeleteSession)

  • 被删当前任务的 run 在有限等待内未排空时,不再在存活 writer 下关闭 recorder;改为后台等待排空后关闭,并重跑一次删除清扫 tail 写入(与 automation-run teardown 同一模式)

验证

  • golangci-lint 0 issues;make lint-web 通过;go test ./internal/web/ ./internal/session/ 通过
  • 浏览器端到端:打开某会话 → ⋯ 菜单 Delete → welcome 页出现、该行从侧栏消失、后端 active session 变为全新 UUID、project 的持久化时间与排序不受影响

对抗评审

已经过一轮对抗评审,修复了:

  1. major — 删除 + 快速打开另一会话的竞态(await 后复查 currentSession)
  2. major — 排队消息经 agent_done 复活已删会话(提前清空队列)
  3. major — 闭包过期整表覆盖(removeSession reducer)
  4. major — 后端 drain 未排空时关闭 recorder 导致文件复活(后台 drain + 清扫)

已知边界(可接受/后续跟进):

  • api.newSession() 失败(如 64 engine 上限)时回退到 welcome,此时发消息仍可能命中旧 engine;属极罕见错误路径,刷新页面即可恢复
  • 删除后 currentSessionId='' 窗口期 WS 过滤放宽,与现有 startNewChat 流程行为一致(非本 PR 引入)

Summary by CodeRabbit

  • Bug Fixes
    • Deleting a running conversation now fails with a clear conflict message until the agent is stopped, preventing inconsistent state.
    • Sidebar and task/chat queues now update deterministically after deletion, avoiding late updates and unnecessary resets.
    • If you navigate away during deletion, the chat UI is preserved.
  • UI & Localization
    • Added a localized sidebar message and disabled Delete while an agent is running.

…fresh session

Deleting the currently-open conversation now transitions to the welcome
page backed by a brand-new session, instead of clearing the UI while the
backend stays stranded on the deleted task's engine.

Frontend (Sidebar.tsx deleteItem):
- After a successful delete of the active row, dispatch startNewChat():
  it provisions a consistent new engine/task (and reclaims the stale
  one — its recorder was reset on delete). Merely clearing the UI left
  the deleted task's engine in the foreground, so the next message ran
  on it and every event was stamped with the deleted task id — dropped
  by the WS client filter, making the new conversation appear dead.
- Remove the row via a new removeSession reducer that filters the
  LATEST state inside the reducer: the old render-scope whole-list
  filter could clobber updates that landed during the delete
  round-trip, and the setTasks/setSessions lazy-index guard resurrected
  the deleted row while it was still the current session.
- Drop the active session's type-ahead queue BEFORE the delete
  round-trip: the cancelled run's agent_done reaches the client before
  the DELETE response and would otherwise drain the queue back into the
  deleted session (resurrecting its file + index entry on disk).
- Re-check the current session after the await (via a ref): if the
  user navigated to another session while the DELETE was in flight,
  their navigation wins and the delete no longer yanks the foreground.

Backend (sessions.go handleDeleteSession):
- When the deleted active task's run hasn't drained within the bounded
  wait, no longer close the recorder under a live writer (which let
  the run resurrect the just-deleted JSONL + index entry). Defer the
  close until the run finishes, then re-run the deletion to sweep up
  anything the tail wrote — same discipline as automation-run teardown.

Verified end-to-end in the browser: open a conversation → delete from
the ⋯ menu → welcome page shows, the row is gone from the sidebar, the
backend's active session is a fresh UUID, and the project's persisted
last-activity time/order are unaffected.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f8e4f381-6468-4540-8de8-5433b6890159

📥 Commits

Reviewing files that changed from the base of the PR and between 29373d3 and 7f7cc16.

📒 Files selected for processing (8)
  • internal/web/sessions.go
  • internal/web/tasks_test.go
  • web/src/components/Sidebar.tsx
  • web/src/i18n/locales/en.ts
  • web/src/i18n/locales/ja.ts
  • web/src/i18n/locales/ko.ts
  • web/src/i18n/locales/zh-Hans.ts
  • web/src/i18n/locales/zh-Hant.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/src/components/Sidebar.tsx

📝 Walkthrough

Walkthrough

Session deletion now refuses requests for running agents with HTTP 409. The frontend disables deletion while running, handles asynchronous navigation and API failures, removes session/task state directly, drops related queues, and conditionally starts a new chat.

Changes

Session deletion safeguards

Layer / File(s) Summary
Backend running-session rejection
internal/web/sessions.go, internal/web/tasks_test.go
Running engines cause deletion to return HTTP 409 without clearing the running state or removing the indexed session; the prior drain wait is removed.
Frontend deletion flow
web/src/app/store.ts, web/src/components/Sidebar.tsx, web/src/i18n/locales/*
The sidebar disables deletion for running sessions, manages queue and store cleanup, handles API failures, tracks navigation during asynchronous deletion, and adds localized guidance.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • cnjack/jcode#147: Related session deletion and session-scoped chat queue handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main user-visible change: deleting the open conversation now returns to welcome with a fresh session.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/delete-open-conversation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/project-last-updated to main July 20, 2026 08:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
web/src/components/Sidebar.tsx (1)

384-414: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Drop the session queue unconditionally before the API call.

Currently, the queue is dropped before the API request for active sessions, but after the request for inactive sessions. If a user deletes an inactive session that is still running in the background, its agent_done could arrive during the deleteSession round-trip and drain its queue, resurrecting the deleted session on disk (the same race condition avoided for the active session).
Dropping the queue unconditionally before the API call prevents this background race and removes duplicate code.

♻️ Proposed refactor
   async function deleteItem(row: SessionRow) {
     const wasActive = row.uuid === currentSessionId
-    if (wasActive) {
-      // Drop queued type-ahead for this session BEFORE the delete round-trip:
-      // the cancelled run's agent_done reaches the client before the DELETE
-      // response, and would otherwise drain the queue back into the deleted
-      // session — resurrecting its file + index entry on disk.
-      dispatch(chatActions.dropSessionQueue(row.uuid))
-    }
+    // Drop queued type-ahead for this session BEFORE the delete round-trip:
+    // the cancelled run's agent_done reaches the client before the DELETE
+    // response, and would otherwise drain the queue back into the deleted
+    // session — resurrecting its file + index entry on disk.
+    dispatch(chatActions.dropSessionQueue(row.uuid))
     try {
       await api.deleteSession(row.uuid)
     } catch {
       return
     }
     // Filter inside the reducer: the render-scope sessions/tasks copies are
     // stale after the await (a WS update or refresh may have landed since),
     // and a whole-list setTasks would clobber it.
     dispatch(sessionActions.removeSession(row.uuid))
-    if (!wasActive) {
-      dispatch(chatActions.dropSessionQueue(row.uuid))
-      return
-    }
+    if (!wasActive) return
     // The user may have navigated to another session while the DELETE was in
     // flight (their click's loadSession wins) — only take over the foreground
     // if we're still on the deleted session.
     if (currentSessionRef.current !== row.uuid) return
     // Land on the welcome page with a FRESH session. Merely clearing the UI
     // would strand the backend on the deleted task's engine: the next
     // message would run on that stale engine, whose events are stamped with
     // the deleted task id and dropped by the WS filter (a conversation that
     // appears dead). startNewChat provisions a consistent new engine/task —
     // and reclaims the stale one (its recorder was reset on delete).
     await dispatch(startNewChat())
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/Sidebar.tsx` around lines 384 - 414, Move
dispatch(chatActions.dropSessionQueue(row.uuid)) out of the wasActive
conditional so it always executes before api.deleteSession(row.uuid). Remove the
later conditional dropSessionQueue call for inactive sessions, while preserving
the existing removeSession dispatch and active-session navigation logic.
internal/web/sessions.go (1)

237-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate recorder close+reset logic between the drained branch and the deferred goroutine.

The eng.emu.Lock(); if eng.recorder != nil && eng.recorder.UUID() == id { ... }; eng.emu.Unlock() block is identical in both places. Given the flagged complexity of this hunk, extracting it into a small helper reduces the chance the two copies drift (e.g., if the timeout-guard from the previous comment is added to only one of them).

♻️ Proposed refactor: shared helper
+// closeSessionRecorder closes and detaches eng's recorder if it still belongs
+// to the given session id, under eng.emu.
+func closeSessionRecorder(eng *Engine, id string) {
+	eng.emu.Lock()
+	defer eng.emu.Unlock()
+	if eng.recorder != nil && eng.recorder.UUID() == id {
+		eng.recorder.Close()
+		eng.recorder = nil
+		eng.history = nil
+	}
+}

Then call closeSessionRecorder(eng, id) in both the drained branch and the goroutine instead of repeating the lock/check/unlock sequence.

Also applies to: 256-262

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/sessions.go` around lines 237 - 244, Extract the duplicated
recorder lock, UUID check, close, and reset sequence into a shared
closeSessionRecorder helper. Replace the inline logic in both the drained branch
and deferred goroutine with closeSessionRecorder(eng, id), preserving the
existing matching-recorder behavior and synchronization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/web/sessions.go`:
- Around line 245-264: The deferred cleanup goroutine must only close the
recorder after the run has drained. After its 30-second polling loop around
eng.running, check whether eng.running is still true; if so, log the timeout
with config.Logger().Printf(...) and return without closing the recorder or
deleting the session. Preserve the existing recorder cleanup and session
deletion only for runs that have finished.

In `@web/src/components/Sidebar.tsx`:
- Around line 95-99: Update the currentSessionRef synchronization in Sidebar so
currentSessionRef.current is assigned inside a useEffect keyed by
currentSessionId, rather than during render. Preserve deleteItem’s ability to
read the latest committed session ID after awaited work.

---

Nitpick comments:
In `@internal/web/sessions.go`:
- Around line 237-244: Extract the duplicated recorder lock, UUID check, close,
and reset sequence into a shared closeSessionRecorder helper. Replace the inline
logic in both the drained branch and deferred goroutine with
closeSessionRecorder(eng, id), preserving the existing matching-recorder
behavior and synchronization.

In `@web/src/components/Sidebar.tsx`:
- Around line 384-414: Move dispatch(chatActions.dropSessionQueue(row.uuid)) out
of the wasActive conditional so it always executes before
api.deleteSession(row.uuid). Remove the later conditional dropSessionQueue call
for inactive sessions, while preserving the existing removeSession dispatch and
active-session navigation logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 958f5d56-3307-4efd-ae50-506e876defbe

📥 Commits

Reviewing files that changed from the base of the PR and between e62d81c and 29373d3.

📒 Files selected for processing (3)
  • internal/web/sessions.go
  • web/src/app/store.ts
  • web/src/components/Sidebar.tsx

Comment thread internal/web/sessions.go Outdated
Comment on lines +245 to +264
} else {
// The run is still draining (a ctx-ignoring tool, a slow LLM
// tail). Closing the recorder now would let the live writer
// resurrect the session file this handler is about to delete.
// Defer the close until the run actually finishes, then re-run
// the deletion to sweep up anything the tail wrote in between
// (same discipline as the automation-run teardown).
go func() {
for i := 0; i < 6000 && eng.running.Load(); i++ {
time.Sleep(5 * time.Millisecond)
}
eng.emu.Lock()
if eng.recorder != nil && eng.recorder.UUID() == id {
eng.recorder.Close()
eng.recorder = nil
eng.history = nil
}
eng.emu.Unlock()
_, _ = session.DeleteSessionByUUID(id)
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify config.Logger()'s method surface (Warn/Error/etc.) to align the suggested calls with the actual API.
ast-grep outline internal/config --items all --type function --match 'Logger'
rg -nP 'func .*Logger\(\)' internal/config
rg -nP 'config\.Logger\(\)\.\w+\(' internal -C2 | head -40

Repository: cnjack/jcode

Length of output: 2241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant section of internal/web/sessions.go with line numbers.
sed -n '220,290p' internal/web/sessions.go | cat -n

# Inspect DeleteSessionByUUID implementation and any related session lifecycle helpers.
rg -n "func DeleteSessionByUUID|DeleteSessionByUUID\\(" internal -C 3

# Inspect the logger implementation to confirm the supported call surface.
sed -n '1,120p' internal/config/log.go | cat -n

Repository: cnjack/jcode

Length of output: 7450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect session deletion and recorder behavior to determine whether the timeout path is still unsafe.
rg -n "type Recorder|func \\(.*Recorder.*\\) Close|func \\(.*Recorder.*\\) UUID|DeleteSessionByUUID|resurrect|running.Load\\(" internal -C 3

# Read the delete helper and recorder close implementation if found.
for f in $(rg -l "DeleteSessionByUUID|type Recorder|func \\(.*Recorder.*\\) Close" internal); do
  echo "### $f"
  sed -n '1,260p' "$f" | cat -n
done

Repository: cnjack/jcode

Length of output: 44236


Only close the recorder once the run has actually drained.

  • After the 30s wait, re-check eng.running; if it’s still true, bail out. Closing eng.recorder here can still let the live writer recreate/truncate the session file.
  • Log the timeout via config.Logger().Printf(...) so the fallback cleanup isn’t silent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/sessions.go` around lines 245 - 264, The deferred cleanup
goroutine must only close the recorder after the run has drained. After its
30-second polling loop around eng.running, check whether eng.running is still
true; if so, log the timeout with config.Logger().Printf(...) and return without
closing the recorder or deleting the session. Preserve the existing recorder
cleanup and session deletion only for runs that have finished.

Source: Coding guidelines

Comment on lines +95 to +99
// Latest currentSessionId readable from async handlers (deleteItem) after an
// await, when the render-scope value is stale: if the user navigated away
// while the DELETE was in flight, the delete must not yank the foreground.
const currentSessionRef = useRef(currentSessionId)
currentSessionRef.current = currentSessionId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid mutating refs during render.

Mutating ref.current during rendering violates React's pure-render rules and can lead to incorrect state in Concurrent Mode if a render is interrupted or discarded. Move the mutation into a useEffect to ensure it only updates after a successful commit.

💡 Proposed fix to mutate ref in an effect
   const currentSessionRef = useRef(currentSessionId)
-  currentSessionRef.current = currentSessionId
+  useEffect(() => {
+    currentSessionRef.current = currentSessionId
+  }, [currentSessionId])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Latest currentSessionId readable from async handlers (deleteItem) after an
// await, when the render-scope value is stale: if the user navigated away
// while the DELETE was in flight, the delete must not yank the foreground.
const currentSessionRef = useRef(currentSessionId)
currentSessionRef.current = currentSessionId
// Latest currentSessionId readable from async handlers (deleteItem) after an
// await, when the render-scope value is stale: if the user navigated away
// while the DELETE was in flight, the delete must not yank the foreground.
const currentSessionRef = useRef(currentSessionId)
useEffect(() => {
currentSessionRef.current = currentSessionId
}, [currentSessionId])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/Sidebar.tsx` around lines 95 - 99, Update the
currentSessionRef synchronization in Sidebar so currentSessionRef.current is
assigned inside a useEffect keyed by currentSessionId, rather than during
render. Preserve deleteItem’s ability to read the latest committed session ID
after awaited work.

Refuse DELETE with 409 when the task engine is mid-run, disable the
sidebar Delete action for running rows, and drop the cancel-and-drain
path that was only needed for delete-while-running.
@cnjack
cnjack merged commit 8abee9c into main Jul 20, 2026
4 checks passed
@cnjack
cnjack deleted the fix/delete-open-conversation branch July 20, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant