polish sidebar hierarchy and speed up sidebar reloads (#61)

* Polish sidebar hierarchy and speed up sidebar hydration

* Avoid clobbering tab selection during startup

* Refine active chat row and project spacing
This commit is contained in:
morgmart
2026-04-01 13:28:28 -07:00
committed by GitHub
parent 38423edd55
commit 1af6e66c00
8 changed files with 215 additions and 102 deletions
+38 -24
View File
@@ -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(),
]);
})();
}, []);
}
@@ -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<void>;
loadTabState: () => Promise<boolean>;
// 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<ChatSessionStore>((set, get) => ({
// State
sessions: [],
sessions: loadCachedSessions(),
openTabIds: [],
activeTabId: null,
isLoading: false,
@@ -117,6 +147,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
set((state) => ({
sessions: [...state.sessions, chatSession],
}));
persistSessions([...get().sessions]);
return chatSession;
},
@@ -126,6 +157,7 @@ export const useChatSessionStore = create<ChatSessionStore>((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<ChatSessionStore>((set, get) => ({
: s,
),
}));
persistSessions(get().sessions);
const backendPatch: {
title?: string;
providerId?: string;
@@ -181,11 +214,11 @@ export const useChatSessionStore = create<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((set, get) => ({
openTabIds: validTabIds,
activeTabId: validActiveTabId,
});
return true;
} catch (err) {
console.error("Failed to load tab state:", err);
return false;
}
},
@@ -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<ProjectState>((set, get) => ({
projects: [],
projects: loadCachedProjects(),
loading: false,
activeProjectId: null,
@@ -52,8 +78,9 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((set, get) => ({
useWorktrees,
);
set((state) => ({ projects: [...state.projects, project] }));
persistProjects(get().projects);
return project;
},
@@ -110,6 +138,7 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((set, get) => ({
activeProjectId:
state.activeProjectId === id ? null : state.activeProjectId,
}));
persistProjects(get().projects);
},
setActiveProject: (id) => set({ activeProjectId: id }),
+45 -13
View File
@@ -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<string, boolean>
>({});
>(() => {
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({
</button>
{/* 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",
}}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span
@@ -310,11 +347,6 @@ export function Sidebar({
? "opacity-100 w-auto"
: "opacity-0 w-0 overflow-hidden",
)}
style={{
transitionDelay: labelVisible
? `${index * 30 + 60}ms`
: "0ms",
}}
>
{item.label}
</span>
@@ -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 (
<div className={cn("flex items-center group", className)}>
<div className={cn("flex items-center group rounded-md", className)}>
<div className="flex items-center flex-1 min-w-0 py-1.5 rounded-md text-[13px] px-2.5">
<input
ref={inputRef}
@@ -101,7 +105,13 @@ export function SidebarChatRow({
}
return (
<div className={cn("flex items-center group", className)}>
<div
className={cn(
"flex items-center group rounded-md transition-colors duration-150",
isActive ? "bg-accent" : "hover:bg-accent",
className,
)}
>
<button
type="button"
onClick={() => onSelect?.(id)}
@@ -112,13 +122,13 @@ export function SidebarChatRow({
}}
title="Double-click to rename"
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 rounded-md text-[13px]",
"transition-colors duration-150 px-2.5 text-left",
"flex items-center gap-2 flex-1 min-w-0 py-1.5 text-[13px]",
"transition-colors duration-150 px-2.5 text-left bg-transparent",
isActive
? "bg-background-tertiary/70 text-foreground font-medium"
? ACTIVE_CHAT_ROW_CLASS
: isOpen
? "text-foreground hover:bg-background-tertiary/50"
: "text-foreground-secondary/70 hover:text-foreground hover:bg-background-tertiary/50",
? OPEN_CHAT_ROW_CLASS
: INACTIVE_CHAT_ROW_CLASS,
)}
>
<span className="flex-1 min-w-0 truncate">{title}</span>
@@ -136,7 +146,7 @@ export function SidebarChatRow({
}}
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md",
"text-foreground-secondary/40 hover:text-foreground hover:bg-background-tertiary/50",
"text-muted-foreground/60 hover:text-foreground",
menuOpen
? "visible opacity-100"
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
@@ -12,6 +12,8 @@ import type { ProjectInfo } from "@/features/projects/api/projects";
import { SidebarChatRow } from "./SidebarChatRow";
const MAX_VISIBLE_CHATS = 3;
const PROJECT_ROW_TEXT_CLASS =
"text-foreground-subtle group-hover:text-foreground";
interface TabInfo {
id: string;
@@ -80,7 +82,7 @@ function ItemMenu({
}}
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md",
"text-foreground-secondary/40 hover:text-foreground hover:bg-background-tertiary/50",
"text-foreground-secondary/40 hover:text-foreground",
open
? "visible opacity-100"
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
@@ -161,25 +163,27 @@ function ProjectSection({
return (
<div>
{/* Project row */}
<div className="flex items-center group">
<div className="flex items-center group rounded-md transition-colors duration-150 hover:bg-accent">
<button
type="button"
onClick={() => toggleProject(project.id)}
className={cn(
"flex items-center flex-1 min-w-0 gap-2 py-1.5 px-2.5 rounded-md text-[13px]",
"flex items-center flex-1 min-w-0 gap-1.5 py-1.5 px-2.5 text-[13px]",
"transition-colors duration-150",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
PROJECT_ROW_TEXT_CLASS,
)}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 flex-shrink-0" />
)}
<span
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color }}
/>
<span className="relative flex h-3 w-3 flex-shrink-0 items-center justify-center">
<span
className="absolute inline-block h-2 w-2 rounded-full transition-opacity duration-150 group-hover:opacity-0"
style={{ backgroundColor: project.color }}
/>
{isExpanded ? (
<ChevronDown className="absolute h-3 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
) : (
<ChevronRight className="absolute h-3 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100" />
)}
</span>
<span className="flex-1 min-w-0 truncate text-left">
{project.name}
</span>
@@ -198,7 +202,7 @@ function ProjectSection({
title="New chat in project"
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md mr-1 flex-shrink-0",
"text-foreground-secondary/50 hover:text-foreground hover:bg-background-tertiary/50",
"text-foreground-secondary/50 hover:text-foreground",
"invisible group-hover:visible opacity-0 group-hover:opacity-100",
)}
>
@@ -208,7 +212,7 @@ function ProjectSection({
{/* Nested chats */}
{isExpanded && (
<div className="space-y-0.5">
<div className="mt-0.5 space-y-0.5">
{visibleChats.map((tab) => {
const isActive = activeTabId === tab.id;
const isOpen = tab.isOpenTab ?? false;
@@ -285,12 +289,6 @@ export function SidebarProjectsSection({
onArchiveChat,
onRenameChat,
}: SidebarProjectsSectionProps) {
const [showAllRecents, setShowAllRecents] = useState(false);
const visibleRecents = showAllRecents
? projectTabs.standalone
: projectTabs.standalone.slice(0, MAX_VISIBLE_CHATS);
const hasMoreRecents = projectTabs.standalone.length > MAX_VISIBLE_CHATS;
return (
<div
className={cn(
@@ -404,7 +402,7 @@ export function SidebarProjectsSection({
{collapsed ? (
<div className="flex flex-col items-center gap-1">
{visibleRecents.map((tab) => (
{projectTabs.standalone.map((tab) => (
<button
type="button"
key={tab.id}
@@ -423,7 +421,7 @@ export function SidebarProjectsSection({
</div>
) : (
<div className="space-y-0.5">
{visibleRecents.map((tab) => {
{projectTabs.standalone.map((tab) => {
const isActive = activeTabId === tab.id;
const isOpen = tab.isOpenTab ?? false;
return (
@@ -439,30 +437,6 @@ export function SidebarProjectsSection({
/>
);
})}
{hasMoreRecents && (
<button
type="button"
onClick={() => setShowAllRecents((prev) => !prev)}
className={cn(
"flex items-center gap-1.5 w-full px-2.5 py-1 text-[11px]",
"text-foreground-secondary/60 hover:text-foreground-secondary transition-colors duration-150",
)}
>
{showAllRecents ? (
<>
<ChevronDown className="w-3 h-3" />
Show less
</>
) : (
<>
<ChevronRight className="w-3 h-3" />
{projectTabs.standalone.length > 8
? `View all ${projectTabs.standalone.length} chats`
: `${projectTabs.standalone.length - MAX_VISIBLE_CHATS} more`}
</>
)}
</button>
)}
</div>
)}
</>
+2
View File
@@ -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;
+1
View File
@@ -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)",