diff --git a/ui/goose2/src/app/hooks/useAppStartup.ts b/ui/goose2/src/app/hooks/useAppStartup.ts index ed41f84e1c..4cb6ed5e8e 100644 --- a/ui/goose2/src/app/hooks/useAppStartup.ts +++ b/ui/goose2/src/app/hooks/useAppStartup.ts @@ -6,33 +6,47 @@ export function useAppStartup() { useEffect(() => { (async () => { const store = useAgentStore.getState(); + const loadPersonas = async () => { + store.setPersonasLoading(true); + try { + const { listPersonas } = await import("@/shared/api/agents"); + const personas = await listPersonas(); + store.setPersonas(personas); + } catch (err) { + console.error("Failed to load personas on startup:", err); + } finally { + store.setPersonasLoading(false); + } + }; - store.setPersonasLoading(true); - try { - const { listPersonas } = await import("@/shared/api/agents"); - const personas = await listPersonas(); - store.setPersonas(personas); - } catch (err) { - console.error("Failed to load personas on startup:", err); - } finally { - store.setPersonasLoading(false); - } + const loadProviders = async () => { + store.setProvidersLoading(true); + try { + const { discoverAcpProviders } = await import("@/shared/api/acp"); + const providers = await discoverAcpProviders(); + store.setProviders(providers); + } catch (err) { + console.error("Failed to load ACP providers on startup:", err); + } finally { + store.setProvidersLoading(false); + } + }; - store.setProvidersLoading(true); - try { - const { discoverAcpProviders } = await import("@/shared/api/acp"); - const providers = await discoverAcpProviders(); - store.setProviders(providers); - } catch (err) { - console.error("Failed to load ACP providers on startup:", err); - } finally { - store.setProvidersLoading(false); - } + const loadSessionState = async () => { + const { loadSessions, loadTabState, setActiveTab } = + useChatSessionStore.getState(); + await loadSessions(); + const hydratedTabState = await loadTabState(); + if (hydratedTabState) { + setActiveTab(null); + } + }; - const { loadSessions, loadTabState } = useChatSessionStore.getState(); - await loadSessions(); - await loadTabState(); - useChatSessionStore.getState().setActiveTab(null); + await Promise.allSettled([ + loadPersonas(), + loadProviders(), + loadSessionState(), + ]); })(); }, []); } diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index ef5707e9e0..e6dfb46de4 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -10,6 +10,8 @@ import { } from "@/shared/api/chat"; import type { Session } from "@/shared/types/chat"; +const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; + // Extended session metadata for tab management export interface ChatSession { id: string; // === sessionId @@ -54,7 +56,7 @@ interface ChatSessionStoreActions { // Persistence (stubs for Phase 2b) persistTabState: () => void; - loadTabState: () => Promise; + loadTabState: () => Promise; // Helpers getSession: (id: string) => ChatSession | undefined; @@ -63,6 +65,34 @@ interface ChatSessionStoreActions { export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions; +function tabIdsMatch(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((id, index) => id === b[index]); +} + +function loadCachedSessions(): ChatSession[] { + if (typeof window === "undefined") return []; + try { + const stored = window.localStorage.getItem(SESSION_CACHE_STORAGE_KEY); + if (!stored) return []; + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? (parsed as ChatSession[]) : []; + } catch { + return []; + } +} + +function persistSessions(sessions: ChatSession[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + SESSION_CACHE_STORAGE_KEY, + JSON.stringify(sessions), + ); + } catch { + // localStorage may be unavailable + } +} + function sessionToChatSession(session: Session): ChatSession { return { id: session.id, @@ -81,7 +111,7 @@ function sessionToChatSession(session: Session): ChatSession { export const useChatSessionStore = create((set, get) => ({ // State - sessions: [], + sessions: loadCachedSessions(), openTabIds: [], activeTabId: null, isLoading: false, @@ -117,6 +147,7 @@ export const useChatSessionStore = create((set, get) => ({ set((state) => ({ sessions: [...state.sessions, chatSession], })); + persistSessions([...get().sessions]); return chatSession; }, @@ -126,6 +157,7 @@ export const useChatSessionStore = create((set, get) => ({ const backendSessions = await apiListSessions(); const chatSessions = backendSessions.map(sessionToChatSession); set({ sessions: chatSessions }); + persistSessions(chatSessions); } finally { set({ isLoading: false }); } @@ -139,6 +171,7 @@ export const useChatSessionStore = create((set, get) => ({ : s, ), })); + persistSessions(get().sessions); const backendPatch: { title?: string; providerId?: string; @@ -181,11 +214,11 @@ export const useChatSessionStore = create((set, get) => ({ try { await apiArchiveSession(id); const archivedAt = new Date().toISOString(); - set((state) => ({ - sessions: state.sessions - .map((s) => (s.id === id ? { ...s, archivedAt } : s)) - .filter((s) => !s.archivedAt), - })); + const nextSessions = get() + .sessions.map((s) => (s.id === id ? { ...s, archivedAt } : s)) + .filter((s) => !s.archivedAt); + set({ sessions: nextSessions }); + persistSessions(nextSessions); } catch (err) { set({ openTabIds, @@ -263,9 +296,24 @@ export const useChatSessionStore = create((set, get) => ({ }, loadTabState: async () => { + const baselineOpenTabIds = get().openTabIds; + const baselineActiveTabId = get().activeTabId; try { const { openTabIds, activeTabId } = await apiLoadUiState(); - const { sessions } = get(); + const { + sessions, + openTabIds: currentOpenTabIds, + activeTabId: currentActiveTabId, + } = get(); + + const userChangedTabs = + currentActiveTabId !== baselineActiveTabId || + !tabIdsMatch(currentOpenTabIds, baselineOpenTabIds); + + if (userChangedTabs) { + return false; + } + const sessionIds = new Set(sessions.map((s) => s.id)); // Filter to only tabs whose sessions still exist @@ -279,8 +327,10 @@ export const useChatSessionStore = create((set, get) => ({ openTabIds: validTabIds, activeTabId: validActiveTabId, }); + return true; } catch (err) { console.error("Failed to load tab state:", err); + return false; } }, diff --git a/ui/goose2/src/features/projects/stores/projectStore.ts b/ui/goose2/src/features/projects/stores/projectStore.ts index 3e90e8b137..f9af8285f6 100644 --- a/ui/goose2/src/features/projects/stores/projectStore.ts +++ b/ui/goose2/src/features/projects/stores/projectStore.ts @@ -7,6 +7,32 @@ import { type ProjectInfo, } from "../api/projects"; +const PROJECT_CACHE_STORAGE_KEY = "goose:projects"; + +function loadCachedProjects(): ProjectInfo[] { + if (typeof window === "undefined") return []; + try { + const stored = window.localStorage.getItem(PROJECT_CACHE_STORAGE_KEY); + if (!stored) return []; + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? (parsed as ProjectInfo[]) : []; + } catch { + return []; + } +} + +function persistProjects(projects: ProjectInfo[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + PROJECT_CACHE_STORAGE_KEY, + JSON.stringify(projects), + ); + } catch { + // localStorage may be unavailable + } +} + interface ProjectState { projects: ProjectInfo[]; loading: boolean; @@ -43,7 +69,7 @@ interface ProjectState { } export const useProjectStore = create((set, get) => ({ - projects: [], + projects: loadCachedProjects(), loading: false, activeProjectId: null, @@ -52,8 +78,9 @@ export const useProjectStore = create((set, get) => ({ try { const projects = await listProjects(); set({ projects, loading: false }); + persistProjects(projects); } catch { - set({ projects: [], loading: false }); + set({ loading: false }); } }, @@ -80,6 +107,7 @@ export const useProjectStore = create((set, get) => ({ useWorktrees, ); set((state) => ({ projects: [...state.projects, project] })); + persistProjects(get().projects); return project; }, @@ -110,6 +138,7 @@ export const useProjectStore = create((set, get) => ({ set((state) => ({ projects: state.projects.map((p) => (p.id === id ? project : p)), })); + persistProjects(get().projects); return project; }, @@ -120,6 +149,7 @@ export const useProjectStore = create((set, get) => ({ activeProjectId: state.activeProjectId === id ? null : state.activeProjectId, })); + persistProjects(get().projects); }, setActiveProject: (id) => set({ activeProjectId: id }), diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 8b4bfce20a..0e5b99c82b 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -42,6 +42,10 @@ const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [ { id: "skills", label: "Skills", icon: BookOpen }, ]; +const SIDEBAR_NAV_TEXT_CLASS = + "text-foreground-subtle hover:text-foreground hover:bg-accent"; +const EXPANDED_PROJECTS_STORAGE_KEY = "goose:sidebar:expanded-projects"; + export function Sidebar({ collapsed, width = 240, @@ -66,7 +70,17 @@ export function Sidebar({ const prevCollapsed = useRef(collapsed); const [expandedProjects, setExpandedProjects] = useState< Record - >({}); + >(() => { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(EXPANDED_PROJECTS_STORAGE_KEY); + if (!stored) return {}; + const parsed = JSON.parse(stored); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } + }); // Read all sessions and open tab IDs from the store const { sessions, openTabIds } = useChatSessionStore(); @@ -138,6 +152,32 @@ export function Sidebar({ } }, [activeTabId, sessions]); + useEffect(() => { + try { + window.localStorage.setItem( + EXPANDED_PROJECTS_STORAGE_KEY, + JSON.stringify(expandedProjects), + ); + } catch { + // localStorage may be unavailable + } + }, [expandedProjects]); + + useEffect(() => { + if (projects.length === 0) return; + const validProjectIds = new Set(projects.map((project) => project.id)); + setExpandedProjects((prev) => { + const next = Object.fromEntries( + Object.entries(prev).filter(([projectId]) => + validProjectIds.has(projectId), + ), + ); + return Object.keys(next).length === Object.keys(prev).length + ? prev + : next; + }); + }, [projects]); + const toggleProject = (projectId: string) => { setExpandedProjects((prev) => ({ ...prev, @@ -258,7 +298,8 @@ export function Sidebar({ onClick={onNewChat} title={collapsed ? "New Chat" : undefined} className={cn( - "flex items-center w-full rounded-md text-[13px] transition-all duration-200 text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50", + "flex items-center w-full rounded-md text-[13px] transition-all duration-200", + SIDEBAR_NAV_TEXT_CLASS, collapsed ? "justify-center px-0 py-1.5" : "gap-2.5 px-3 py-1.5", @@ -278,7 +319,7 @@ export function Sidebar({ {/* Nav items */} - {NAV_ITEMS.map((item, index) => { + {NAV_ITEMS.map((item) => { const Icon = item.icon; const isActive = activeView === item.id; return ( @@ -294,13 +335,9 @@ export function Sidebar({ : "gap-2.5 px-3 py-1.5", isActive ? "bg-background-secondary text-foreground" - : "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50", + : SIDEBAR_NAV_TEXT_CLASS, )} aria-current={isActive ? "page" : undefined} - style={{ - transitionDelay: - !collapsed && expanded ? `${index * 30}ms` : "0ms", - }} > {item.label} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx index d092c8b687..a001f9c79d 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx @@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from "react"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { cn } from "@/shared/lib/cn"; +const OPEN_CHAT_ROW_CLASS = "text-foreground group-hover:text-foreground"; +const INACTIVE_CHAT_ROW_CLASS = "text-foreground group-hover:text-foreground"; +const ACTIVE_CHAT_ROW_CLASS = "bg-accent text-foreground"; + interface SidebarChatRowProps { id: string; title: string; @@ -71,7 +75,7 @@ export function SidebarChatRow({ if (editing) { return ( -
+
+
- )}
)} diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css index ecd933eb6d..8094e8e0c0 100644 --- a/ui/goose2/src/shared/styles/globals.css +++ b/ui/goose2/src/shared/styles/globals.css @@ -50,6 +50,7 @@ /* Text */ --color-text-primary: #0a0a0a; + --color-text-subtle: #5f5f5f; --color-text-secondary: #878787; --color-text-tertiary: #a3a3a3; --color-text-inverse: #ffffff; @@ -138,6 +139,7 @@ /* Text */ --color-text-primary: #fafafa; + --color-text-subtle: #c2c2c2; --color-text-secondary: #a3a3a3; --color-text-tertiary: #666666; --color-text-inverse: #000000; diff --git a/ui/goose2/tailwind.config.js b/ui/goose2/tailwind.config.js index 6a989b7d52..81bf9877b3 100644 --- a/ui/goose2/tailwind.config.js +++ b/ui/goose2/tailwind.config.js @@ -63,6 +63,7 @@ export default { foreground: { DEFAULT: "var(--color-text-primary)", primary: "var(--color-text-primary)", + subtle: "var(--color-text-subtle)", secondary: "var(--color-text-secondary)", tertiary: "var(--color-text-tertiary)", inverse: "var(--color-text-inverse)",