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 { 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>
+51 -3
View File
@@ -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);
});
});
+1
View File
@@ -0,0 +1 @@
export const SIDE_PANEL_DEFAULT_WIDTH = 300;