polish sidebar and context panel (#9059)

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi
2026-05-12 15:25:51 -07:00
committed by GitHub
parent 6ffb26dccc
commit 75e89964a4
14 changed files with 382 additions and 98 deletions
+9 -5
View File
@@ -47,6 +47,7 @@ import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate"; import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate";
import { Spinner } from "@/shared/ui/spinner"; import { Spinner } from "@/shared/ui/spinner";
import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels";
export type AppView = export type AppView =
| "home" | "home"
@@ -57,8 +58,10 @@ export type AppView =
| "projects" | "projects"
| "session-history"; | "session-history";
const SIDEBAR_DEFAULT_WIDTH = 240; const SIDEBAR_OUTER_GUTTER_WIDTH = 12;
const SIDEBAR_MIN_WIDTH = 180; 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_MAX_WIDTH = 380;
const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100; const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100;
const SIDEBAR_COLLAPSED_WIDTH = 48; 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" className="flex-shrink-0 h-full py-3 pl-3"
style={{ style={{
width: sidebarCollapsed width: sidebarCollapsed
? SIDEBAR_COLLAPSED_WIDTH + 12 ? SIDEBAR_COLLAPSED_WIDTH + SIDEBAR_OUTER_GUTTER_WIDTH
: sidebarWidth + 12, : sidebarWidth + SIDEBAR_OUTER_GUTTER_WIDTH,
transition: isResizing ? "none" : "width 200ms ease-out", transition: isResizing ? "none" : "width 200ms ease-out",
}} }}
> >
@@ -855,7 +858,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
<div <div
onMouseDown={handleResizeStart} onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick} onDoubleClick={handleResizeDoubleClick}
className="flex-shrink-0 w-4 h-full cursor-col-resize group flex items-center justify-center" className="flex-shrink-0 h-full cursor-col-resize group flex items-center justify-center"
style={{ width: SIDEBAR_RESIZE_HANDLE_WIDTH }}
> >
<div className="w-px h-8 rounded-full bg-transparent group-hover:bg-border transition-colors" /> <div className="w-px h-8 rounded-full bg-transparent group-hover:bg-border transition-colors" />
</div> </div>
@@ -3,13 +3,15 @@ import {
IconLayoutSidebarRightFilled, IconLayoutSidebarRightFilled,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/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 { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels";
import { ContextPanel } from "./ContextPanel"; import { ContextPanel } from "./ContextPanel";
const CP_PAD = 12; 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_RIGHT = CP_PAD + 12;
const CP_TOGGLE_TOP = CP_PAD + 10; const CP_TOGGLE_TOP = CP_PAD + 10;
const CP_FADE_S = 0.15; const CP_FADE_S = 0.15;
@@ -73,12 +75,14 @@ export function ChatContextPanel({
className={cn( className={cn(
"flex", "flex",
isCompactViewport 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", : "h-full",
)} )}
style={ style={
isCompactViewport isCompactViewport
? undefined ? ({
"--context-panel-width": `${CP_PANEL_W}px`,
} as CSSProperties)
: { : {
width: CP_TOTAL_W, width: CP_TOTAL_W,
padding: CP_PAD, padding: CP_PAD,
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FilesList } from "./FilesList"; import { FilesList } from "./FilesList";
import { useGitState } from "@/shared/hooks/useGitState"; import { useGitState } from "@/shared/hooks/useGitState";
@@ -29,6 +29,35 @@ interface ContextPanelProps {
} }
type ContextPanelTab = "details" | "files"; type ContextPanelTab = "details" | "files";
type ContextPanelSection = "workspace" | "changes" | "artifacts";
type ContextPanelSectionVisibility = Record<ContextPanelSection, boolean>;
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({ export function ContextPanel({
sessionId, sessionId,
@@ -38,6 +67,9 @@ export function ContextPanel({
}: ContextPanelProps) { }: ContextPanelProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const [activeTab, setActiveTab] = useState<ContextPanelTab>("details"); const [activeTab, setActiveTab] = useState<ContextPanelTab>("details");
const [sectionVisibility, setSectionVisibility] = useState(
getStoredSectionVisibility,
);
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null; const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
const activeContext = useChatSessionStore( const activeContext = useChatSessionStore(
@@ -157,13 +189,31 @@ export function ContextPanel({
void refetchAll(); void refetchAll();
}, [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 ( return (
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as ContextPanelTab)} onValueChange={(value) => 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"
> >
<div className="shrink-0 border-b border-border px-3 pb-2 pt-2.5"> <div className="shrink-0 border-b border-border px-4 pb-2 pt-2.5">
<TabsList variant="buttons"> <TabsList variant="buttons">
<TabsTrigger value="details" variant="buttons"> <TabsTrigger value="details" variant="buttons">
{t("contextPanel.tabs.details")} {t("contextPanel.tabs.details")}
@@ -175,7 +225,7 @@ export function ContextPanel({
</div> </div>
<TabsContent value="details" className="flex-1 overflow-y-auto"> <TabsContent value="details" className="flex-1 overflow-y-auto">
<div className="space-y-2.5 px-3 pb-3 pt-2"> <div className="pb-3">
<WorkspaceWidget <WorkspaceWidget
projectName={projectName} projectName={projectName}
projectColor={projectColor} projectColor={projectColor}
@@ -194,6 +244,8 @@ export function ContextPanel({
onCreateBranch={handleCreateBranch} onCreateBranch={handleCreateBranch}
onCreateWorktree={handleCreateWorktree} onCreateWorktree={handleCreateWorktree}
onRefresh={handleRefresh} onRefresh={handleRefresh}
isOpen={sectionVisibility.workspace}
onToggleOpen={() => toggleSection("workspace")}
/> />
<ChangesWidget <ChangesWidget
files={changedFiles} files={changedFiles}
@@ -201,8 +253,13 @@ export function ContextPanel({
currentBranch={gitState?.currentBranch ?? null} currentBranch={gitState?.currentBranch ?? null}
repoPath={gitTargetPath ?? ""} repoPath={gitTargetPath ?? ""}
onOpenFile={handleOpenChangedFile} onOpenFile={handleOpenChangedFile}
isOpen={sectionVisibility.changes}
onToggleOpen={() => toggleSection("changes")}
/>
<ArtifactsWidget
isOpen={sectionVisibility.artifacts}
onToggleOpen={() => toggleSection("artifacts")}
/> />
<ArtifactsWidget />
</div> </div>
</TabsContent> </TabsContent>
@@ -74,6 +74,7 @@ describe("ContextPanel", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.localStorage.clear();
mockRefetch.mockResolvedValue(undefined); mockRefetch.mockResolvedValue(undefined);
mockRefetchFiles.mockResolvedValue(undefined); mockRefetchFiles.mockResolvedValue(undefined);
mockListDirectoryEntries.mockResolvedValue([]); mockListDirectoryEntries.mockResolvedValue([]);
@@ -143,6 +144,24 @@ describe("ContextPanel", () => {
expect(screen.getByText("goose2")).toBeInTheDocument(); 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 () => { it("shows path and init button for non-git directory", async () => {
mockUseGitState.mockReturnValue({ mockUseGitState.mockReturnValue({
data: { data: {
@@ -59,7 +59,15 @@ function getArtifactIcon(artifact: SessionArtifact) {
return IconFile; return IconFile;
} }
export function ArtifactsWidget() { interface ArtifactsWidgetProps {
isOpen: boolean;
onToggleOpen: () => void;
}
export function ArtifactsWidget({
isOpen,
onToggleOpen,
}: ArtifactsWidgetProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { getAllSessionArtifacts, openResolvedPath } = const { getAllSessionArtifacts, openResolvedPath } =
useArtifactPolicyContext(); useArtifactPolicyContext();
@@ -77,6 +85,8 @@ export function ArtifactsWidget() {
<Widget <Widget
title={t("contextPanel.widgets.artifacts")} title={t("contextPanel.widgets.artifacts")}
icon={<IconFileDescription className="size-3.5" />} icon={<IconFileDescription className="size-3.5" />}
isOpen={isOpen}
onToggleOpen={onToggleOpen}
action={ action={
<span className="text-xxs text-foreground-subtle"> <span className="text-xxs text-foreground-subtle">
{artifacts.length} {artifacts.length}
@@ -93,11 +103,11 @@ export function ArtifactsWidget() {
> >
<button <button
type="button" type="button"
className="flex w-full select-none items-center gap-2 px-3 py-1.5 text-left transition-colors duration-100 hover:bg-muted/80" className="relative flex w-full select-none items-center gap-2 px-4 py-1.5 text-left transition-colors duration-100 before:pointer-events-none before:absolute before:inset-x-4 before:top-0 before:h-px before:bg-border/70 before:content-[''] hover:bg-muted/80"
onClick={() => void openResolvedPath(artifact.resolvedPath)} onClick={() => void openResolvedPath(artifact.resolvedPath)}
> >
<Icon className="size-3.5 shrink-0 text-foreground-subtle" /> <Icon className="size-4 shrink-0 text-foreground-subtle" />
<span className="truncate text-xs text-foreground"> <span className="truncate text-sm text-foreground">
{artifact.filename} {artifact.filename}
</span> </span>
</button> </button>
@@ -34,7 +34,8 @@ function ChangedFileRow({
type="button" type="button"
disabled={isDeleted} disabled={isDeleted}
className={cn( 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", "transition-colors duration-100",
isDeleted ? "cursor-default opacity-60" : "hover:bg-muted/80", isDeleted ? "cursor-default opacity-60" : "hover:bg-muted/80",
)} )}
@@ -47,11 +48,11 @@ function ChangedFileRow({
)} )}
> >
{dir && ( {dir && (
<span className="shrink truncate text-xs text-muted-foreground"> <span className="shrink truncate text-sm text-muted-foreground">
{dir} {dir}
</span> </span>
)} )}
<span className="shrink-0 whitespace-nowrap text-xs font-medium text-foreground"> <span className="shrink-0 whitespace-nowrap text-sm font-normal text-foreground">
{name} {name}
</span> </span>
</div> </div>
@@ -69,6 +70,8 @@ interface ChangesWidgetProps {
currentBranch: string | null; currentBranch: string | null;
repoPath: string; repoPath: string;
onOpenFile: (path: string) => void; onOpenFile: (path: string) => void;
isOpen: boolean;
onToggleOpen: () => void;
} }
export function ChangesWidget({ export function ChangesWidget({
@@ -77,6 +80,8 @@ export function ChangesWidget({
currentBranch, currentBranch,
repoPath, repoPath,
onOpenFile, onOpenFile,
isOpen,
onToggleOpen,
}: ChangesWidgetProps) { }: ChangesWidgetProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
@@ -97,7 +102,7 @@ export function ChangesWidget({
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span>{t("contextPanel.widgets.changes")}</span> <span>{t("contextPanel.widgets.changes")}</span>
{currentBranch && ( {currentBranch && (
<span className="flex min-w-0 items-center gap-1 font-normal text-muted-foreground"> <span className="flex min-w-0 items-center gap-1 font-normal normal-case tracking-normal text-muted-foreground">
<span className="shrink-0"> <span className="shrink-0">
{t("contextPanel.widgets.changesOnBranch")} {t("contextPanel.widgets.changesOnBranch")}
</span> </span>
@@ -128,9 +133,11 @@ export function ChangesWidget({
icon={<IconGitBranch className="size-3.5 shrink-0" />} icon={<IconGitBranch className="size-3.5 shrink-0" />}
action={headerAction} action={headerAction}
flush={hasChanges} flush={hasChanges}
isOpen={isOpen}
onToggleOpen={onToggleOpen}
> >
{isLoading && !files ? ( {isLoading && !files ? (
<div className="space-y-2 px-3 py-2.5"> <div className="space-y-2 px-4 pb-3">
<Skeleton className="h-3 w-3/4" /> <Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" /> <Skeleton className="h-3 w-2/3" />
@@ -147,7 +154,7 @@ export function ChangesWidget({
))} ))}
</div> </div>
) : ( ) : (
<p className="text-foreground-subtle"> <p className="px-4 text-sm text-foreground-subtle">
{t("contextPanel.empty.noChanges")} {t("contextPanel.empty.noChanges")}
</p> </p>
)} )}
@@ -1,30 +1,69 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { IconChevronDown } from "@tabler/icons-react";
import { cn } from "@/shared/lib/cn";
interface WidgetProps { interface WidgetProps {
title: ReactNode; title: ReactNode;
icon: ReactNode; icon: ReactNode;
action?: ReactNode; action?: ReactNode;
flush?: boolean; flush?: boolean;
isOpen?: boolean;
onToggleOpen?: () => void;
children: ReactNode; 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 ? (
<IconChevronDown
className={cn(
"size-3 shrink-0 text-foreground-subtle transition-transform duration-150",
!isOpen && "-rotate-90",
)}
/>
) : null}
<span className="shrink-0 text-foreground-subtle">{icon}</span>
<div className={SECTION_HEADER_TEXT_CLASS}>{title}</div>
</>
);
return ( return (
<div className="overflow-hidden rounded-md border border-border"> <section className="pb-3 pt-4 first:pt-3 last:pb-0">
<div className="flex h-8 items-center justify-between gap-2 bg-background-alt px-3"> <div className="px-4">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground"> <div className="flex min-h-6 items-center justify-between gap-2">
{icon} {onToggleOpen ? (
{title} <button
type="button"
onClick={onToggleOpen}
aria-expanded={isOpen}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-1 text-left transition-colors hover:text-foreground"
>
{headerTitle}
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{headerTitle}
</div>
)}
{action && <div className="shrink-0">{action}</div>}
</div> </div>
{action && <div className="shrink-0">{action}</div>} {isOpen && !flush && (
<div className="pt-2 text-sm text-foreground">{children}</div>
)}
</div> </div>
{flush ? ( {isOpen && flush ? <div className="pt-1.5">{children}</div> : null}
children </section>
) : (
<div className="px-3 py-2.5 text-xs text-foreground-subtle">
{children}
</div>
)}
</div>
); );
} }
@@ -204,17 +204,17 @@ export function WorkingContextPicker({
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-2", "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", "hover:bg-background-alt focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)} )}
aria-label={t("contextPanel.picker.selectContext")} aria-label={t("contextPanel.picker.selectContext")}
> >
<IconFolder className="size-3.5 shrink-0 text-muted-foreground" /> <IconFolder className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate font-medium text-foreground"> <span className="block truncate font-normal text-foreground">
{activeWorktreeLabel ?? t("contextPanel.empty.folderNotSet")} {activeWorktreeLabel ?? t("contextPanel.empty.folderNotSet")}
</span> </span>
<span className="block truncate text-xxs text-foreground-subtle"> <span className="block truncate text-xs text-foreground-subtle">
{t("contextPanel.picker.checkedOutBranch", { {t("contextPanel.picker.checkedOutBranch", {
branch: activeBranchLabel, branch: activeBranchLabel,
})} })}
@@ -239,18 +239,18 @@ export function WorkingContextPicker({
key={wt.path} key={wt.path}
type="button" type="button"
className={cn( 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", "hover:bg-muted focus-visible:outline-none focus-visible:bg-muted",
isWorktreeSelected(wt.path) && "bg-muted", isWorktreeSelected(wt.path) && "bg-muted",
)} )}
onClick={() => handleWorktreeSelect(wt.path, wt.branch)} onClick={() => handleWorktreeSelect(wt.path, wt.branch)}
> >
<IconFolder className="size-3.5 shrink-0 text-muted-foreground" /> <IconFolder className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground"> <span className="block truncate font-normal text-foreground">
{worktreeName(wt.path)} {worktreeName(wt.path)}
</span> </span>
<span className="block truncate text-xxs text-foreground-subtle"> <span className="block truncate text-xs text-foreground-subtle">
{t("contextPanel.picker.checkedOutBranch", { {t("contextPanel.picker.checkedOutBranch", {
branch: wt.branch ?? t("contextPanel.states.detached"), branch: wt.branch ?? t("contextPanel.states.detached"),
})} })}
@@ -283,19 +283,19 @@ export function WorkingContextPicker({
type="button" type="button"
disabled={switching || isCurrentBranch} disabled={switching || isCurrentBranch}
className={cn( 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", "hover:bg-muted focus-visible:outline-none focus-visible:bg-muted",
"disabled:opacity-50", "disabled:opacity-50",
)} )}
onClick={() => handleBranchSelect(branch)} onClick={() => handleBranchSelect(branch)}
> >
<IconGitBranch className="size-3.5 shrink-0 text-muted-foreground" /> <IconGitBranch className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground"> <span className="block truncate font-normal text-foreground">
{branch} {branch}
</span> </span>
{branchMeta ? ( {branchMeta ? (
<span className="block truncate text-xxs text-muted-foreground"> <span className="block truncate text-xs text-muted-foreground">
{branchMeta} {branchMeta}
</span> </span>
) : null} ) : null}
@@ -36,6 +36,8 @@ interface WorkspaceWidgetProps {
baseBranch?: string, baseBranch?: string,
) => Promise<CreatedWorktree>; ) => Promise<CreatedWorktree>;
onRefresh: () => void; onRefresh: () => void;
isOpen: boolean;
onToggleOpen: () => void;
} }
export function WorkspaceWidget({ export function WorkspaceWidget({
@@ -56,6 +58,8 @@ export function WorkspaceWidget({
onCreateBranch, onCreateBranch,
onCreateWorktree, onCreateWorktree,
onRefresh, onRefresh,
isOpen,
onToggleOpen,
}: WorkspaceWidgetProps) { }: WorkspaceWidgetProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null; const primaryWorkspaceRoot = projectWorkingDirs[0] ?? null;
@@ -67,6 +71,8 @@ export function WorkspaceWidget({
<Widget <Widget
title={t("contextPanel.widgets.workspace")} title={t("contextPanel.widgets.workspace")}
icon={<IconFolder className="size-3.5" />} icon={<IconFolder className="size-3.5" />}
isOpen={isOpen}
onToggleOpen={onToggleOpen}
action={ action={
<Button <Button
type="button" type="button"
@@ -107,7 +113,7 @@ export function WorkspaceWidget({
<p className="truncate">{t("contextPanel.empty.folderNotSet")}</p> <p className="truncate">{t("contextPanel.empty.folderNotSet")}</p>
) : isLoading && !gitState ? ( ) : isLoading && !gitState ? (
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<Spinner className="size-3.5" /> <Spinner className="size-4" />
<span>{t("contextPanel.states.gitLoading")}</span> <span>{t("contextPanel.states.gitLoading")}</span>
</div> </div>
) : error ? ( ) : error ? (
@@ -144,8 +150,9 @@ export function WorkspaceWidget({
variant="ghost" variant="ghost"
size="xs" size="xs"
onClick={() => void onInitRepo(primaryWorkspaceRoot)} onClick={() => void onInitRepo(primaryWorkspaceRoot)}
className="text-sm"
> >
<IconGitBranch className="size-3" /> <IconGitBranch className="size-4" />
{t("contextPanel.git.initRepo")} {t("contextPanel.git.initRepo")}
</Button> </Button>
</div> </div>
+51 -3
View File
@@ -33,6 +33,7 @@ import { useProjectStore } from "@/features/projects/stores/projectStore";
import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch"; import { useSessionSearch } from "@/features/sessions/hooks/useSessionSearch";
import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels";
import { SidebarProjectsSection } from "./SidebarProjectsSection"; import { SidebarProjectsSection } from "./SidebarProjectsSection";
import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarSearchResults } from "./SidebarSearchResults"; import { SidebarSearchResults } from "./SidebarSearchResults";
@@ -66,10 +67,36 @@ interface SidebarProps {
} }
const EXPANDED_PROJECTS_STORAGE_KEY = "goose:sidebar:expanded-projects"; 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({ export function Sidebar({
collapsed, collapsed,
width = 240, width = SIDE_PANEL_DEFAULT_WIDTH,
isResizing = false, isResizing = false,
onCollapse, onCollapse,
onSettingsClick, onSettingsClick,
@@ -107,6 +134,9 @@ export function Sidebar({
return {}; return {};
} }
}); });
const [sectionVisibility, setSectionVisibility] = useState(
getStoredSectionVisibility,
);
const messagesBySession = useChatStore(selectMessagesBySession); const messagesBySession = useChatStore(selectMessagesBySession);
const sessionStateById = useChatStore(selectSessionStateById); const sessionStateById = useChatStore(selectSessionStateById);
@@ -239,6 +269,17 @@ export function Sidebar({
} }
}, [expandedProjects]); }, [expandedProjects]);
useEffect(() => {
try {
window.localStorage.setItem(
SECTION_VISIBILITY_STORAGE_KEY,
JSON.stringify(sectionVisibility),
);
} catch {
// localStorage may be unavailable
}
}, [sectionVisibility]);
useEffect(() => { useEffect(() => {
if (projects.length === 0) return; if (projects.length === 0) return;
const validProjectIds = new Set(projects.map((project) => project.id)); const validProjectIds = new Set(projects.map((project) => project.id));
@@ -267,6 +308,9 @@ export function Sidebar({
const toggleProject = (projectId: string) => const toggleProject = (projectId: string) =>
setExpandedProjects((prev) => ({ ...prev, [projectId]: !prev[projectId] })); setExpandedProjects((prev) => ({ ...prev, [projectId]: !prev[projectId] }));
const toggleSection = (section: keyof SidebarSectionVisibility) => {
setSectionVisibility((prev) => ({ ...prev, [section]: !prev[section] }));
};
return ( return (
<div <div
@@ -297,7 +341,7 @@ export function Sidebar({
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={onCollapse} onClick={onCollapse}
className="text-foreground hover:text-foreground" className="text-muted-foreground transition-opacity duration-150 hover:text-foreground"
aria-label={t("actions.collapse")} aria-label={t("actions.collapse")}
title={t("actions.collapse")} title={t("actions.collapse")}
> >
@@ -320,7 +364,7 @@ export function Sidebar({
type="button" type="button"
onClick={onCollapse} onClick={onCollapse}
title={t("actions.expand")} 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")} aria-label={t("actions.expand")}
> >
<IconLayoutSidebar className="size-4 flex-shrink-0" /> <IconLayoutSidebar className="size-4 flex-shrink-0" />
@@ -455,6 +499,10 @@ export function Sidebar({
onRenameChat={onRenameChat} onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject} onMoveToProject={onMoveToProject}
onReorderProject={onReorderProject} onReorderProject={onReorderProject}
projectsSectionOpen={sectionVisibility.projects}
recentsSectionOpen={sectionVisibility.recents}
onToggleProjectsSection={() => toggleSection("projects")}
onToggleRecentsSection={() => toggleSection("recents")}
/> />
))} ))}
</nav> </nav>
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconChevronDown } from "@tabler/icons-react";
import type { AppView } from "@/app/AppShell"; import type { AppView } from "@/app/AppShell";
import type { ProjectInfo } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
@@ -36,8 +37,15 @@ interface SidebarProjectsSectionProps {
onRenameChat?: (sessionId: string, nextTitle: string) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void;
onMoveToProject?: (sessionId: string, projectId: string | null) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void;
onReorderProject?: (fromId: string, toId: string) => 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({ export function SidebarProjectsSection({
projects, projects,
projectSessions, projectSessions,
@@ -58,8 +66,13 @@ export function SidebarProjectsSection({
onRenameChat, onRenameChat,
onMoveToProject, onMoveToProject,
onReorderProject, onReorderProject,
projectsSectionOpen,
recentsSectionOpen,
onToggleProjectsSection,
onToggleRecentsSection,
}: SidebarProjectsSectionProps) { }: SidebarProjectsSectionProps) {
const { t } = useTranslation(["sidebar", "common"]); const { t } = useTranslation(["sidebar", "common"]);
const showProjects = collapsed || projectsSectionOpen;
return ( return (
<div <div
@@ -79,17 +92,30 @@ export function SidebarProjectsSection({
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5", collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5",
)} )}
> >
<span {!collapsed && (
className={cn( <button
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3", type="button"
labelTransition, onClick={onToggleProjectsSection}
labelVisible aria-expanded={projectsSectionOpen}
? "opacity-100 w-auto" className={cn(
: "opacity-0 w-0 overflow-hidden", "flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-1 pl-3 text-left transition-colors hover:text-foreground",
)} labelTransition,
> labelVisible
{t("sections.projects")} ? "opacity-100 w-auto"
</span> : "opacity-0 w-0 overflow-hidden",
)}
>
<IconChevronDown
className={cn(
"size-3 shrink-0 text-foreground-subtle transition-transform duration-150",
!projectsSectionOpen && "-rotate-90",
)}
/>
<span className={cn("truncate", SECTION_HEADER_TEXT_CLASS)}>
{t("sections.projects")}
</span>
</button>
)}
{!collapsed && ( {!collapsed && (
<Button <Button
type="button" type="button"
@@ -107,23 +133,25 @@ export function SidebarProjectsSection({
)} )}
</div> </div>
<SidebarProjectList {showProjects && (
projects={projects} <SidebarProjectList
projectSessionsByProject={projectSessions.byProject} projects={projects}
expandedProjects={expandedProjects} projectSessionsByProject={projectSessions.byProject}
toggleProject={toggleProject} expandedProjects={expandedProjects}
collapsed={collapsed} toggleProject={toggleProject}
activeSessionId={activeSessionId} collapsed={collapsed}
onNavigate={onNavigate} activeSessionId={activeSessionId}
onSelectSession={onSelectSession} onNavigate={onNavigate}
onNewChatInProject={onNewChatInProject} onSelectSession={onSelectSession}
onEditProject={onEditProject} onNewChatInProject={onNewChatInProject}
onArchiveProject={onArchiveProject} onEditProject={onEditProject}
onArchiveChat={onArchiveChat} onArchiveProject={onArchiveProject}
onRenameChat={onRenameChat} onArchiveChat={onArchiveChat}
onMoveToProject={onMoveToProject} onRenameChat={onRenameChat}
onReorderProject={onReorderProject} onMoveToProject={onMoveToProject}
/> onReorderProject={onReorderProject}
/>
)}
<SidebarRecentsSection <SidebarRecentsSection
sessions={projectSessions.standalone} sessions={projectSessions.standalone}
@@ -136,6 +164,9 @@ export function SidebarProjectsSection({
onArchiveChat={onArchiveChat} onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat} onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject} onMoveToProject={onMoveToProject}
isOpen={recentsSectionOpen}
onToggleOpen={onToggleRecentsSection}
sectionHeaderTextClass={SECTION_HEADER_TEXT_CLASS}
/> />
</div> </div>
); );
@@ -1,6 +1,6 @@
import { useCallback, useState, type DragEvent } from "react"; import { useCallback, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next"; 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 { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -26,6 +26,9 @@ export function SidebarRecentsSection({
onArchiveChat, onArchiveChat,
onRenameChat, onRenameChat,
onMoveToProject, onMoveToProject,
isOpen,
onToggleOpen,
sectionHeaderTextClass,
}: { }: {
sessions: TabInfo[]; sessions: TabInfo[];
collapsed: boolean; collapsed: boolean;
@@ -37,9 +40,13 @@ export function SidebarRecentsSection({
onArchiveChat?: (sessionId: string) => void; onArchiveChat?: (sessionId: string) => void;
onRenameChat?: (sessionId: string, nextTitle: string) => void; onRenameChat?: (sessionId: string, nextTitle: string) => void;
onMoveToProject?: (sessionId: string, projectId: string | null) => void; onMoveToProject?: (sessionId: string, projectId: string | null) => void;
isOpen: boolean;
onToggleOpen: () => void;
sectionHeaderTextClass: string;
}) { }) {
const { t } = useTranslation(["sidebar", "common"]); const { t } = useTranslation(["sidebar", "common"]);
const [recentsDragOver, setRecentsDragOver] = useState(false); const [recentsDragOver, setRecentsDragOver] = useState(false);
const showContent = collapsed || isOpen;
const handleRecentsDragOver = useCallback((e: DragEvent<HTMLDivElement>) => { const handleRecentsDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
const hasSession = e.dataTransfer.types.includes("text/x-session-id"); 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", collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5",
)} )}
> >
<span {!collapsed && (
className={cn( <button
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3", type="button"
labelTransition, onClick={onToggleOpen}
labelVisible aria-expanded={isOpen}
? "opacity-100 w-auto" className={cn(
: "opacity-0 w-0 overflow-hidden", "flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-1 pl-3 text-left transition-colors hover:text-foreground",
)} labelTransition,
> labelVisible
{t("sections.recents")} ? "opacity-100 w-auto"
</span> : "opacity-0 w-0 overflow-hidden",
)}
>
<IconChevronDown
className={cn(
"size-3 shrink-0 text-foreground-subtle transition-transform duration-150",
!isOpen && "-rotate-90",
)}
/>
<span className={cn("truncate", sectionHeaderTextClass)}>
{t("sections.recents")}
</span>
</button>
)}
{!collapsed && onNewChat && ( {!collapsed && onNewChat && (
<Button <Button
type="button" type="button"
@@ -114,7 +134,8 @@ export function SidebarRecentsSection({
)} )}
</div> </div>
{sessions.length > 0 && {showContent &&
sessions.length > 0 &&
(collapsed ? ( (collapsed ? (
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
{sessions.map((session) => ( {sessions.map((session) => (
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "../Sidebar"; import { Sidebar } from "../Sidebar";
const mockSessions: Array<{ const mockSessions: Array<{
@@ -44,6 +44,11 @@ vi.mock("@/features/projects/stores/projectStore", () => ({
})); }));
describe("Sidebar", () => { describe("Sidebar", () => {
beforeEach(() => {
mockSessions.splice(0, mockSessions.length);
window.localStorage.clear();
});
it("shows sessions in recents when their project is not loaded", () => { it("shows sessions in recents when their project is not loaded", () => {
mockSessions.splice(0, mockSessions.length, { mockSessions.splice(0, mockSessions.length, {
id: "session-1", id: "session-1",
@@ -132,4 +137,35 @@ describe("Sidebar", () => {
expect(screen.getByRole("button", { name: /home/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /home/i })).toBeInTheDocument();
}); });
it("collapses and expands the recents section", async () => {
const user = userEvent.setup();
mockSessions.splice(0, mockSessions.length, {
id: "session-1",
title: "Recovered Session",
updatedAt: "2026-04-09T12:00:00.000Z",
messageCount: 3,
});
render(
<Sidebar
collapsed={false}
onCollapse={vi.fn()}
onNavigate={vi.fn()}
onSelectSession={vi.fn()}
projects={[]}
/>,
);
const recentsHeader = screen.getByRole("button", { name: /chats/i });
expect(screen.getByText("Recovered Session")).toBeInTheDocument();
await user.click(recentsHeader);
expect(screen.queryByText("Recovered Session")).not.toBeInTheDocument();
await user.click(recentsHeader);
expect(screen.getByText("Recovered Session")).toBeInTheDocument();
mockSessions.splice(0, mockSessions.length);
});
}); });
+1
View File
@@ -0,0 +1 @@
export const SIDE_PANEL_DEFAULT_WIDTH = 300;