mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Task history picker fixes.
This commit is contained in:
parent
4a4156085b
commit
607390b94a
3 changed files with 358 additions and 8 deletions
277
apps/cli/src/__tests__/store.test.ts
Normal file
277
apps/cli/src/__tests__/store.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue