0eaaa54e25
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
/**
|
|
* Current Project / Agent context:
|
|
* - Project list (owned + authorized); the current selection is remembered in localStorage and
|
|
* synced to server-side prefs (lastProjectId, best-effort);
|
|
* - the current Project's Agent list and current Agent (switched via the top-bar breadcrumb
|
|
* dropdown; remembered per Project).
|
|
*/
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import type { AgentSummary, ProjectSummary } from "@prismshadow/penguin-server/api";
|
|
import * as api from "../api/endpoints";
|
|
|
|
const PROJECT_KEY = "penguin.lastProjectId";
|
|
const agentKey = (projectId: string) => `penguin.lastAgentId.${projectId}`;
|
|
|
|
interface ProjectContextValue {
|
|
projects: ProjectSummary[];
|
|
projectsLoading: boolean;
|
|
currentProject: ProjectSummary | null;
|
|
setCurrentProjectId: (projectId: string) => void;
|
|
reloadProjects: () => Promise<void>;
|
|
|
|
agents: AgentSummary[];
|
|
agentsLoading: boolean;
|
|
currentAgent: AgentSummary | null;
|
|
setCurrentAgentId: (agentId: string) => void;
|
|
reloadAgents: () => Promise<void>;
|
|
}
|
|
|
|
const ProjectContext = createContext<ProjectContextValue | null>(null);
|
|
|
|
/** Project display name fallback: falls back to projectId when name is absent. */
|
|
export function projectDisplayName(p: ProjectSummary): string {
|
|
return p.name ?? p.projectId;
|
|
}
|
|
|
|
/** Agent display name fallback: falls back to agentId when name is absent. */
|
|
export function agentDisplayName(a: AgentSummary): string {
|
|
return a.name ?? a.agentId;
|
|
}
|
|
|
|
export function ProjectProvider({ children }: { children: ReactNode }) {
|
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
|
const [projectsLoading, setProjectsLoading] = useState(true);
|
|
const [currentProjectId, setCurrentProjectIdState] = useState<string | null>(null);
|
|
|
|
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
const [agentsLoading, setAgentsLoading] = useState(true);
|
|
const [currentAgentId, setCurrentAgentIdState] = useState<string | null>(null);
|
|
|
|
const reloadProjects = useCallback(async () => {
|
|
setProjectsLoading(true);
|
|
try {
|
|
const res = await api.listProjects();
|
|
setProjects(res.projects);
|
|
setCurrentProjectIdState((prev) => {
|
|
const wanted = prev ?? localStorage.getItem(PROJECT_KEY);
|
|
const found = res.projects.find((p) => p.projectId === wanted);
|
|
return (found ?? res.projects[0])?.projectId ?? null;
|
|
});
|
|
} finally {
|
|
setProjectsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void reloadProjects();
|
|
}, [reloadProjects]);
|
|
|
|
const setCurrentProjectId = useCallback(
|
|
(projectId: string) => {
|
|
// If the selection is already the current Project, return immediately. Otherwise the
|
|
// code below would clear agents and set loading back to true while currentProjectId
|
|
// stays unchanged — reloadAgents' effect depends on it and wouldn't rerun, so the Agent
|
|
// list (and the Session list mounted under it) would disappear for good (reproducible
|
|
// by clicking the already-current Project in the dropdown).
|
|
if (projectId === currentProjectId) return;
|
|
localStorage.setItem(PROJECT_KEY, projectId);
|
|
setCurrentProjectIdState(projectId);
|
|
setCurrentAgentIdState(null);
|
|
// Clear the Agent list in sync: avoids a transient render with "new projectId + old
|
|
// Project's agents" that would make downstream consumers (Sessions) fetch with the
|
|
// wrong Agent set (which could create spurious Sessions under the new Project).
|
|
setAgents([]);
|
|
setAgentsLoading(true);
|
|
// Sync server-side prefs (best-effort; failure doesn't affect the local experience).
|
|
void api.putPrefs({ lastProjectId: projectId }).catch(() => undefined);
|
|
},
|
|
[currentProjectId],
|
|
);
|
|
|
|
const reloadAgents = useCallback(async () => {
|
|
if (!currentProjectId) return;
|
|
setAgentsLoading(true);
|
|
try {
|
|
const res = await api.listAgents(currentProjectId);
|
|
setAgents(res.agents);
|
|
setCurrentAgentIdState((prev) => {
|
|
const wanted = prev ?? localStorage.getItem(agentKey(currentProjectId));
|
|
const found = res.agents.find((a) => a.agentId === wanted);
|
|
// Default to conversing with default_agent.
|
|
const fallback =
|
|
res.agents.find((a) => a.agentId === "default_agent") ?? res.agents[0] ?? null;
|
|
return (found ?? fallback)?.agentId ?? null;
|
|
});
|
|
} finally {
|
|
setAgentsLoading(false);
|
|
}
|
|
}, [currentProjectId]);
|
|
|
|
useEffect(() => {
|
|
setAgents([]);
|
|
void reloadAgents();
|
|
}, [reloadAgents]);
|
|
|
|
const setCurrentAgentId = useCallback(
|
|
(agentId: string) => {
|
|
if (currentProjectId) localStorage.setItem(agentKey(currentProjectId), agentId);
|
|
setCurrentAgentIdState(agentId);
|
|
},
|
|
[currentProjectId],
|
|
);
|
|
|
|
const value = useMemo<ProjectContextValue>(() => {
|
|
const currentProject = projects.find((p) => p.projectId === currentProjectId) ?? null;
|
|
const currentAgent = agents.find((a) => a.agentId === currentAgentId) ?? null;
|
|
return {
|
|
projects,
|
|
projectsLoading,
|
|
currentProject,
|
|
setCurrentProjectId,
|
|
reloadProjects,
|
|
agents,
|
|
agentsLoading,
|
|
currentAgent,
|
|
setCurrentAgentId,
|
|
reloadAgents,
|
|
};
|
|
}, [
|
|
projects,
|
|
projectsLoading,
|
|
currentProjectId,
|
|
setCurrentProjectId,
|
|
reloadProjects,
|
|
agents,
|
|
agentsLoading,
|
|
currentAgentId,
|
|
setCurrentAgentId,
|
|
reloadAgents,
|
|
]);
|
|
|
|
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
|
|
}
|
|
|
|
export function useProject(): ProjectContextValue {
|
|
const ctx = useContext(ProjectContext);
|
|
if (!ctx) throw new Error("useProject must be used within a ProjectProvider");
|
|
return ctx;
|
|
}
|