Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
1aed54236f fix(views): reset scope to mine when switching to a workspace with no persisted value
zustand persist.rehydrate() is a no-op when storage returns null, so
workspaces with no entry kept the previous workspace's in-memory scope
("all" leaked from one workspace into the next). Provide a custom merge
that resets to the default "mine" when no persisted state is present.

Add coverage for the missing-storage workspace-switch case for both
Agents and Squads.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 19:54:29 +08:00
Jiayuan Zhang
5dbd752368 MUL-2216: feat(agents,squads): persist Mine/All tab selection per workspace
Tab selection on the Agents and Squads list pages was held in
component-local state, so navigating into a detail page and back
remounted the list and reset the tab to the default "Mine". Move
`scope` into Zustand stores backed by `persist` +
`createWorkspaceAwareStorage`, matching the pattern used by the
Issues view store. Selection now survives list → detail → back
navigation and page reloads, scoped per workspace.

Only `scope` is persisted; `search`, `sort`, and other ephemeral
filters intentionally still reset on remount.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 19:46:21 +08:00
10 changed files with 294 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
export {
useAgentsViewStore,
type AgentsScope,
type AgentsViewState,
} from "./view-store";

View File

@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useAgentsViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useAgentsViewStore.setState({ scope: "mine" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useAgentsViewStore", () => {
it("defaults to 'mine'", () => {
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("setScope mutates the store", () => {
useAgentsViewStore.getState().setScope("all");
expect(useAgentsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useAgentsViewStore.getState().setScope("all");
const raw = localStorage.getItem("multica_agents_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
});
it("rehydrates a different saved scope on workspace switch", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
localStorage.setItem(
"multica_agents_view:beta",
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
});
});

View File

@@ -0,0 +1,40 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type AgentsScope = "mine" | "all";
export interface AgentsViewState {
scope: AgentsScope;
setScope: (scope: AgentsScope) => void;
}
export const useAgentsViewStore = create<AgentsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
}),
{
name: "multica_agents_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<AgentsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());

View File

@@ -54,6 +54,9 @@
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./agents/stores": "./agents/stores/index.ts",
"./squads": "./squads/index.ts",
"./squads/stores": "./squads/stores/index.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",

View File

@@ -0,0 +1 @@
export * from "./stores";

View File

@@ -0,0 +1,5 @@
export {
useSquadsViewStore,
type SquadsScope,
type SquadsViewState,
} from "./view-store";

View File

@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useSquadsViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useSquadsViewStore.setState({ scope: "mine" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useSquadsViewStore", () => {
it("defaults to 'mine'", () => {
expect(useSquadsViewStore.getState().scope).toBe("mine");
});
it("setScope mutates the store", () => {
useSquadsViewStore.getState().setScope("all");
expect(useSquadsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useSquadsViewStore.getState().setScope("all");
const raw = localStorage.getItem("multica_squads_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
});
it("rehydrates a different saved scope on workspace switch", async () => {
localStorage.setItem(
"multica_squads_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
localStorage.setItem(
"multica_squads_view:beta",
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("mine");
});
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_squads_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_squads_view:acme")).not.toBeNull();
});
});

View File

@@ -0,0 +1,40 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type SquadsScope = "mine" | "all";
export interface SquadsViewState {
scope: SquadsScope;
setScope: (scope: SquadsScope) => void;
}
export const useSquadsViewStore = create<SquadsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
}),
{
name: "multica_squads_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<SquadsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() => useSquadsViewStore.persist.rehydrate());

View File

@@ -19,6 +19,7 @@ import {
useWorkspaceActivityMap,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { useAgentsViewStore } from "@multica/core/agents/stores";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -100,10 +101,10 @@ export function AgentsPage() {
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const [view, setView] = useState<View>("active");
// Default to "mine" — matches runtimes page convention and the visual
// ordering (Mine first). All is one click away when users want the
// workspace-wide view.
const [scope, setScope] = useState<Scope>("mine");
// Scope (Mine/All) is persisted per workspace so it survives list →
// detail → back navigation. Default is "mine" on first visit.
const scope = useAgentsViewStore((s) => s.scope);
const setScope = useAgentsViewStore((s) => s.setScope);
const [availabilityFilter, setAvailabilityFilter] =
useState<AvailabilityFilter>("all");
const [sort, setSort] = useState<SortKey>("recent");

View File

@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { agentListOptions, memberListOptions, squadListOptions } from "@multica/core/workspace/queries";
import { useAuthStore } from "@multica/core/auth";
import { useSquadsViewStore } from "@multica/core/squads/stores";
import { AppLink } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
import { Users, Plus, Search, Bot, User } from "lucide-react";
@@ -43,7 +44,8 @@ export function SquadsPage() {
return m;
}, [members]);
const [scope, setScope] = useState<Scope>("mine");
const scope = useSquadsViewStore((s) => s.scope);
const setScope = useSquadsViewStore((s) => s.setScope);
const [search, setSearch] = useState("");
const scopeCounts = useMemo(() => {