diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index cc7ac43465..551d04e6c4 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -47,6 +47,7 @@ import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate"; import { Spinner } from "@/shared/ui/spinner"; +import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels"; export type AppView = | "home" @@ -57,8 +58,10 @@ export type AppView = | "projects" | "session-history"; -const SIDEBAR_DEFAULT_WIDTH = 240; -const SIDEBAR_MIN_WIDTH = 180; +const SIDEBAR_OUTER_GUTTER_WIDTH = 12; +const SIDEBAR_RESIZE_HANDLE_WIDTH = 12; +const SIDEBAR_DEFAULT_WIDTH = SIDE_PANEL_DEFAULT_WIDTH; +const SIDEBAR_MIN_WIDTH = 220; const SIDEBAR_MAX_WIDTH = 380; const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100; const SIDEBAR_COLLAPSED_WIDTH = 48; @@ -818,8 +821,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) { className="flex-shrink-0 h-full py-3 pl-3" style={{ width: sidebarCollapsed - ? SIDEBAR_COLLAPSED_WIDTH + 12 - : sidebarWidth + 12, + ? SIDEBAR_COLLAPSED_WIDTH + SIDEBAR_OUTER_GUTTER_WIDTH + : sidebarWidth + SIDEBAR_OUTER_GUTTER_WIDTH, transition: isResizing ? "none" : "width 200ms ease-out", }} > @@ -855,7 +858,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
diff --git a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx index 5d94d12b89..043a58eb60 100644 --- a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx @@ -3,13 +3,15 @@ import { IconLayoutSidebarRightFilled, } from "@tabler/icons-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; +import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels"; import { ContextPanel } from "./ContextPanel"; const CP_PAD = 12; -const CP_TOTAL_W = 340 + CP_PAD * 2; +const CP_PANEL_W = SIDE_PANEL_DEFAULT_WIDTH; +const CP_TOTAL_W = CP_PANEL_W + CP_PAD * 2; const CP_TOGGLE_RIGHT = CP_PAD + 12; const CP_TOGGLE_TOP = CP_PAD + 10; const CP_FADE_S = 0.15; @@ -73,12 +75,14 @@ export function ChatContextPanel({ className={cn( "flex", isCompactViewport - ? "absolute bottom-3 right-3 top-12 z-10 w-[min(340px,calc(100%-1.5rem))]" + ? "absolute bottom-3 right-3 top-12 z-10 w-[min(var(--context-panel-width),calc(100%-1.5rem))]" : "h-full", )} style={ isCompactViewport - ? undefined + ? ({ + "--context-panel-width": `${CP_PANEL_W}px`, + } as CSSProperties) : { width: CP_TOTAL_W, padding: CP_PAD, diff --git a/ui/goose2/src/features/chat/ui/ContextPanel.tsx b/ui/goose2/src/features/chat/ui/ContextPanel.tsx index bff41aa824..1d60d671ea 100644 --- a/ui/goose2/src/features/chat/ui/ContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ContextPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { FilesList } from "./FilesList"; import { useGitState } from "@/shared/hooks/useGitState"; @@ -29,6 +29,35 @@ interface ContextPanelProps { } type ContextPanelTab = "details" | "files"; +type ContextPanelSection = "workspace" | "changes" | "artifacts"; +type ContextPanelSectionVisibility = Record; + +const SECTION_VISIBILITY_STORAGE_KEY = "goose:context-panel:section-visibility"; + +function getStoredSectionVisibility(): ContextPanelSectionVisibility { + const defaults = { workspace: true, changes: true, artifacts: true }; + if (typeof window === "undefined") return defaults; + try { + const stored = window.localStorage.getItem(SECTION_VISIBILITY_STORAGE_KEY); + if (!stored) return defaults; + const parsed = JSON.parse(stored); + if (!parsed || typeof parsed !== "object") return defaults; + return { + workspace: + typeof parsed.workspace === "boolean" + ? parsed.workspace + : defaults.workspace, + changes: + typeof parsed.changes === "boolean" ? parsed.changes : defaults.changes, + artifacts: + typeof parsed.artifacts === "boolean" + ? parsed.artifacts + : defaults.artifacts, + }; + } catch { + return defaults; + } +} export function ContextPanel({ sessionId, @@ -38,6 +67,9 @@ export function ContextPanel({ }: ContextPanelProps) { const { t } = useTranslation("chat"); const [activeTab, setActiveTab] = useState("details"); + const [sectionVisibility, setSectionVisibility] = useState( + getStoredSectionVisibility, + ); const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null; const activeContext = useChatSessionStore( @@ -157,13 +189,31 @@ export function ContextPanel({ void refetchAll(); }, [refetchAll]); + useEffect(() => { + try { + window.localStorage.setItem( + SECTION_VISIBILITY_STORAGE_KEY, + JSON.stringify(sectionVisibility), + ); + } catch { + // localStorage may be unavailable + } + }, [sectionVisibility]); + + const toggleSection = useCallback((section: ContextPanelSection) => { + setSectionVisibility((prev) => ({ + ...prev, + [section]: !prev[section], + })); + }, []); + return ( setActiveTab(value as ContextPanelTab)} - className="flex h-full min-w-0 flex-1 flex-col" + className="flex h-full min-w-0 flex-1 flex-col gap-0" > -
+
{t("contextPanel.tabs.details")} @@ -175,7 +225,7 @@ export function ContextPanel({
-
+
toggleSection("workspace")} /> toggleSection("changes")} + /> + toggleSection("artifacts")} /> -
diff --git a/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx index 89747e53be..df8c6063d9 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -74,6 +74,7 @@ describe("ContextPanel", () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); mockRefetch.mockResolvedValue(undefined); mockRefetchFiles.mockResolvedValue(undefined); mockListDirectoryEntries.mockResolvedValue([]); @@ -143,6 +144,24 @@ describe("ContextPanel", () => { expect(screen.getByText("goose2")).toBeInTheDocument(); }); + it("collapses and expands context panel sections", async () => { + const user = userEvent.setup(); + + renderContextPanel({ + sessionId: "test-session-collapse", + projectName: "Desktop UX", + projectColor: "#22c55e", + }); + + await user.click(screen.getByRole("button", { name: /workspace/i })); + + expect(screen.queryByText("Desktop UX")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /workspace/i })); + + expect(screen.getByText("Desktop UX")).toBeInTheDocument(); + }); + it("shows path and init button for non-git directory", async () => { mockUseGitState.mockReturnValue({ data: { diff --git a/ui/goose2/src/features/chat/ui/widgets/ArtifactsWidget.tsx b/ui/goose2/src/features/chat/ui/widgets/ArtifactsWidget.tsx index 8ddd1e227c..22dd76a9f0 100644 --- a/ui/goose2/src/features/chat/ui/widgets/ArtifactsWidget.tsx +++ b/ui/goose2/src/features/chat/ui/widgets/ArtifactsWidget.tsx @@ -59,7 +59,15 @@ function getArtifactIcon(artifact: SessionArtifact) { return IconFile; } -export function ArtifactsWidget() { +interface ArtifactsWidgetProps { + isOpen: boolean; + onToggleOpen: () => void; +} + +export function ArtifactsWidget({ + isOpen, + onToggleOpen, +}: ArtifactsWidgetProps) { const { t } = useTranslation("chat"); const { getAllSessionArtifacts, openResolvedPath } = useArtifactPolicyContext(); @@ -77,6 +85,8 @@ export function ArtifactsWidget() { } + isOpen={isOpen} + onToggleOpen={onToggleOpen} action={ {artifacts.length} @@ -93,11 +103,11 @@ export function ArtifactsWidget() { > diff --git a/ui/goose2/src/features/chat/ui/widgets/ChangesWidget.tsx b/ui/goose2/src/features/chat/ui/widgets/ChangesWidget.tsx index 455ad8ac0e..219037c990 100644 --- a/ui/goose2/src/features/chat/ui/widgets/ChangesWidget.tsx +++ b/ui/goose2/src/features/chat/ui/widgets/ChangesWidget.tsx @@ -34,7 +34,8 @@ function ChangedFileRow({ type="button" disabled={isDeleted} className={cn( - "flex w-full select-none items-center gap-2 px-3 py-1.5 text-left", + "relative flex w-full select-none items-center gap-2 px-4 py-1.5 text-left", + "before:pointer-events-none before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-border/70 before:content-['']", "transition-colors duration-100", isDeleted ? "cursor-default opacity-60" : "hover:bg-muted/80", )} @@ -47,11 +48,11 @@ function ChangedFileRow({ )} > {dir && ( - + {dir} )} - + {name}
@@ -69,6 +70,8 @@ interface ChangesWidgetProps { currentBranch: string | null; repoPath: string; onOpenFile: (path: string) => void; + isOpen: boolean; + onToggleOpen: () => void; } export function ChangesWidget({ @@ -77,6 +80,8 @@ export function ChangesWidget({ currentBranch, repoPath, onOpenFile, + isOpen, + onToggleOpen, }: ChangesWidgetProps) { const { t } = useTranslation("chat"); @@ -97,7 +102,7 @@ export function ChangesWidget({
{t("contextPanel.widgets.changes")} {currentBranch && ( - + {t("contextPanel.widgets.changesOnBranch")} @@ -128,9 +133,11 @@ export function ChangesWidget({ icon={} action={headerAction} flush={hasChanges} + isOpen={isOpen} + onToggleOpen={onToggleOpen} > {isLoading && !files ? ( -
+
@@ -147,7 +154,7 @@ export function ChangesWidget({ ))}
) : ( -

+

{t("contextPanel.empty.noChanges")}

)} diff --git a/ui/goose2/src/features/chat/ui/widgets/Widget.tsx b/ui/goose2/src/features/chat/ui/widgets/Widget.tsx index 63c2edae8a..05007bff7c 100644 --- a/ui/goose2/src/features/chat/ui/widgets/Widget.tsx +++ b/ui/goose2/src/features/chat/ui/widgets/Widget.tsx @@ -1,30 +1,69 @@ import type { ReactNode } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; +import { cn } from "@/shared/lib/cn"; interface WidgetProps { title: ReactNode; icon: ReactNode; action?: ReactNode; flush?: boolean; + isOpen?: boolean; + onToggleOpen?: () => void; children: ReactNode; } -export function Widget({ title, icon, action, flush, children }: WidgetProps) { +const SECTION_HEADER_TEXT_CLASS = + "min-w-0 overflow-hidden text-[11px] font-medium uppercase tracking-[0.08em] text-foreground-subtle"; + +export function Widget({ + title, + icon, + action, + flush, + isOpen = true, + onToggleOpen, + children, +}: WidgetProps) { + const headerTitle = ( + <> + {onToggleOpen ? ( + + ) : null} + {icon} +
{title}
+ + ); + return ( -
-
-
- {icon} - {title} +
+
+
+ {onToggleOpen ? ( + + ) : ( +
+ {headerTitle} +
+ )} + {action &&
{action}
}
- {action &&
{action}
} + {isOpen && !flush && ( +
{children}
+ )}
- {flush ? ( - children - ) : ( -
- {children} -
- )} -
+ {isOpen && flush ?
{children}
: null} + ); } diff --git a/ui/goose2/src/features/chat/ui/widgets/WorkingContextPicker.tsx b/ui/goose2/src/features/chat/ui/widgets/WorkingContextPicker.tsx index 87c1787c8d..77a18bd1eb 100644 --- a/ui/goose2/src/features/chat/ui/widgets/WorkingContextPicker.tsx +++ b/ui/goose2/src/features/chat/ui/widgets/WorkingContextPicker.tsx @@ -204,17 +204,17 @@ export function WorkingContextPicker({ type="button" className={cn( "flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-2", - "text-xs text-foreground transition-colors", + "text-sm text-foreground transition-colors", "hover:bg-background-alt focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", )} aria-label={t("contextPanel.picker.selectContext")} > - + - + {activeWorktreeLabel ?? t("contextPanel.empty.folderNotSet")} - + {t("contextPanel.picker.checkedOutBranch", { branch: activeBranchLabel, })} @@ -239,18 +239,18 @@ export function WorkingContextPicker({ key={wt.path} type="button" className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors", + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors", "hover:bg-muted focus-visible:outline-none focus-visible:bg-muted", isWorktreeSelected(wt.path) && "bg-muted", )} onClick={() => handleWorktreeSelect(wt.path, wt.branch)} > - +
- + {worktreeName(wt.path)} - + {t("contextPanel.picker.checkedOutBranch", { branch: wt.branch ?? t("contextPanel.states.detached"), })} @@ -283,19 +283,19 @@ export function WorkingContextPicker({ type="button" disabled={switching || isCurrentBranch} className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors", + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors", "hover:bg-muted focus-visible:outline-none focus-visible:bg-muted", "disabled:opacity-50", )} onClick={() => handleBranchSelect(branch)} > - +
- + {branch} {branchMeta ? ( - + {branchMeta} ) : null} diff --git a/ui/goose2/src/features/chat/ui/widgets/WorkspaceWidget.tsx b/ui/goose2/src/features/chat/ui/widgets/WorkspaceWidget.tsx index 752a925ad3..1fa346aa22 100644 --- a/ui/goose2/src/features/chat/ui/widgets/WorkspaceWidget.tsx +++ b/ui/goose2/src/features/chat/ui/widgets/WorkspaceWidget.tsx @@ -36,6 +36,8 @@ interface WorkspaceWidgetProps { baseBranch?: string, ) => Promise; onRefresh: () => void; + isOpen: boolean; + onToggleOpen: () => void; } export function WorkspaceWidget({ @@ -56,6 +58,8 @@ export function WorkspaceWidget({ onCreateBranch, onCreateWorktree, onRefresh, + isOpen, + onToggleOpen, }: WorkspaceWidgetProps) { const { t } = useTranslation("chat"); const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null; @@ -67,6 +71,8 @@ export function WorkspaceWidget({ } + isOpen={isOpen} + onToggleOpen={onToggleOpen} action={
diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index ae0a459ae5..8a47ebc285 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -33,6 +33,7 @@ import { useProjectStore } from "@/features/projects/stores/projectStore"; import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { Button } from "@/shared/ui/button"; import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch"; +import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels"; import { SidebarProjectsSection } from "./SidebarProjectsSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarSearchResults } from "./SidebarSearchResults"; @@ -66,10 +67,36 @@ interface SidebarProps { } const EXPANDED_PROJECTS_STORAGE_KEY = "goose:sidebar:expanded-projects"; +const SECTION_VISIBILITY_STORAGE_KEY = "goose:sidebar:section-visibility"; +type SidebarSectionVisibility = { + projects: boolean; + recents: boolean; +}; + +function getStoredSectionVisibility(): SidebarSectionVisibility { + const defaults = { projects: true, recents: true }; + if (typeof window === "undefined") return defaults; + try { + const stored = window.localStorage.getItem(SECTION_VISIBILITY_STORAGE_KEY); + if (!stored) return defaults; + const parsed = JSON.parse(stored); + if (!parsed || typeof parsed !== "object") return defaults; + return { + projects: + typeof parsed.projects === "boolean" + ? parsed.projects + : defaults.projects, + recents: + typeof parsed.recents === "boolean" ? parsed.recents : defaults.recents, + }; + } catch { + return defaults; + } +} export function Sidebar({ collapsed, - width = 240, + width = SIDE_PANEL_DEFAULT_WIDTH, isResizing = false, onCollapse, onSettingsClick, @@ -107,6 +134,9 @@ export function Sidebar({ return {}; } }); + const [sectionVisibility, setSectionVisibility] = useState( + getStoredSectionVisibility, + ); const messagesBySession = useChatStore(selectMessagesBySession); const sessionStateById = useChatStore(selectSessionStateById); @@ -239,6 +269,17 @@ export function Sidebar({ } }, [expandedProjects]); + useEffect(() => { + try { + window.localStorage.setItem( + SECTION_VISIBILITY_STORAGE_KEY, + JSON.stringify(sectionVisibility), + ); + } catch { + // localStorage may be unavailable + } + }, [sectionVisibility]); + useEffect(() => { if (projects.length === 0) return; const validProjectIds = new Set(projects.map((project) => project.id)); @@ -267,6 +308,9 @@ export function Sidebar({ const toggleProject = (projectId: string) => setExpandedProjects((prev) => ({ ...prev, [projectId]: !prev[projectId] })); + const toggleSection = (section: keyof SidebarSectionVisibility) => { + setSectionVisibility((prev) => ({ ...prev, [section]: !prev[section] })); + }; return (
@@ -320,7 +364,7 @@ export function Sidebar({ type="button" onClick={onCollapse} title={t("actions.expand")} - className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-foreground transition-colors duration-200 hover:text-foreground" + className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-200 hover:text-foreground" aria-label={t("actions.expand")} > @@ -455,6 +499,10 @@ export function Sidebar({ onRenameChat={onRenameChat} onMoveToProject={onMoveToProject} onReorderProject={onReorderProject} + projectsSectionOpen={sectionVisibility.projects} + recentsSectionOpen={sectionVisibility.recents} + onToggleProjectsSection={() => toggleSection("projects")} + onToggleRecentsSection={() => toggleSection("recents")} /> ))} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index 39d5517a4d..dd2fa159c1 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { IconChevronDown } from "@tabler/icons-react"; import type { AppView } from "@/app/AppShell"; import type { ProjectInfo } from "@/features/projects/api/projects"; import { cn } from "@/shared/lib/cn"; @@ -36,8 +37,15 @@ interface SidebarProjectsSectionProps { onRenameChat?: (sessionId: string, nextTitle: string) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void; onReorderProject?: (fromId: string, toId: string) => void; + projectsSectionOpen: boolean; + recentsSectionOpen: boolean; + onToggleProjectsSection: () => void; + onToggleRecentsSection: () => void; } +const SECTION_HEADER_TEXT_CLASS = + "text-[11px] font-medium uppercase tracking-[0.08em] text-foreground-subtle"; + export function SidebarProjectsSection({ projects, projectSessions, @@ -58,8 +66,13 @@ export function SidebarProjectsSection({ onRenameChat, onMoveToProject, onReorderProject, + projectsSectionOpen, + recentsSectionOpen, + onToggleProjectsSection, + onToggleRecentsSection, }: SidebarProjectsSectionProps) { const { t } = useTranslation(["sidebar", "common"]); + const showProjects = collapsed || projectsSectionOpen; return (
- - {t("sections.projects")} - + {!collapsed && ( + + )} {!collapsed && (
); diff --git a/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx index 19f6dea6dc..3f905410a0 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarRecentsSection.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; -import { IconEdit, IconMessage } from "@tabler/icons-react"; +import { IconChevronDown, IconEdit, IconMessage } from "@tabler/icons-react"; import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -26,6 +26,9 @@ export function SidebarRecentsSection({ onArchiveChat, onRenameChat, onMoveToProject, + isOpen, + onToggleOpen, + sectionHeaderTextClass, }: { sessions: TabInfo[]; collapsed: boolean; @@ -37,9 +40,13 @@ export function SidebarRecentsSection({ onArchiveChat?: (sessionId: string) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void; + isOpen: boolean; + onToggleOpen: () => void; + sectionHeaderTextClass: string; }) { const { t } = useTranslation(["sidebar", "common"]); const [recentsDragOver, setRecentsDragOver] = useState(false); + const showContent = collapsed || isOpen; const handleRecentsDragOver = useCallback((e: DragEvent) => { const hasSession = e.dataTransfer.types.includes("text/x-session-id"); @@ -81,17 +88,30 @@ export function SidebarRecentsSection({ collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5", )} > - - {t("sections.recents")} - + {!collapsed && ( + + )} {!collapsed && onNewChat && (