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:
gsxdsm
2026-04-08 10:39:38 -07:00
parent 110971333f
commit 29255a4a6a
28 changed files with 720 additions and 393 deletions

View File

@@ -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("");
});
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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(() => {