Keep CLI task state aligned with the active session

The desktop task bar used a single global task store, but session switches
could leave it pointed at the previous conversation. Re-entering an already
connected session skipped task refresh entirely, and changing task list IDs did
not clear stale tasks before the next fetch resolved.

This change refreshes CLI tasks on every session activation, clears stale task
state immediately when the tracked session changes, and locks that behavior
with regression tests around reconnect and cross-session task isolation.

Constraint: The current desktop task bar architecture uses one shared CLI task store across sessions
Rejected: Per-session task store refactor | too broad for a targeted bug fix
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If tasks become session-scoped later, re-evaluate the eager refresh in connectToSession before removing it
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test src/stores/chatStore.test.ts src/stores/cliTaskStore.test.ts src/pages/ActiveSession.test.tsx
Not-tested: Manual desktop UI verification against a packaged app build
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 20:38:21 +08:00
parent 802b94c5cd
commit eaa4a8435a
5 changed files with 134 additions and 9 deletions

View File

@ -100,7 +100,7 @@ describe('ActiveSession task polling', () => {
expect(
fetchSessionTasks.mock.calls.filter(([currentSessionId]) => currentSessionId === sessionId),
).toHaveLength(3)
).toHaveLength(4)
unmount()
useCLITaskStore.setState(originalCliTaskState)

View File

@ -8,6 +8,11 @@ const {
handleTeamCreatedMock,
handleTeamUpdateMock,
handleTeamDeletedMock,
fetchSessionTasksMock,
clearTasksMock,
setTasksFromTodosMock,
markCompletedAndDismissedMock,
refreshTasksMock,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null),
@ -15,6 +20,11 @@ const {
handleTeamCreatedMock: vi.fn(),
handleTeamUpdateMock: vi.fn(),
handleTeamDeletedMock: vi.fn(),
fetchSessionTasksMock: vi.fn(),
clearTasksMock: vi.fn(),
setTasksFromTodosMock: vi.fn(),
markCompletedAndDismissedMock: vi.fn(),
refreshTasksMock: vi.fn(),
}))
vi.mock('../api/websocket', () => ({
@ -66,12 +76,13 @@ vi.mock('./sessionStore', () => ({
vi.mock('./cliTaskStore', () => ({
useCLITaskStore: {
getState: () => ({
fetchSessionTasks: vi.fn(),
fetchSessionTasks: fetchSessionTasksMock,
tasks: [],
clearTasks: vi.fn(),
setTasksFromTodos: vi.fn(),
markCompletedAndDismissed: vi.fn(),
refreshTasks: vi.fn(),
sessionId: null,
clearTasks: clearTasksMock,
setTasksFromTodos: setTasksFromTodosMock,
markCompletedAndDismissed: markCompletedAndDismissedMock,
refreshTasks: refreshTasksMock,
}),
},
}))
@ -87,6 +98,11 @@ describe('chatStore history mapping', () => {
getMemberBySessionIdMock.mockReset()
getMemberBySessionIdMock.mockReturnValue(null)
sendMessageToMemberMock.mockReset()
fetchSessionTasksMock.mockReset()
clearTasksMock.mockReset()
setTasksFromTodosMock.mockReset()
markCompletedAndDismissedMock.mockReset()
refreshTasksMock.mockReset()
useChatStore.setState({
...initialState,
sessions: {},
@ -502,4 +518,33 @@ describe('chatStore history mapping', () => {
pending: true,
})
})
it('refreshes CLI tasks when switching to an already-connected session', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
})
})

View File

@ -120,6 +120,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(),
connectToSession: (sessionId) => {
void useCLITaskStore.getState().fetchSessionTasks(sessionId)
const existing = get().sessions[sessionId]
if (existing && existing.connectionState !== 'disconnected') return
@ -144,7 +146,6 @@ export const useChatStore = create<ChatStore>((set, get) => ({
})
get().loadHistory(sessionId)
useCLITaskStore.getState().fetchSessionTasks(sessionId)
sessionsApi.getSlashCommands(sessionId)
.then(({ commands }) => {
if (get().sessions[sessionId]) {

View File

@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cliTasksApi } from '../api/cliTasks'
import type { CLITask, TaskStatus } from '../types/cliTask'
import { useCLITaskStore } from './cliTaskStore'
vi.mock('../api/cliTasks', () => ({
cliTasksApi: {
getTasksForList: vi.fn(),
},
}))
function makeTask(taskListId: string, status: TaskStatus = 'in_progress'): CLITask {
return {
id: '1',
subject: 'Keep current session isolated',
description: '',
status,
blocks: [],
blockedBy: [],
taskListId,
}
}
describe('cliTaskStore', () => {
beforeEach(() => {
useCLITaskStore.getState().clearTasks()
vi.clearAllMocks()
})
afterEach(() => {
useCLITaskStore.getState().clearTasks()
})
it('clears stale tasks immediately when switching tracked sessions', async () => {
let resolveRequest: ((value: { tasks: ReturnType<typeof makeTask>[] }) => void) | null = null
vi.mocked(cliTasksApi.getTasksForList).mockImplementation(
(sessionId: string) =>
new Promise<{ tasks: ReturnType<typeof makeTask>[] }>((resolve) => {
if (sessionId === 'session-2') resolveRequest = resolve
}),
)
useCLITaskStore.setState({
sessionId: 'session-1',
tasks: [makeTask('session-1')],
expanded: true,
completedAndDismissed: true,
dismissedCompletionKey: 'session-1::done',
})
const fetchPromise = useCLITaskStore.getState().fetchSessionTasks('session-2')
expect(useCLITaskStore.getState()).toMatchObject({
sessionId: 'session-2',
tasks: [],
expanded: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
})
expect(resolveRequest).not.toBeNull()
resolveRequest!({ tasks: [makeTask('session-2', 'completed')] })
await fetchPromise
expect(useCLITaskStore.getState().tasks).toMatchObject([
{ taskListId: 'session-2', status: 'completed' },
])
})
})

View File

@ -83,7 +83,16 @@ export const useCLITaskStore = create<CLITaskStore>((set, get) => ({
dismissedCompletionKey: null,
fetchSessionTasks: async (sessionId) => {
set({ sessionId })
if (get().sessionId !== sessionId) {
set({
sessionId,
tasks: [],
completedAndDismissed: false,
dismissedCompletionKey: null,
expanded: false,
})
}
try {
const { tasks } = await cliTasksApi.getTasksForList(sessionId)
// Only update if still tracking the same session
@ -96,7 +105,7 @@ export const useCLITaskStore = create<CLITaskStore>((set, get) => ({
} catch {
// No tasks for this session — that's fine
if (get().sessionId === sessionId) {
set({ tasks: [], completedAndDismissed: false, dismissedCompletionKey: null })
set({ tasks: [], completedAndDismissed: false, dismissedCompletionKey: null, expanded: false })
}
}
},