mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
polish sidebar and context panel (#9059)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -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 }) {
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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({
|
||||
sessionId,
|
||||
@@ -38,6 +67,9 @@ export function ContextPanel({
|
||||
}: ContextPanelProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const [activeTab, setActiveTab] = useState<ContextPanelTab>("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 (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
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">
|
||||
<TabsTrigger value="details" variant="buttons">
|
||||
{t("contextPanel.tabs.details")}
|
||||
@@ -175,7 +225,7 @@ export function ContextPanel({
|
||||
</div>
|
||||
|
||||
<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
|
||||
projectName={projectName}
|
||||
projectColor={projectColor}
|
||||
@@ -194,6 +244,8 @@ export function ContextPanel({
|
||||
onCreateBranch={handleCreateBranch}
|
||||
onCreateWorktree={handleCreateWorktree}
|
||||
onRefresh={handleRefresh}
|
||||
isOpen={sectionVisibility.workspace}
|
||||
onToggleOpen={() => toggleSection("workspace")}
|
||||
/>
|
||||
<ChangesWidget
|
||||
files={changedFiles}
|
||||
@@ -201,8 +253,13 @@ export function ContextPanel({
|
||||
currentBranch={gitState?.currentBranch ?? null}
|
||||
repoPath={gitTargetPath ?? ""}
|
||||
onOpenFile={handleOpenChangedFile}
|
||||
isOpen={sectionVisibility.changes}
|
||||
onToggleOpen={() => toggleSection("changes")}
|
||||
/>
|
||||
<ArtifactsWidget
|
||||
isOpen={sectionVisibility.artifacts}
|
||||
onToggleOpen={() => toggleSection("artifacts")}
|
||||
/>
|
||||
<ArtifactsWidget />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
<Widget
|
||||
title={t("contextPanel.widgets.artifacts")}
|
||||
icon={<IconFileDescription className="size-3.5" />}
|
||||
isOpen={isOpen}
|
||||
onToggleOpen={onToggleOpen}
|
||||
action={
|
||||
<span className="text-xxs text-foreground-subtle">
|
||||
{artifacts.length}
|
||||
@@ -93,11 +103,11 @@ export function ArtifactsWidget() {
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-foreground-subtle" />
|
||||
<span className="truncate text-xs text-foreground">
|
||||
<Icon className="size-4 shrink-0 text-foreground-subtle" />
|
||||
<span className="truncate text-sm text-foreground">
|
||||
{artifact.filename}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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 && (
|
||||
<span className="shrink truncate text-xs text-muted-foreground">
|
||||
<span className="shrink truncate text-sm text-muted-foreground">
|
||||
{dir}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -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({
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span>{t("contextPanel.widgets.changes")}</span>
|
||||
{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">
|
||||
{t("contextPanel.widgets.changesOnBranch")}
|
||||
</span>
|
||||
@@ -128,9 +133,11 @@ export function ChangesWidget({
|
||||
icon={<IconGitBranch className="size-3.5 shrink-0" />}
|
||||
action={headerAction}
|
||||
flush={hasChanges}
|
||||
isOpen={isOpen}
|
||||
onToggleOpen={onToggleOpen}
|
||||
>
|
||||
{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-1/2" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
@@ -147,7 +154,7 @@ export function ChangesWidget({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-subtle">
|
||||
<p className="px-4 text-sm text-foreground-subtle">
|
||||
{t("contextPanel.empty.noChanges")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
<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 (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<div className="flex h-8 items-center justify-between gap-2 bg-background-alt px-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground">
|
||||
{icon}
|
||||
{title}
|
||||
<section className="pb-3 pt-4 first:pt-3 last:pb-0">
|
||||
<div className="px-4">
|
||||
<div className="flex min-h-6 items-center justify-between gap-2">
|
||||
{onToggleOpen ? (
|
||||
<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>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
{isOpen && !flush && (
|
||||
<div className="pt-2 text-sm text-foreground">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
{flush ? (
|
||||
children
|
||||
) : (
|
||||
<div className="px-3 py-2.5 text-xs text-foreground-subtle">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && flush ? <div className="pt-1.5">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<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="block truncate font-medium text-foreground">
|
||||
<span className="block truncate font-normal text-foreground">
|
||||
{activeWorktreeLabel ?? t("contextPanel.empty.folderNotSet")}
|
||||
</span>
|
||||
<span className="block truncate text-xxs text-foreground-subtle">
|
||||
<span className="block truncate text-xs text-foreground-subtle">
|
||||
{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)}
|
||||
>
|
||||
<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">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
<span className="block truncate font-normal text-foreground">
|
||||
{worktreeName(wt.path)}
|
||||
</span>
|
||||
<span className="block truncate text-xxs text-foreground-subtle">
|
||||
<span className="block truncate text-xs text-foreground-subtle">
|
||||
{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)}
|
||||
>
|
||||
<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">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
<span className="block truncate font-normal text-foreground">
|
||||
{branch}
|
||||
</span>
|
||||
{branchMeta ? (
|
||||
<span className="block truncate text-xxs text-muted-foreground">
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{branchMeta}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -36,6 +36,8 @@ interface WorkspaceWidgetProps {
|
||||
baseBranch?: string,
|
||||
) => Promise<CreatedWorktree>;
|
||||
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({
|
||||
<Widget
|
||||
title={t("contextPanel.widgets.workspace")}
|
||||
icon={<IconFolder className="size-3.5" />}
|
||||
isOpen={isOpen}
|
||||
onToggleOpen={onToggleOpen}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -107,7 +113,7 @@ export function WorkspaceWidget({
|
||||
<p className="truncate">{t("contextPanel.empty.folderNotSet")}</p>
|
||||
) : isLoading && !gitState ? (
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<Spinner className="size-3.5" />
|
||||
<Spinner className="size-4" />
|
||||
<span>{t("contextPanel.states.gitLoading")}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
@@ -144,8 +150,9 @@ export function WorkspaceWidget({
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void onInitRepo(primaryWorkspaceRoot)}
|
||||
className="text-sm"
|
||||
>
|
||||
<IconGitBranch className="size-3" />
|
||||
<IconGitBranch className="size-4" />
|
||||
{t("contextPanel.git.initRepo")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -297,7 +341,7 @@ export function Sidebar({
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onCollapse}
|
||||
className="text-foreground hover:text-foreground"
|
||||
className="text-muted-foreground transition-opacity duration-150 hover:text-foreground"
|
||||
aria-label={t("actions.collapse")}
|
||||
title={t("actions.collapse")}
|
||||
>
|
||||
@@ -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")}
|
||||
>
|
||||
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
||||
@@ -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")}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -79,17 +92,30 @@ export function SidebarProjectsSection({
|
||||
collapsed ? "px-0 pt-0 pb-1 justify-center" : "pt-5 pb-1.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{t("sections.projects")}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleProjectsSection}
|
||||
aria-expanded={projectsSectionOpen}
|
||||
className={cn(
|
||||
"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
|
||||
? "opacity-100 w-auto"
|
||||
: "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 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -107,23 +133,25 @@ export function SidebarProjectsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarProjectList
|
||||
projects={projects}
|
||||
projectSessionsByProject={projectSessions.byProject}
|
||||
expandedProjects={expandedProjects}
|
||||
toggleProject={toggleProject}
|
||||
collapsed={collapsed}
|
||||
activeSessionId={activeSessionId}
|
||||
onNavigate={onNavigate}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onReorderProject={onReorderProject}
|
||||
/>
|
||||
{showProjects && (
|
||||
<SidebarProjectList
|
||||
projects={projects}
|
||||
projectSessionsByProject={projectSessions.byProject}
|
||||
expandedProjects={expandedProjects}
|
||||
toggleProject={toggleProject}
|
||||
collapsed={collapsed}
|
||||
activeSessionId={activeSessionId}
|
||||
onNavigate={onNavigate}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewChatInProject={onNewChatInProject}
|
||||
onEditProject={onEditProject}
|
||||
onArchiveProject={onArchiveProject}
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
onReorderProject={onReorderProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarRecentsSection
|
||||
sessions={projectSessions.standalone}
|
||||
@@ -136,6 +164,9 @@ export function SidebarProjectsSection({
|
||||
onArchiveChat={onArchiveChat}
|
||||
onRenameChat={onRenameChat}
|
||||
onMoveToProject={onMoveToProject}
|
||||
isOpen={recentsSectionOpen}
|
||||
onToggleOpen={onToggleRecentsSection}
|
||||
sectionHeaderTextClass={SECTION_HEADER_TEXT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-normal text-muted-foreground/80 flex-1 pl-3",
|
||||
labelTransition,
|
||||
labelVisible
|
||||
? "opacity-100 w-auto"
|
||||
: "opacity-0 w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{t("sections.recents")}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleOpen}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
"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
|
||||
? "opacity-100 w-auto"
|
||||
: "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 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -114,7 +134,8 @@ export function SidebarRecentsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length > 0 &&
|
||||
{showContent &&
|
||||
sessions.length > 0 &&
|
||||
(collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{sessions.map((session) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
const mockSessions: Array<{
|
||||
@@ -44,6 +44,11 @@ vi.mock("@/features/projects/stores/projectStore", () => ({
|
||||
}));
|
||||
|
||||
describe("Sidebar", () => {
|
||||
beforeEach(() => {
|
||||
mockSessions.splice(0, mockSessions.length);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("shows sessions in recents when their project is not loaded", () => {
|
||||
mockSessions.splice(0, mockSessions.length, {
|
||||
id: "session-1",
|
||||
@@ -132,4 +137,35 @@ describe("Sidebar", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDE_PANEL_DEFAULT_WIDTH = 300;
|
||||
Reference in New Issue
Block a user