Task history picker fixes.

This commit is contained in:
cte 2026-01-07 19:18:15 -08:00
parent 4a4156085b
commit 607390b94a
3 changed files with 358 additions and 8 deletions

View file

@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useCLIStore } from "../ui/store.js"
describe("useCLIStore", () => {
beforeEach(() => {
// Reset store to initial state before each test
useCLIStore.getState().reset()
})
describe("initialState", () => {
it("should have isResumingTask set to false initially", () => {
const state = useCLIStore.getState()
expect(state.isResumingTask).toBe(false)
})
it("should have empty messages array initially", () => {
const state = useCLIStore.getState()
expect(state.messages).toEqual([])
})
it("should have empty taskHistory initially", () => {
const state = useCLIStore.getState()
expect(state.taskHistory).toEqual([])
})
})
describe("setIsResumingTask", () => {
it("should set isResumingTask to true", () => {
useCLIStore.getState().setIsResumingTask(true)
expect(useCLIStore.getState().isResumingTask).toBe(true)
})
it("should set isResumingTask to false", () => {
useCLIStore.getState().setIsResumingTask(true)
useCLIStore.getState().setIsResumingTask(false)
expect(useCLIStore.getState().isResumingTask).toBe(false)
})
})
describe("reset", () => {
it("should reset all state to initial values", () => {
// Set some state first
const store = useCLIStore.getState()
store.addMessage({ id: "1", role: "user", content: "test" })
store.setTaskHistory([{ id: "task1", task: "test", workspace: "/test", ts: Date.now() }])
store.setAvailableModes([{ slug: "code", name: "Code" }])
store.setAllSlashCommands([{ name: "test", source: "global" as const }])
store.setIsResumingTask(true)
store.setLoading(true)
store.setHasStartedTask(true)
// Reset
useCLIStore.getState().reset()
// Verify all state is reset
const resetState = useCLIStore.getState()
expect(resetState.messages).toEqual([])
expect(resetState.taskHistory).toEqual([])
expect(resetState.availableModes).toEqual([])
expect(resetState.allSlashCommands).toEqual([])
expect(resetState.isResumingTask).toBe(false)
expect(resetState.isLoading).toBe(false)
expect(resetState.hasStartedTask).toBe(false)
})
})
describe("resetForTaskSwitch", () => {
it("should clear task-specific state", () => {
// Set up task-specific state
const store = useCLIStore.getState()
store.addMessage({ id: "1", role: "user", content: "test" })
store.setLoading(true)
store.setComplete(true)
store.setHasStartedTask(true)
store.setError("some error")
store.setIsResumingTask(true)
store.setTokenUsage({
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 0,
totalCacheReads: 0,
totalCacheWrites: 0,
})
store.setTodos([{ id: "1", content: "test todo", status: "pending" }])
// Reset for task switch
useCLIStore.getState().resetForTaskSwitch()
// Verify task-specific state is cleared
const resetState = useCLIStore.getState()
expect(resetState.messages).toEqual([])
expect(resetState.pendingAsk).toBeNull()
expect(resetState.isLoading).toBe(false)
expect(resetState.isComplete).toBe(false)
expect(resetState.hasStartedTask).toBe(false)
expect(resetState.error).toBeNull()
expect(resetState.isResumingTask).toBe(false)
expect(resetState.tokenUsage).toBeNull()
expect(resetState.currentTodos).toEqual([])
expect(resetState.previousTodos).toEqual([])
})
it("should PRESERVE taskHistory", () => {
const taskHistory = [
{ id: "task1", task: "test task 1", workspace: "/test", ts: Date.now() },
{ id: "task2", task: "test task 2", workspace: "/test", ts: Date.now() },
]
useCLIStore.getState().setTaskHistory(taskHistory)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().taskHistory).toEqual(taskHistory)
})
it("should PRESERVE availableModes", () => {
const modes = [
{ slug: "code", name: "Code", description: "Code mode" },
{ slug: "architect", name: "Architect", description: "Architect mode" },
]
useCLIStore.getState().setAvailableModes(modes)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().availableModes).toEqual(modes)
})
it("should PRESERVE allSlashCommands", () => {
const commands = [
{ name: "new", description: "New task", source: "global" as const },
{ name: "help", description: "Get help", source: "built-in" as const },
]
useCLIStore.getState().setAllSlashCommands(commands)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().allSlashCommands).toEqual(commands)
})
it("should PRESERVE fileSearchResults", () => {
const results = [
{ path: "file1.ts", type: "file" as const },
{ path: "file2.ts", type: "file" as const },
]
useCLIStore.getState().setFileSearchResults(results)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().fileSearchResults).toEqual(results)
})
it("should PRESERVE currentMode", () => {
useCLIStore.getState().setCurrentMode("architect")
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().currentMode).toBe("architect")
})
it("should PRESERVE routerModels", () => {
const models = { openai: { "gpt-4": { contextWindow: 128000 } } }
useCLIStore.getState().setRouterModels(models)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().routerModels).toEqual(models)
})
it("should PRESERVE apiConfiguration", () => {
const config = { apiProvider: "openai", apiModelId: "gpt-4" }
useCLIStore
.getState()
.setApiConfiguration(config as ReturnType<typeof useCLIStore.getState>["apiConfiguration"])
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().apiConfiguration).toEqual(config)
})
})
describe("task resumption flow", () => {
it("should support the full task resumption workflow", () => {
const store = useCLIStore.getState
// Step 1: Initial state with task history and modes from webviewDidLaunch
store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }])
store().setAvailableModes([{ slug: "code", name: "Code" }])
store().setAllSlashCommands([{ name: "new", source: "global" as const }])
// Step 2: User starts a new task
store().setHasStartedTask(true)
store().addMessage({ id: "1", role: "user", content: "New task" })
store().addMessage({ id: "2", role: "assistant", content: "Working on it..." })
store().setLoading(true)
// Verify current state
expect(store().messages.length).toBe(2)
expect(store().hasStartedTask).toBe(true)
// Step 3: User selects a task from history to resume
// This triggers resetForTaskSwitch + setIsResumingTask(true)
store().resetForTaskSwitch()
store().setIsResumingTask(true)
// Verify task-specific state is cleared but global state preserved
expect(store().messages).toEqual([])
expect(store().isLoading).toBe(false)
expect(store().hasStartedTask).toBe(false)
expect(store().isResumingTask).toBe(true) // Flag is set
expect(store().taskHistory.length).toBe(1) // Preserved
expect(store().availableModes.length).toBe(1) // Preserved
expect(store().allSlashCommands.length).toBe(1) // Preserved
// Step 4: Extension sends state message with clineMessages
// (simulated by adding messages)
store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" })
store().addMessage({ id: "old2", role: "assistant", content: "Previous response" })
// Step 5: After processing state, isResumingTask should be cleared
store().setIsResumingTask(false)
// Final verification
expect(store().isResumingTask).toBe(false)
expect(store().messages.length).toBe(2)
expect(store().taskHistory.length).toBe(1) // Still preserved
})
it("should allow reading isResumingTask synchronously during message processing", () => {
const store = useCLIStore.getState
// Set the flag
store().setIsResumingTask(true)
// Simulate synchronous read during message processing
const isResuming = store().isResumingTask
expect(isResuming).toBe(true)
// The handler can use this to decide whether to skip messages
if (!isResuming) {
// Would skip first text message for new tasks
} else {
// Would NOT skip first text message for resumed tasks
}
// After processing, clear the flag
store().setIsResumingTask(false)
expect(store().isResumingTask).toBe(false)
})
})
describe("difference between reset and resetForTaskSwitch", () => {
it("should show that reset clears everything while resetForTaskSwitch preserves global state", () => {
const store = useCLIStore.getState
// Set up both task-specific and global state
store().addMessage({ id: "1", role: "user", content: "test" })
store().setTaskHistory([{ id: "t1", task: "task", workspace: "/", ts: Date.now() }])
store().setAvailableModes([{ slug: "code", name: "Code" }])
// Use resetForTaskSwitch
store().resetForTaskSwitch()
// Task-specific cleared, global preserved
expect(store().messages).toEqual([])
expect(store().taskHistory.length).toBe(1)
expect(store().availableModes.length).toBe(1)
// Now use reset()
store().reset()
// Everything cleared
expect(store().messages).toEqual([])
expect(store().taskHistory).toEqual([])
expect(store().availableModes).toEqual([])
})
})
})

View file

@ -193,6 +193,8 @@ function AppInner({
setAllSlashCommands,
setAvailableModes,
setTaskHistory,
currentTaskId,
setCurrentTaskId,
currentMode,
setCurrentMode,
tokenUsage,
@ -567,6 +569,7 @@ function AppInner({
const handleSayMessage = useCallback(
(ts: number, say: SayType, text: string, partial: boolean) => {
const messageId = ts.toString()
const isResuming = useCLIStore.getState().isResumingTask
if (say === "checkpoint_saved") {
return
@ -580,7 +583,9 @@ function AppInner({
return
}
if (say === "text" && !firstTextMessageSkipped.current) {
// Skip first text message ONLY for new tasks, not resumed tasks
// When resuming, we want to show all historical messages including the first one
if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
firstTextMessageSkipped.current = true
seenMessageIds.current.add(messageId)
return
@ -691,6 +696,9 @@ function AppInner({
// Mark that a task has been started so subsequent messages continue the task
// (instead of starting a brand new task via runTask)
setHasStartedTask(true)
// Clear the resuming flag since we're now ready for interaction
// Historical messages should already be displayed from state processing
useCLIStore.getState().setIsResumingTask(false)
// Do not set pendingAsk - let the normal text input appear
return
}
@ -839,6 +847,12 @@ function AppInner({
setTokenUsage(metrics)
}
}
// After processing state, clear the resuming flag if it was set
// This ensures the flag is cleared even if no resume_task ask message is received
if (useCLIStore.getState().isResumingTask) {
useCLIStore.getState().setIsResumingTask(false)
}
} else if (msg.type === "messageUpdated") {
const clineMessage = msg.clineMessage as Record<string, unknown>
if (!clineMessage) return
@ -1171,20 +1185,37 @@ function AppInner({
return
}
// If selecting the same task that's already loaded, just close the picker
if (historyItem.id === currentTaskId) {
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
return
}
// Send showTaskWithId message to extension to resume the task
if (hostRef.current) {
// Reset CLI state before resuming task
useCLIStore.getState().reset()
// Use selective reset that preserves global state (taskHistory, modes, commands)
useCLIStore.getState().resetForTaskSwitch()
// Set the resuming flag so message handlers know we're resuming
// This prevents skipping the first text message (which is historical)
useCLIStore.getState().setIsResumingTask(true)
// Track which task we're switching to
setCurrentTaskId(historyItem.id)
// Reset refs to avoid stale state across task switches
seenMessageIds.current.clear()
firstTextMessageSkipped.current = false
// Send message to resume the selected task
// This triggers createTaskWithHistoryItem -> postStateToWebview
// which includes clineMessages and handles mode restoration
hostRef.current.sendToExtension({ type: "showTaskWithId", text: historyItem.id })
// Re-request state, commands and modes since reset() cleared them
hostRef.current.sendToExtension({ type: "webviewDidLaunch" })
hostRef.current.sendToExtension({ type: "requestCommands" })
hostRef.current.sendToExtension({ type: "requestModes" })
// DON'T send these redundant requests - they cause race conditions:
// - showTaskWithId already triggers postStateToWebview which includes everything
// - resetForTaskSwitch preserves taskHistory, modes, and commands
// hostRef.current.sendToExtension({ type: "webviewDidLaunch" })
// hostRef.current.sendToExtension({ type: "requestCommands" })
// hostRef.current.sendToExtension({ type: "requestModes" })
}
// Close the picker
@ -1196,7 +1227,7 @@ function AppInner({
followupAutocompleteRef.current?.handleItemSelect(item)
}
},
[pickerState.activeTrigger, isLoading, showInfo],
[pickerState.activeTrigger, isLoading, showInfo, currentTaskId, setCurrentTaskId],
)
// Handle picker close from external PickerSelect

View file

@ -35,6 +35,10 @@ interface CLIState {
hasStartedTask: boolean
error: string | null
// Task resumption flag - true when resuming a task from history
// Used to modify message processing behavior (e.g., don't skip first text message)
isResumingTask: boolean
// Autocomplete data (from API/extension)
fileSearchResults: FileSearchResult[]
allSlashCommands: SlashCommandResult[]
@ -43,6 +47,9 @@ interface CLIState {
// Task history (for resuming previous tasks)
taskHistory: TaskHistoryItem[]
// Current task ID (for detecting same-task reselection)
currentTaskId: string | null
// Current mode (updated reactively when mode changes)
currentMode: string | null
@ -70,6 +77,10 @@ interface CLIActions {
setHasStartedTask: (started: boolean) => void
setError: (error: string | null) => void
reset: () => void
/** Reset for task switching - preserves global state (taskHistory, modes, commands) */
resetForTaskSwitch: () => void
/** Set the isResumingTask flag - used when resuming a task from history */
setIsResumingTask: (isResuming: boolean) => void
// Autocomplete data actions
setFileSearchResults: (results: FileSearchResult[]) => void
@ -79,6 +90,9 @@ interface CLIActions {
// Task history action
setTaskHistory: (history: TaskHistoryItem[]) => void
// Current task ID action
setCurrentTaskId: (taskId: string | null) => void
// Current mode action
setCurrentMode: (mode: string | null) => void
@ -98,10 +112,12 @@ const initialState: CLIState = {
isComplete: false,
hasStartedTask: false,
error: null,
isResumingTask: false,
fileSearchResults: [],
allSlashCommands: [],
availableModes: [],
taskHistory: [],
currentTaskId: null,
currentMode: null,
tokenUsage: null,
routerModels: null,
@ -160,10 +176,36 @@ export const useCLIStore = create<CLIState & CLIActions>((set) => ({
setHasStartedTask: (started) => set({ hasStartedTask: started }),
setError: (error) => set({ error }),
reset: () => set(initialState),
resetForTaskSwitch: () =>
set((state) => ({
// Clear task-specific state
messages: [],
pendingAsk: null,
isLoading: false,
isComplete: false,
hasStartedTask: false,
error: null,
isResumingTask: false,
tokenUsage: null,
currentTodos: [],
previousTodos: [],
// currentTaskId is preserved - will be updated to new task ID by caller
currentTaskId: state.currentTaskId,
// PRESERVE global state - don't clear these
taskHistory: state.taskHistory,
availableModes: state.availableModes,
allSlashCommands: state.allSlashCommands,
fileSearchResults: state.fileSearchResults,
currentMode: state.currentMode,
routerModels: state.routerModels,
apiConfiguration: state.apiConfiguration,
})),
setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }),
setFileSearchResults: (results) => set({ fileSearchResults: results }),
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
setAvailableModes: (modes) => set({ availableModes: modes }),
setTaskHistory: (history) => set({ taskHistory: history }),
setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }),
setCurrentMode: (mode) => set({ currentMode: mode }),
setTokenUsage: (usage) => set({ tokenUsage: usage }),
setRouterModels: (models) => set({ routerModels: models }),