Skip to content
Merged
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
23 changes: 12 additions & 11 deletions internal/web/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"io"
"net/http"
"time"

"github.com/cloudwego/eino/schema"
"github.com/cnjack/jcode/internal/config"
Expand Down Expand Up @@ -213,10 +212,17 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session id is required"})
return
}
// Tear down the live engine for this task (if any) so its run is cancelled and
// resources reclaimed. The active foreground engine is left in place — but its
// recorder is reset to a fresh session so post-delete writes don't land in the
// now-unlinked file (silent data loss).
// Refuse while the agent is mid-run. Cancelling + deleting a live task races
// the recorder (file/index resurrection) and is a bad UX for an intentional
// stop — the user should stop the run first, then delete.
if eng := s.resolveEngine(id); eng != nil && eng.running.Load() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is currently running"})
return
}
// Tear down the live engine for this task (if any) so leftover cancel state
// is cleared and resources reclaimed. The active foreground engine is left
// in place — but its recorder is reset so post-delete writes don't land in
// the now-unlinked file (silent data loss).
if eng := s.resolveEngine(id); eng != nil {
eng.emu.Lock()
cancel := eng.runCancel
Expand All @@ -227,12 +233,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
if eng != s.activeEngine() {
s.deleteEngine(id)
} else {
// Active task: wait for the cancelled run to drain so its final
// RecordAssistant/usage writes land before we close + reset the recorder
// (a post-close write would re-create and truncate the file).
for i := 0; i < 200 && eng.running.Load(); i++ {
time.Sleep(5 * time.Millisecond)
}
// Not running (guarded above): safe to close the recorder now.
eng.emu.Lock()
if eng.recorder != nil && eng.recorder.UUID() == id {
eng.recorder.Close()
Expand Down
28 changes: 28 additions & 0 deletions internal/web/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,34 @@ func TestUpdateTaskMetaResponseShape(t *testing.T) {
}
}

// Running tasks must not be deleted: cancel+delete races the recorder and is
// the wrong product behaviour (stop first, then delete).
func TestDeleteSessionRejectsRunning(t *testing.T) {
seedIndex(t, map[string][]session.SessionMeta{
"/work/p": {{UUID: "run-1", Project: "/work/p"}},
})
eng := &Engine{taskID: "run-1", pwd: "/work/p"}
eng.running.Store(true)
s := &Server{Engine: eng, tasks: map[string]*Engine{"run-1": eng}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/sessions/run-1", nil)
req.SetPathValue("id", "run-1")
s.handleDeleteSession(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("delete while running: code=%d body=%q, want 409", rec.Code, rec.Body.String())
}
all, err := session.ListAllSessions()
if err != nil {
t.Fatal(err)
}
if len(all["/work/p"]) != 1 || all["/work/p"][0].UUID != "run-1" {
t.Fatalf("running session must remain indexed, got %+v", all["/work/p"])
}
if !eng.running.Load() {
t.Fatal("delete must not clear the running flag")
}
}

// Regression: deleting a task that belongs to a project OTHER than the active
// one (s.pwd) must still remove it from the index — the sidebar tree can delete
// across projects. Previously this silently no-op'd, leaving a ghost task.
Expand Down
8 changes: 8 additions & 0 deletions web/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,14 @@ const sessionSlice = createSlice({
const { uuid: _uuid, ...rest } = a.payload
Object.assign(t, rest)
},
/** Remove a deleted session from BOTH lists, inside the reducer (operates
* on the latest state — a component-side filter over render-scope copies
* would clobber any list update that landed during the delete round-trip,
* and unlike setTasks/setSessions this never re-adds anything). */
removeSession(s, a: { payload: string }) {
s.sessions = s.sessions.filter((x) => x.uuid !== a.payload)
s.tasks = s.tasks.filter((x) => x.uuid !== a.payload)
},
/** Insert or merge a session for the active-project fallback list. */
upsertSession(s, a: { payload: SessionItem }) {
const i = s.sessions.findIndex((x) => x.uuid === a.payload.uuid)
Expand Down
62 changes: 52 additions & 10 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export function Sidebar() {
const tasks = useAppSelector((s) => s.session.tasks)
const projectTimes = useAppSelector((s) => s.session.projectTimes)
const currentSessionId = useAppSelector((s) => s.session.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)
currentSessionRef.current = currentSessionId
Comment on lines +95 to +99

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.

const activePath = useAppSelector((s) => s.session.projectPath)
const activeView = useAppSelector((s) => s.ui.activeView)

Expand Down Expand Up @@ -375,20 +380,40 @@ export function Sidebar() {
}

async function deleteItem(row: SessionRow) {
// Running tasks must not be deleted — backend also returns 409. Stop first.
if (row.running) return
const wasActive = row.uuid === currentSessionId
if (wasActive) {
// Drop queued type-ahead for this session BEFORE the delete round-trip:
// a late agent_done (from a prior cancel/stop) can arrive 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
}
dispatch(sessionActions.setSessions(sessions.filter((s) => s.uuid !== row.uuid)))
dispatch(sessionActions.setTasks(tasks.filter((t) => t.uuid !== row.uuid)))
dispatch(chatActions.dropSessionQueue(row.uuid))
if (wasActive) {
dispatch(chatActions.clearChat())
dispatch(sessionActions.setCurrentSession(''))
dispatch(chatActions.setRunning(false))
// 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
}
// 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())
}

function openContext(e: React.MouseEvent, row: SessionRow) {
Expand Down Expand Up @@ -457,6 +482,8 @@ export function Sidebar() {
icon={TrashIcon}
label={t('sidebar.actions.delete')}
danger
disabled={row.running}
title={row.running ? t('sidebar.actions.deleteWhileRunning') : undefined}
onClick={() => { void deleteItem(row); closeCtx() }}
/>
</>
Expand Down Expand Up @@ -856,19 +883,34 @@ function CtxItem({
label,
onClick,
danger,
disabled,
title,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
onClick: () => void
danger?: boolean
disabled?: boolean
title?: string
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-[var(--color-muted)] ${
danger ? 'text-[var(--color-destructive)]' : 'text-[var(--color-foreground)]'
disabled={disabled}
title={title}
aria-disabled={disabled || undefined}
onClick={disabled ? undefined : onClick}
className={`flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-[12.5px] transition-colors ${
disabled
? 'cursor-not-allowed opacity-45'
: 'hover:bg-[var(--color-muted)]'
} ${
danger && !disabled
? 'text-[var(--color-destructive)]'
: disabled
? 'text-[var(--color-muted-foreground)]'
: 'text-[var(--color-foreground)]'
}`}
>
<Icon className="h-3.5 w-3.5" />
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ export default {
markRead: 'Mark read',
markUnread: 'Mark unread',
delete: 'Delete',
deleteWhileRunning: 'Stop the agent before deleting this conversation',
taskActions: 'Task actions',
},
relativeTime: {
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ export default {
markRead: '既読にする',
markUnread: '未読にする',
delete: '削除',
deleteWhileRunning: '削除する前にエージェントを停止してください',
taskActions: 'タスク操作',
},
relativeTime: {
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ export default {
markRead: '읽음으로 표시',
markUnread: '안 읽음으로 표시',
delete: '삭제',
deleteWhileRunning: '삭제하기 전에 실행 중인 에이전트를 중지하세요',
taskActions: '작업 동작',
},
relativeTime: {
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,7 @@ export default {
markRead: '标记为已读',
markUnread: '标记为未读',
delete: '删除',
deleteWhileRunning: '请先停止运行中的 Agent,再删除此会话',
taskActions: '任务操作',
},
relativeTime: {
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/locales/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,7 @@ export default {
markRead: '標記為已讀',
markUnread: '標記為未讀',
delete: '刪除',
deleteWhileRunning: '請先停止執行中的 Agent,再刪除此對話',
taskActions: '工作操作',
},
relativeTime: {
Expand Down
Loading