feat(FN-1276): scope dashboard persistence by project
- Add a project storage utility with scoped key helpers and key inventories for global vs project state - Scope dashboard/task view, list preferences, quick-entry drafts, agent/tree state, terminal tabs, usage view, and modal draft persistence to projectId - Reload persisted UI state when project context changes so per-project settings and drafts do not leak across projects - Update and expand dashboard tests, including new projectStorage coverage and scoped persistence regression fixes
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
getMissionGoal,
|
||||
clearMissionGoal,
|
||||
} from "../modalPersistence";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
describe("modalPersistence", () => {
|
||||
beforeEach(() => {
|
||||
@@ -39,6 +40,14 @@ describe("modalPersistence", () => {
|
||||
expect(getPlanningDescription()).toBe("Build authentication");
|
||||
});
|
||||
|
||||
it("saves and retrieves planning description per project", () => {
|
||||
savePlanningDescription("Build auth for project", "proj-123");
|
||||
expect(getPlanningDescription("proj-123")).toBe("Build auth for project");
|
||||
expect(localStorage.getItem(scopedKey(STORED_PLANNING_KEY, "proj-123"))).toBe(
|
||||
"Build auth for project",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string when nothing saved", () => {
|
||||
expect(getPlanningDescription()).toBe("");
|
||||
});
|
||||
@@ -49,6 +58,12 @@ describe("modalPersistence", () => {
|
||||
expect(getPlanningDescription()).toBe("");
|
||||
});
|
||||
|
||||
it("clears correctly per project", () => {
|
||||
savePlanningDescription("Test", "proj-123");
|
||||
clearPlanningDescription("proj-123");
|
||||
expect(getPlanningDescription("proj-123")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when localStorage returns null", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockReturnValue(null);
|
||||
expect(getPlanningDescription()).toBe("");
|
||||
@@ -68,6 +83,14 @@ describe("modalPersistence", () => {
|
||||
expect(getSubtaskDescription()).toBe("Implement login feature");
|
||||
});
|
||||
|
||||
it("saves and retrieves subtask description per project", () => {
|
||||
saveSubtaskDescription("Implement login feature", "proj-123");
|
||||
expect(getSubtaskDescription("proj-123")).toBe("Implement login feature");
|
||||
expect(localStorage.getItem(scopedKey(STORED_SUBTASK_KEY, "proj-123"))).toBe(
|
||||
"Implement login feature",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string when nothing saved", () => {
|
||||
expect(getSubtaskDescription()).toBe("");
|
||||
});
|
||||
@@ -78,6 +101,12 @@ describe("modalPersistence", () => {
|
||||
expect(getSubtaskDescription()).toBe("");
|
||||
});
|
||||
|
||||
it("clears correctly per project", () => {
|
||||
saveSubtaskDescription("Test", "proj-123");
|
||||
clearSubtaskDescription("proj-123");
|
||||
expect(getSubtaskDescription("proj-123")).toBe("");
|
||||
});
|
||||
|
||||
it("overwrites previous value", () => {
|
||||
saveSubtaskDescription("First");
|
||||
saveSubtaskDescription("Second");
|
||||
@@ -91,6 +120,14 @@ describe("modalPersistence", () => {
|
||||
expect(getMissionGoal()).toBe("Build a SaaS platform");
|
||||
});
|
||||
|
||||
it("saves and retrieves mission goal per project", () => {
|
||||
saveMissionGoal("Build a SaaS platform", "proj-123");
|
||||
expect(getMissionGoal("proj-123")).toBe("Build a SaaS platform");
|
||||
expect(localStorage.getItem(scopedKey(STORED_MISSION_KEY, "proj-123"))).toBe(
|
||||
"Build a SaaS platform",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string when nothing saved", () => {
|
||||
expect(getMissionGoal()).toBe("");
|
||||
});
|
||||
@@ -101,6 +138,12 @@ describe("modalPersistence", () => {
|
||||
expect(getMissionGoal()).toBe("");
|
||||
});
|
||||
|
||||
it("clears correctly per project", () => {
|
||||
saveMissionGoal("Test", "proj-123");
|
||||
clearMissionGoal("proj-123");
|
||||
expect(getMissionGoal("proj-123")).toBe("");
|
||||
});
|
||||
|
||||
it("overwrites previous value", () => {
|
||||
saveMissionGoal("First");
|
||||
saveMissionGoal("Second");
|
||||
@@ -140,5 +183,14 @@ describe("modalPersistence", () => {
|
||||
expect(getSubtaskDescription()).toBe("");
|
||||
expect(getMissionGoal()).toBe("mission");
|
||||
});
|
||||
|
||||
it("project-scoped values do not interfere with other projects", () => {
|
||||
savePlanningDescription("project-a", "proj-a");
|
||||
savePlanningDescription("project-b", "proj-b");
|
||||
|
||||
expect(getPlanningDescription("proj-a")).toBe("project-a");
|
||||
expect(getPlanningDescription("proj-b")).toBe("project-b");
|
||||
expect(getPlanningDescription()).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAgentHierarchy } from "../useAgentHierarchy";
|
||||
import type { Agent, AgentCapability, AgentState } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
@@ -19,6 +20,9 @@ const localStorageMock = {
|
||||
};
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
|
||||
const PROJECT_ID = "proj-123";
|
||||
const EXPANDED_STORAGE_KEY = scopedKey("kb-agent-tree-expanded", PROJECT_ID);
|
||||
|
||||
function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-001",
|
||||
@@ -32,6 +36,10 @@ function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
};
|
||||
}
|
||||
|
||||
function renderHierarchy(agents: Agent[], projectId: string | undefined = PROJECT_ID) {
|
||||
return renderHook(() => useAgentHierarchy(agents, projectId));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => localStorageStore[key] ?? null);
|
||||
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
|
||||
@@ -51,7 +59,7 @@ describe("useAgentHierarchy", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||
const { result } = renderHierarchy([parent, child]);
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(1);
|
||||
expect(result.current.rootNodes[0].agent.id).toBe("parent-1");
|
||||
@@ -59,7 +67,7 @@ describe("useAgentHierarchy", () => {
|
||||
});
|
||||
|
||||
it("handles empty agents array", () => {
|
||||
const { result } = renderHook(() => useAgentHierarchy([]));
|
||||
const { result } = renderHierarchy([]);
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
@@ -70,7 +78,7 @@ describe("useAgentHierarchy", () => {
|
||||
const agent2 = createMockAgent({ id: "agent-2" });
|
||||
const agent3 = createMockAgent({ id: "agent-3" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2, agent3]));
|
||||
const { result } = renderHierarchy([agent1, agent2, agent3]);
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(3);
|
||||
expect(result.current.rootNodes.map((n) => n.agent.id)).toEqual(["agent-1", "agent-2", "agent-3"]);
|
||||
@@ -83,11 +91,11 @@ describe("useAgentHierarchy", () => {
|
||||
|
||||
// Pre-expand parent and child so grandchild shows up
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent", "child"]);
|
||||
if (key === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent", "child"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child, grandchild]));
|
||||
const { result } = renderHierarchy([parent, child, grandchild]);
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(1);
|
||||
const rootNode = result.current.rootNodes[0];
|
||||
@@ -105,7 +113,7 @@ describe("useAgentHierarchy", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||
const { result } = renderHierarchy([parent, child]);
|
||||
|
||||
// Initially not expanded
|
||||
expect(result.current.isExpanded("parent-1")).toBe(false);
|
||||
@@ -128,27 +136,27 @@ describe("useAgentHierarchy", () => {
|
||||
it("persists expand state to localStorage", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||
const { result } = renderHierarchy([parent]);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpand("parent-1");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"kb-agent-tree-expanded",
|
||||
EXPANDED_STORAGE_KEY,
|
||||
JSON.stringify(["parent-1"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores expand state from localStorage on mount", () => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent-1"]);
|
||||
if (key === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent-1"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||
const { result } = renderHierarchy([parent]);
|
||||
|
||||
expect(result.current.isExpanded("parent-1")).toBe(true);
|
||||
});
|
||||
@@ -157,7 +165,7 @@ describe("useAgentHierarchy", () => {
|
||||
const agent1 = createMockAgent({ id: "agent-1" });
|
||||
const agent2 = createMockAgent({ id: "agent-2" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2]));
|
||||
const { result } = renderHierarchy([agent1, agent2]);
|
||||
|
||||
expect(result.current.isExpanded("agent-1")).toBe(false);
|
||||
expect(result.current.isExpanded("agent-2")).toBe(false);
|
||||
@@ -176,7 +184,7 @@ describe("useAgentHierarchy", () => {
|
||||
const child2 = createMockAgent({ id: "child-2", name: "Child 2", reportsTo: "parent-1" });
|
||||
const unrelated = createMockAgent({ id: "unrelated", name: "Unrelated" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child1, child2, unrelated]));
|
||||
const { result } = renderHierarchy([parent, child1, child2, unrelated]);
|
||||
|
||||
const children = result.current.getChildren("parent-1");
|
||||
expect(children).toHaveLength(2);
|
||||
@@ -187,7 +195,7 @@ describe("useAgentHierarchy", () => {
|
||||
const orphan = createMockAgent({ id: "orphan-1", name: "Orphan", reportsTo: "missing-parent" });
|
||||
const normal = createMockAgent({ id: "normal-1", name: "Normal" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([orphan, normal]));
|
||||
const { result } = renderHierarchy([orphan, normal]);
|
||||
|
||||
// Orphan should be treated as a root node since parent doesn't exist
|
||||
expect(result.current.rootNodes).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useTerminalSessions } from "../useTerminalSessions";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
// Mock API
|
||||
@@ -26,6 +27,9 @@ Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
const TERMINAL_TABS_KEY = scopedKey("kb-terminal-tabs", TEST_PROJECT_ID);
|
||||
|
||||
describe("useTerminalSessions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -51,7 +55,7 @@ describe("useTerminalSessions", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -80,7 +84,7 @@ describe("useTerminalSessions", () => {
|
||||
// Session is still valid on server
|
||||
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -118,7 +122,7 @@ describe("useTerminalSessions", () => {
|
||||
{ id: "session-valid", shell: "/bin/zsh", cwd: "/project" }
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -147,7 +151,7 @@ describe("useTerminalSessions", () => {
|
||||
// No sessions exist on server
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -179,7 +183,7 @@ describe("useTerminalSessions", () => {
|
||||
cwd: "/project",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -210,7 +214,7 @@ describe("useTerminalSessions", () => {
|
||||
.mockResolvedValueOnce({ sessionId: "session-2", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-3", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -238,7 +242,7 @@ describe("useTerminalSessions", () => {
|
||||
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -278,7 +282,7 @@ describe("useTerminalSessions", () => {
|
||||
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -309,7 +313,7 @@ describe("useTerminalSessions", () => {
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -363,7 +367,7 @@ describe("useTerminalSessions", () => {
|
||||
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -386,7 +390,7 @@ describe("useTerminalSessions", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -409,7 +413,7 @@ describe("useTerminalSessions", () => {
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-new", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -436,7 +440,7 @@ describe("useTerminalSessions", () => {
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockResolvedValueOnce({ sessionId: "session-replacement", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -466,7 +470,7 @@ describe("useTerminalSessions", () => {
|
||||
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
||||
.mockRejectedValueOnce(new Error("Server unreachable"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -492,7 +496,7 @@ describe("useTerminalSessions", () => {
|
||||
// Make auto-create hang so no tab is created
|
||||
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
// Wait for isReady to be true (list completes) but tabs are empty (create pending)
|
||||
await waitFor(() => {
|
||||
@@ -517,7 +521,7 @@ describe("useTerminalSessions", () => {
|
||||
.mockRejectedValueOnce(new Error("Temporary failure"))
|
||||
.mockResolvedValueOnce({ sessionId: "session-recovered", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -550,7 +554,7 @@ describe("useTerminalSessions", () => {
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
renderHook(() => useTerminalSessions());
|
||||
renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
@@ -559,8 +563,9 @@ describe("useTerminalSessions", () => {
|
||||
// Verify the stored data contains the tabs
|
||||
const setItemCalls = localStorageMock.setItem.mock.calls;
|
||||
expect(setItemCalls.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
const lastCall = setItemCalls[setItemCalls.length - 1];
|
||||
expect(lastCall[0]).toBe(TERMINAL_TABS_KEY);
|
||||
const storedTabs = JSON.parse(lastCall[1]);
|
||||
expect(storedTabs).toBeInstanceOf(Array);
|
||||
});
|
||||
@@ -574,7 +579,7 @@ describe("useTerminalSessions", () => {
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
// Should not throw
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -590,7 +595,7 @@ describe("useTerminalSessions", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -607,7 +612,7 @@ describe("useTerminalSessions", () => {
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockKillPtyTerminalSession.mockRejectedValue(new Error("Kill failed"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -630,7 +635,7 @@ describe("useTerminalSessions", () => {
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockCreateTerminalSession.mockRejectedValue(new Error("Server unreachable"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
// Should become ready (validation passed)
|
||||
await waitFor(() => {
|
||||
@@ -652,7 +657,7 @@ describe("useTerminalSessions", () => {
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockCreateTerminalSession.mockRejectedValue("string error");
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -670,7 +675,7 @@ describe("useTerminalSessions", () => {
|
||||
// First attempt fails
|
||||
mockCreateTerminalSession.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.bootstrapError).toBe("Connection refused");
|
||||
@@ -710,7 +715,7 @@ describe("useTerminalSessions", () => {
|
||||
cwd: "/project",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
// Wait for initial tab creation
|
||||
await waitFor(() => {
|
||||
@@ -739,7 +744,7 @@ describe("useTerminalSessions", () => {
|
||||
cwd: "/project",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
@@ -766,7 +771,7 @@ describe("useTerminalSessions", () => {
|
||||
// Auto-create fails
|
||||
mockCreateTerminalSession.mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -790,7 +795,7 @@ describe("useTerminalSessions", () => {
|
||||
// createTerminalSession never resolves
|
||||
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
// isReady should become true (list resolved)
|
||||
await waitFor(() => {
|
||||
@@ -822,7 +827,7 @@ describe("useTerminalSessions", () => {
|
||||
// First create call hangs forever
|
||||
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
@@ -874,7 +879,7 @@ describe("useTerminalSessions", () => {
|
||||
});
|
||||
mockCreateTerminalSession.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions());
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
// Storage keys — each modal type has independent storage
|
||||
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
||||
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
|
||||
@@ -5,57 +7,42 @@ export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
||||
|
||||
// Planning persistence
|
||||
|
||||
export function savePlanningDescription(description: string): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORED_PLANNING_KEY, description);
|
||||
}
|
||||
export function savePlanningDescription(description: string, projectId?: string): void {
|
||||
setScopedItem(STORED_PLANNING_KEY, description, projectId);
|
||||
}
|
||||
|
||||
export function getPlanningDescription(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(STORED_PLANNING_KEY) || "";
|
||||
export function getPlanningDescription(projectId?: string): string {
|
||||
return getScopedItem(STORED_PLANNING_KEY, projectId) || "";
|
||||
}
|
||||
|
||||
export function clearPlanningDescription(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORED_PLANNING_KEY);
|
||||
}
|
||||
export function clearPlanningDescription(projectId?: string): void {
|
||||
removeScopedItem(STORED_PLANNING_KEY, projectId);
|
||||
}
|
||||
|
||||
// Subtask persistence
|
||||
|
||||
export function saveSubtaskDescription(description: string): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORED_SUBTASK_KEY, description);
|
||||
}
|
||||
export function saveSubtaskDescription(description: string, projectId?: string): void {
|
||||
setScopedItem(STORED_SUBTASK_KEY, description, projectId);
|
||||
}
|
||||
|
||||
export function getSubtaskDescription(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(STORED_SUBTASK_KEY) || "";
|
||||
export function getSubtaskDescription(projectId?: string): string {
|
||||
return getScopedItem(STORED_SUBTASK_KEY, projectId) || "";
|
||||
}
|
||||
|
||||
export function clearSubtaskDescription(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORED_SUBTASK_KEY);
|
||||
}
|
||||
export function clearSubtaskDescription(projectId?: string): void {
|
||||
removeScopedItem(STORED_SUBTASK_KEY, projectId);
|
||||
}
|
||||
|
||||
// Mission persistence
|
||||
|
||||
export function saveMissionGoal(goal: string): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORED_MISSION_KEY, goal);
|
||||
}
|
||||
export function saveMissionGoal(goal: string, projectId?: string): void {
|
||||
setScopedItem(STORED_MISSION_KEY, goal, projectId);
|
||||
}
|
||||
|
||||
export function getMissionGoal(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(STORED_MISSION_KEY) || "";
|
||||
export function getMissionGoal(projectId?: string): string {
|
||||
return getScopedItem(STORED_MISSION_KEY, projectId) || "";
|
||||
}
|
||||
|
||||
export function clearMissionGoal(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORED_MISSION_KEY);
|
||||
}
|
||||
export function clearMissionGoal(projectId?: string): void {
|
||||
removeScopedItem(STORED_MISSION_KEY, projectId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import type { Agent } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
const EXPANDED_KEY = "kb-agent-tree-expanded";
|
||||
|
||||
@@ -17,9 +18,9 @@ export interface UseAgentHierarchyReturn {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function readExpandedFromStorage(): Set<string> {
|
||||
function readExpandedFromStorage(projectId?: string): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(EXPANDED_KEY);
|
||||
const stored = getScopedItem(EXPANDED_KEY, projectId);
|
||||
if (stored) {
|
||||
const parsed: string[] = JSON.parse(stored);
|
||||
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||
@@ -30,9 +31,9 @@ function readExpandedFromStorage(): Set<string> {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function writeExpandedToStorage(expanded: Set<string>): void {
|
||||
function writeExpandedToStorage(expanded: Set<string>, projectId?: string): void {
|
||||
try {
|
||||
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]));
|
||||
setScopedItem(EXPANDED_KEY, JSON.stringify([...expanded]), projectId);
|
||||
} catch {
|
||||
// Gracefully degrade if localStorage is unavailable
|
||||
}
|
||||
@@ -71,8 +72,12 @@ function buildTree(agents: Agent[], expanded: Set<string>): AgentNode[] {
|
||||
* Derives the tree structure from the `reportsTo` field on agents.
|
||||
* Expand/collapse state is persisted to localStorage.
|
||||
*/
|
||||
export function useAgentHierarchy(agents: Agent[]): UseAgentHierarchyReturn {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpandedFromStorage());
|
||||
export function useAgentHierarchy(agents: Agent[], projectId?: string): UseAgentHierarchyReturn {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpandedFromStorage(projectId));
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(readExpandedFromStorage(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const rootNodes = useMemo(() => buildTree(agents, expanded), [agents, expanded]);
|
||||
|
||||
@@ -84,10 +89,10 @@ export function useAgentHierarchy(agents: Agent[]): UseAgentHierarchyReturn {
|
||||
} else {
|
||||
next.add(agentId);
|
||||
}
|
||||
writeExpandedToStorage(next);
|
||||
writeExpandedToStorage(next, projectId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(agentId: string) => expanded.has(agentId),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
const STORAGE_KEY = "kb-terminal-tabs";
|
||||
|
||||
@@ -67,6 +68,21 @@ function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function readTabsFromStorage(projectId?: string): TerminalTab[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const stored = getScopedItem(STORAGE_KEY, projectId);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as TerminalTab[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isRelativeUrlFetchError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
||||
@@ -100,20 +116,9 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||
* const { tabs, activeTab, isReady, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab } = useTerminalSessions();
|
||||
* ```
|
||||
*/
|
||||
export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
export function useTerminalSessions(projectId?: string): UseTerminalSessionsReturn {
|
||||
// Initialize state synchronously from localStorage (no async here)
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>(() => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as TerminalTab[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>(() => readTabsFromStorage(projectId));
|
||||
|
||||
// Track whether validation has completed
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
@@ -126,14 +131,22 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
// bootstrap attempts. Only the current generation may mutate state.
|
||||
const generationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
setTabs(readTabsFromStorage(projectId));
|
||||
setIsReady(false);
|
||||
setServerAvailable(true);
|
||||
setBootstrapError(null);
|
||||
}, [projectId]);
|
||||
|
||||
// Persist tabs to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
setScopedItem(STORAGE_KEY, JSON.stringify(tabs), projectId);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [tabs]);
|
||||
}, [projectId, tabs]);
|
||||
|
||||
// Validate and restore tabs from server on mount
|
||||
useEffect(() => {
|
||||
@@ -208,7 +221,7 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // Only run once on mount
|
||||
}, [projectId]); // Re-run when project scope changes
|
||||
|
||||
// Auto-create first tab if no tabs exist after validation
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user