mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
fix(frontend): persist selected agent mode across page refresh (planning agent) (#12672)
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
clearConversationLocalStorage,
|
||||
getConversationState,
|
||||
isTaskConversationId,
|
||||
setConversationState,
|
||||
LOCAL_STORAGE_KEYS,
|
||||
} from "#/utils/conversation-local-storage";
|
||||
@@ -11,6 +12,76 @@ describe("conversation localStorage utilities", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("isTaskConversationId", () => {
|
||||
it("returns true for IDs starting with task-", () => {
|
||||
expect(isTaskConversationId("task-abc-123")).toBe(true);
|
||||
expect(isTaskConversationId("task-")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for normal conversation IDs", () => {
|
||||
expect(isTaskConversationId("conv-123")).toBe(false);
|
||||
expect(isTaskConversationId("abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConversationState", () => {
|
||||
it("returns default state including conversationMode for task IDs without reading localStorage", () => {
|
||||
const state = getConversationState("task-uuid-123");
|
||||
|
||||
expect(state.conversationMode).toBe("code");
|
||||
expect(state.selectedTab).toBe("editor");
|
||||
expect(state.rightPanelShown).toBe(true);
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
`${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-uuid-123`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns merged state from localStorage for real conversation ID including conversationMode", () => {
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-1`;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ conversationMode: "plan", selectedTab: "terminal" }),
|
||||
);
|
||||
|
||||
const state = getConversationState("conv-1");
|
||||
|
||||
expect(state.conversationMode).toBe("plan");
|
||||
expect(state.selectedTab).toBe("terminal");
|
||||
expect(state.rightPanelShown).toBe(true);
|
||||
});
|
||||
|
||||
it("returns default state when key is missing or invalid", () => {
|
||||
expect(getConversationState("conv-missing").conversationMode).toBe(
|
||||
"code",
|
||||
);
|
||||
|
||||
const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-bad`;
|
||||
localStorage.setItem(key, "not json");
|
||||
expect(getConversationState("conv-bad").conversationMode).toBe("code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConversationState", () => {
|
||||
it("does not persist when conversationId is a task ID", () => {
|
||||
setConversationState("task-xyz", { conversationMode: "plan" });
|
||||
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
`${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-xyz`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("persists conversationMode for real conversation ID and getConversationState returns it", () => {
|
||||
setConversationState("conv-2", { conversationMode: "plan" });
|
||||
|
||||
const state = getConversationState("conv-2");
|
||||
expect(state.conversationMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConversationLocalStorage", () => {
|
||||
it("removes the consolidated conversation-state localStorage entry", () => {
|
||||
const conversationId = "conv-123";
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("useHandlePlanClick", () => {
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: null,
|
||||
conversationMode: "code",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +116,7 @@ describe("useHandlePlanClick", () => {
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
@@ -152,6 +154,7 @@ describe("useHandlePlanClick", () => {
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
|
||||
65
frontend/__tests__/stores/conversation-store.test.ts
Normal file
65
frontend/__tests__/stores/conversation-store.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const defaultConversationState = {
|
||||
selectedTab: "editor" as const,
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [] as string[],
|
||||
conversationMode: "code" as const,
|
||||
};
|
||||
|
||||
const mockGetConversationState = vi.fn((_id: string) => defaultConversationState);
|
||||
const mockSetConversationState = vi.fn();
|
||||
|
||||
vi.mock("#/utils/conversation-local-storage", () => ({
|
||||
getConversationState: (id: string) => mockGetConversationState(id),
|
||||
setConversationState: (id: string, updates: object) =>
|
||||
mockSetConversationState(id, updates),
|
||||
}));
|
||||
|
||||
const CONV_ID = "conv-test-1";
|
||||
|
||||
describe("conversation store", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetConversationState.mockReturnValue(defaultConversationState);
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { pathname: `/conversations/${CONV_ID}` },
|
||||
writable: true,
|
||||
});
|
||||
useConversationStore.setState({
|
||||
conversationMode: "code",
|
||||
planContent: null,
|
||||
subConversationTaskId: null,
|
||||
shouldHideSuggestions: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConversationMode", () => {
|
||||
it("updates store state and persists via setConversationState when conversation ID is in location", () => {
|
||||
useConversationStore.getState().setConversationMode("plan");
|
||||
|
||||
expect(useConversationStore.getState().conversationMode).toBe("plan");
|
||||
expect(mockSetConversationState).toHaveBeenCalledWith(CONV_ID, {
|
||||
conversationMode: "plan",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetConversationState", () => {
|
||||
it("sets conversationMode from getConversationState", () => {
|
||||
useConversationStore.setState({ conversationMode: "plan" });
|
||||
mockGetConversationState.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
});
|
||||
|
||||
useConversationStore.getState().resetConversationState();
|
||||
|
||||
expect(useConversationStore.getState().conversationMode).toBe("code");
|
||||
expect(mockGetConversationState).toHaveBeenCalledWith(CONV_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import {
|
||||
getConversationState,
|
||||
setConversationState,
|
||||
} from "#/utils/conversation-local-storage";
|
||||
|
||||
export type ConversationTab =
|
||||
| "editor"
|
||||
@@ -105,6 +109,20 @@ const getInitialRightPanelState = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const getInitialConversationMode = (): ConversationMode => {
|
||||
if (typeof window === "undefined") {
|
||||
return "code";
|
||||
}
|
||||
|
||||
const conversationId = getConversationIdFromLocation();
|
||||
if (!conversationId) {
|
||||
return "code";
|
||||
}
|
||||
|
||||
const state = getConversationState(conversationId);
|
||||
return state.conversationMode;
|
||||
};
|
||||
|
||||
export const useConversationStore = create<ConversationStore>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
@@ -121,7 +139,7 @@ export const useConversationStore = create<ConversationStore>()(
|
||||
shouldHideSuggestions: false,
|
||||
hasRightPanelToggled: true,
|
||||
planContent: null,
|
||||
conversationMode: "code",
|
||||
conversationMode: getInitialConversationMode(),
|
||||
subConversationTaskId: null,
|
||||
|
||||
// Actions
|
||||
@@ -257,7 +275,7 @@ export const useConversationStore = create<ConversationStore>()(
|
||||
set(
|
||||
{
|
||||
shouldHideSuggestions: false,
|
||||
conversationMode: "code",
|
||||
conversationMode: getInitialConversationMode(),
|
||||
subConversationTaskId: null,
|
||||
planContent: null,
|
||||
},
|
||||
@@ -268,8 +286,13 @@ export const useConversationStore = create<ConversationStore>()(
|
||||
setHasRightPanelToggled: (hasRightPanelToggled) =>
|
||||
set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"),
|
||||
|
||||
setConversationMode: (conversationMode) =>
|
||||
set({ conversationMode }, false, "setConversationMode"),
|
||||
setConversationMode: (conversationMode) => {
|
||||
const conversationId = getConversationIdFromLocation();
|
||||
if (conversationId) {
|
||||
setConversationState(conversationId, { conversationMode });
|
||||
}
|
||||
set({ conversationMode }, false, "setConversationMode");
|
||||
},
|
||||
|
||||
setSubConversationTaskId: (subConversationTaskId) =>
|
||||
set({ subConversationTaskId }, false, "setSubConversationTaskId"),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { ConversationTab } from "#/stores/conversation-store";
|
||||
import type {
|
||||
ConversationTab,
|
||||
ConversationMode,
|
||||
} from "#/stores/conversation-store";
|
||||
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
CONVERSATION_STATE: "conversation-state",
|
||||
@@ -12,6 +15,7 @@ export interface ConversationState {
|
||||
selectedTab: ConversationTab | null;
|
||||
rightPanelShown: boolean;
|
||||
unpinnedTabs: string[];
|
||||
conversationMode: ConversationMode;
|
||||
subConversationTaskId: string | null;
|
||||
}
|
||||
|
||||
@@ -19,6 +23,7 @@ const DEFAULT_CONVERSATION_STATE: ConversationState = {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
};
|
||||
|
||||
@@ -93,6 +98,7 @@ export function useConversationLocalStorageState(conversationId: string): {
|
||||
setSelectedTab: (tab: ConversationTab | null) => void;
|
||||
setRightPanelShown: (shown: boolean) => void;
|
||||
setUnpinnedTabs: (tabs: string[]) => void;
|
||||
setConversationMode: (mode: ConversationMode) => void;
|
||||
} {
|
||||
const [state, setState] = useState<ConversationState>(() =>
|
||||
getConversationState(conversationId),
|
||||
@@ -108,5 +114,6 @@ export function useConversationLocalStorageState(conversationId: string): {
|
||||
setSelectedTab: (tab) => updateState({ selectedTab: tab }),
|
||||
setRightPanelShown: (shown) => updateState({ rightPanelShown: shown }),
|
||||
setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }),
|
||||
setConversationMode: (mode) => updateState({ conversationMode: mode }),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user