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 { 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const SIDE_PANEL_DEFAULT_WIDTH = 300;
|
||||||
Reference in New Issue
Block a user