mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
fix(desktop): align startup appearance and session context (#12608)
* fix(desktop): align startup appearance and session context * update cline logo size * display full workspace name * fix: fits in narrow screen size * header in narrow screen * Transient failures no longer replace a valid branch with no-git. * header alignments * account settings button row * fix(desktop): improve collapsed sidebar settings layout Use a compact overlay-friendly width and left-align navigation controls in collapsed settings. Adjust header padding, stack account details, anchor the expand button, and add layout regression tests. --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { HUB_THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -31,7 +32,19 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html className="h-full" lang="en">
|
||||
<html
|
||||
className="dark h-full"
|
||||
data-cline-hub-theme="dark"
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the first paint
|
||||
dangerouslySetInnerHTML={{ __html: HUB_THEME_BOOTSTRAP_SCRIPT }}
|
||||
id="cline-hub-theme-bootstrap"
|
||||
/>
|
||||
</head>
|
||||
<body className="h-full min-h-screen font-sans antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "@/lib/desktop-app-state";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
|
||||
import { createLatestSuccessfulRequestGate } from "@/lib/latest-successful-request";
|
||||
import {
|
||||
hasCompletedOnboarding,
|
||||
markOnboardingCompleted,
|
||||
@@ -79,6 +80,8 @@ function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
const GIT_BRANCH_REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
type AppLocation = DesktopAppLocation<SettingsSection>;
|
||||
|
||||
function toThreadTitle(options: { title?: string; prompt?: string }): string {
|
||||
@@ -299,7 +302,7 @@ export default function Home() {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
|
||||
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
|
||||
{view === "sessions" ? (
|
||||
<SessionsView
|
||||
activeSessionId={activeHistorySessionId}
|
||||
@@ -440,6 +443,7 @@ function ChatThreadPane({
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
const manualTitleSessionRef = useRef<string | null>(null);
|
||||
const workspaceSelectionRequestRef = useRef(0);
|
||||
const gitBranchRequestGateRef = useRef(createLatestSuccessfulRequestGate());
|
||||
const workspaceRef = useRef({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
@@ -448,6 +452,7 @@ function ChatThreadPane({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
};
|
||||
const activeWorkspaceCwd = (config.cwd || config.workspaceRoot || "").trim();
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaces((current) => {
|
||||
@@ -526,9 +531,12 @@ function ChatThreadPane({
|
||||
);
|
||||
|
||||
const refreshGitBranch = useCallback(async () => {
|
||||
const requestId = gitBranchRequestGateRef.current.begin();
|
||||
const cwd = getWorkspaceCwd();
|
||||
if (!cwd) {
|
||||
setGitBranch("no-git");
|
||||
if (gitBranchRequestGateRef.current.commit(requestId)) {
|
||||
setGitBranch("no-git");
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -536,13 +544,21 @@ function ChatThreadPane({
|
||||
"get_git_branch",
|
||||
{ cwd },
|
||||
);
|
||||
if (!gitBranchRequestGateRef.current.commit(requestId)) {
|
||||
return;
|
||||
}
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
} catch {
|
||||
setGitBranch("no-git");
|
||||
// Preserve the latest successful branch through transient failures.
|
||||
}
|
||||
}, [getWorkspaceCwd]);
|
||||
|
||||
const invalidateGitBranch = useCallback(() => {
|
||||
gitBranchRequestGateRef.current.invalidate();
|
||||
setGitBranch("no-git");
|
||||
}, []);
|
||||
|
||||
const listGitBranches = useCallback(async (): Promise<{
|
||||
current: string;
|
||||
branches: string[];
|
||||
@@ -573,18 +589,18 @@ function ChatThreadPane({
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const payload = await desktopClient.invoke<{ branch?: string }>(
|
||||
"checkout_git_branch",
|
||||
{ cwd, branch: nextBranch },
|
||||
);
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
await desktopClient.invoke<{ branch?: string }>("checkout_git_branch", {
|
||||
cwd,
|
||||
branch: nextBranch,
|
||||
});
|
||||
invalidateGitBranch();
|
||||
await refreshGitBranch();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[getWorkspaceCwd],
|
||||
[getWorkspaceCwd, invalidateGitBranch, refreshGitBranch],
|
||||
);
|
||||
|
||||
const listWorkspaces = useCallback(
|
||||
@@ -653,43 +669,26 @@ function ChatThreadPane({
|
||||
return false;
|
||||
}
|
||||
|
||||
invalidateGitBranch();
|
||||
setWorkspacePath(nextWorkspace);
|
||||
setWorkspaces((prev) =>
|
||||
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
|
||||
);
|
||||
|
||||
// Fire git branch + workspace list refresh in the background
|
||||
desktopClient
|
||||
.invoke<{ branch?: string }>("get_git_branch", {
|
||||
cwd: nextWorkspace,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (requestId !== workspaceSelectionRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
const branch = payload?.branch?.trim();
|
||||
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId === workspaceSelectionRequestRef.current) {
|
||||
setGitBranch("no-git");
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the merged history, stored, and current workspace catalog.
|
||||
void refreshWorkspaces(nextWorkspace);
|
||||
|
||||
return true;
|
||||
},
|
||||
[refreshWorkspaces, setWorkspacePath],
|
||||
[invalidateGitBranch, refreshWorkspaces, setWorkspacePath],
|
||||
);
|
||||
|
||||
const selectChat = useCallback(async (): Promise<boolean> => {
|
||||
workspaceSelectionRequestRef.current += 1;
|
||||
invalidateGitBranch();
|
||||
setWorkspacePath("");
|
||||
setGitBranch("no-git");
|
||||
return true;
|
||||
}, [setWorkspacePath]);
|
||||
}, [invalidateGitBranch, setWorkspacePath]);
|
||||
|
||||
const pickWorkspaceDirectory = useCallback(
|
||||
async (initialPath?: string): Promise<string | null> => {
|
||||
@@ -714,7 +713,27 @@ function ChatThreadPane({
|
||||
|
||||
useEffect(() => {
|
||||
void refreshGitBranch();
|
||||
}, [refreshGitBranch]);
|
||||
if (!activeWorkspaceCwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshVisibleBranch = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refreshGitBranch();
|
||||
}
|
||||
};
|
||||
const intervalId = window.setInterval(
|
||||
refreshVisibleBranch,
|
||||
GIT_BRANCH_REFRESH_INTERVAL_MS,
|
||||
);
|
||||
window.addEventListener("focus", refreshVisibleBranch);
|
||||
document.addEventListener("visibilitychange", refreshVisibleBranch);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", refreshVisibleBranch);
|
||||
document.removeEventListener("visibilitychange", refreshVisibleBranch);
|
||||
};
|
||||
}, [activeWorkspaceCwd, refreshGitBranch]);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedHistorySessionId(null);
|
||||
|
||||
@@ -37,6 +37,15 @@ describe("AgentHeader title editor", () => {
|
||||
const titleButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[title="A title wide enough to expose resizing"]',
|
||||
);
|
||||
expect(container.querySelector("header")?.className).toContain(
|
||||
"max-md:pl-28",
|
||||
);
|
||||
expect(container.querySelector("header")?.className).toContain(
|
||||
"max-md:h-7",
|
||||
);
|
||||
expect(container.querySelector("header")?.className).toContain(
|
||||
"md:group-data-[state=collapsed]/sidebar-wrapper:pl-7",
|
||||
);
|
||||
expect(titleButton).not.toBeNull();
|
||||
vi.spyOn(
|
||||
titleButton as HTMLButtonElement,
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AgentHeader({
|
||||
const triggerDeleteSession = () => onDeleteSession?.();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:pl-12">
|
||||
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7">
|
||||
{/* Left: thread title */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SessionStatus
|
||||
@@ -189,24 +189,26 @@ export function AgentHeader({
|
||||
|
||||
{showSessionActions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
aria-label={`Open diff: ${additions} additions, ${deletions} deletions`}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
? "hover:bg-secondary/80"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
{additions !== 0 && (
|
||||
<Button
|
||||
aria-label={`Open diff: ${additions} additions, ${deletions} deletions`}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
|
||||
hasChanges
|
||||
? "hover:bg-secondary/80"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
disabled={!hasChanges}
|
||||
id="diff-stats"
|
||||
onClick={() => onOpenDiff?.()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span className="text-chart-2">+{additions}</span>
|
||||
<span className="text-destructive">-{deletions}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
@@ -214,7 +216,7 @@ export function AgentHeader({
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus />
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -93,6 +93,24 @@ function sessionIsVisible(title: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const signedInUser = {
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
@@ -276,23 +294,7 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
|
||||
it("shows the signed-in account and active organization in the footer", async () => {
|
||||
invoke.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "beatrix@cline.bot",
|
||||
displayName: "Beatrix",
|
||||
photoUrl: "",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Cline Bot Inc",
|
||||
organizationId: "org-1",
|
||||
roles: ["admin"],
|
||||
},
|
||||
],
|
||||
});
|
||||
invoke.mockResolvedValue(signedInUser);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -319,11 +321,26 @@ describe("AgentSidebar session organization", () => {
|
||||
});
|
||||
expect(container.textContent).not.toContain("Cline Desktop");
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
const accountButton = container.querySelector(
|
||||
'[aria-label="Account settings"]',
|
||||
);
|
||||
const settingsButton = container.querySelector('[aria-label="Settings"]');
|
||||
expect(accountButton?.parentElement).toBe(settingsButton?.parentElement);
|
||||
expect(settingsButton?.textContent).toBe("");
|
||||
const accountName = [
|
||||
...(accountButton?.querySelectorAll("span") ?? []),
|
||||
].find((element) => element.textContent === "Beatrix");
|
||||
const organizationName = [
|
||||
...(accountButton?.querySelectorAll("span") ?? []),
|
||||
].find((element) => element.textContent === "Cline Bot Inc");
|
||||
expect(accountName?.nextElementSibling).toBe(organizationName);
|
||||
expect(accountName?.parentElement?.className).toContain("flex-col");
|
||||
});
|
||||
|
||||
it("opens the Account settings section when the footer account row is clicked", async () => {
|
||||
const setView = vi.fn();
|
||||
const onSettingsSectionChange = vi.fn();
|
||||
invoke.mockResolvedValue(signedInUser);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -344,10 +361,11 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const accountButton = container.querySelector(
|
||||
'[aria-label="Account settings"]',
|
||||
);
|
||||
expect(accountButton).not.toBeNull();
|
||||
const accountButton = await vi.waitFor(() => {
|
||||
const button = container.querySelector('[aria-label="Account settings"]');
|
||||
expect(button).not.toBeNull();
|
||||
return button;
|
||||
});
|
||||
await click(accountButton as Element);
|
||||
|
||||
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
|
||||
@@ -541,9 +559,61 @@ describe("AgentSidebar session organization", () => {
|
||||
|
||||
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Expand sidebar"]')?.className,
|
||||
).toContain("mt-auto");
|
||||
});
|
||||
|
||||
it("falls back to a signed-out footer without account data", async () => {
|
||||
it("uses a compact overlay-friendly width in collapsed settings", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<AgentSidebar
|
||||
activeSessionId={null}
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="Account"
|
||||
view="settings"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</AccountProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const sidebarWrapper = container.querySelector<HTMLElement>(
|
||||
'[data-slot="sidebar-wrapper"]',
|
||||
);
|
||||
expect(sidebarWrapper?.style.getPropertyValue("--sidebar-width-icon")).toBe(
|
||||
"3rem",
|
||||
);
|
||||
expect(sidebarWrapper?.dataset.state).toBe("collapsed");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Settings sections"]'),
|
||||
).not.toBeNull();
|
||||
const leftAlignedButtons = [
|
||||
"Cline home",
|
||||
"General",
|
||||
"Account",
|
||||
"Expand sidebar",
|
||||
"Settings",
|
||||
];
|
||||
for (const label of leftAlignedButtons) {
|
||||
const button = container.querySelector(`[aria-label="${label}"]`);
|
||||
expect(button?.className).not.toContain("mx-auto");
|
||||
}
|
||||
expect(
|
||||
container.querySelector('[aria-label="Expand sidebar"]')?.className,
|
||||
).toContain("mt-auto");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Settings sections"]')?.className,
|
||||
).toContain("items-start");
|
||||
});
|
||||
|
||||
it("shows only the labeled Settings button when signed out", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AccountProvider>
|
||||
@@ -563,9 +633,14 @@ describe("AgentSidebar session organization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Cline Desktop");
|
||||
});
|
||||
expect(container.textContent).not.toContain("Local");
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector('[aria-label="Settings"]')).not.toBeNull(),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[aria-label="Account settings"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Settings"]')?.textContent,
|
||||
).toContain("Settings");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ function SettingsSectionNavigation({
|
||||
"min-w-0 justify-start",
|
||||
activeSection === section &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
collapsed && "mx-auto size-9 justify-center px-0",
|
||||
collapsed && "size-9 justify-center px-0",
|
||||
)}
|
||||
key={section}
|
||||
onClick={() => onSelect(section)}
|
||||
@@ -174,7 +174,7 @@ function SettingsSectionNavigation({
|
||||
aria-label="Settings sections"
|
||||
className={cn(
|
||||
"flex h-full min-h-0 flex-col gap-0.5 overflow-y-auto",
|
||||
collapsed ? "w-full items-center" : "w-full",
|
||||
collapsed ? "w-full items-start" : "w-full",
|
||||
)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
@@ -598,12 +598,15 @@ export function AgentSidebar({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
aria-label="Cline home"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
isCollapsed && "size-9",
|
||||
)}
|
||||
onClick={openHome}
|
||||
title="Home"
|
||||
type="button"
|
||||
>
|
||||
<ClineLogo className="size-6" />
|
||||
<ClineLogo className="size-5" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-64 p-3" side="bottom">
|
||||
@@ -649,7 +652,7 @@ export function AgentSidebar({
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 px-1.5">
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col items-start gap-1 px-1.5">
|
||||
{view === "settings" ? (
|
||||
<SettingsSectionNavigation
|
||||
activeSection={settingsSection}
|
||||
@@ -659,7 +662,7 @@ export function AgentSidebar({
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Expand sidebar"
|
||||
className="mx-auto size-9 justify-center px-0"
|
||||
className="mt-auto size-9 justify-center px-0"
|
||||
onClick={() => setOpen(true)}
|
||||
title="Expand sidebar"
|
||||
type="button"
|
||||
@@ -829,49 +832,74 @@ export function AgentSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
|
||||
{view !== "settings" && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 border-t border-sidebar-border/70 py-3",
|
||||
isCollapsed ? "px-1.5" : "px-2",
|
||||
)}
|
||||
>
|
||||
{user && !isCollapsed ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
aria-label="Account settings"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
view === "settings" &&
|
||||
settingsSection === "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Account")}
|
||||
title={user.email || undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
{accountInitial}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col leading-tight">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{accountName}
|
||||
</span>
|
||||
{accountScope ? (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{accountScope}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
className={cn(
|
||||
"size-9 shrink-0 justify-center px-0",
|
||||
view === "settings" &&
|
||||
settingsSection !== "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
className={cn(
|
||||
"min-w-0 justify-start",
|
||||
isCollapsed && "mx-auto size-9 justify-center px-0",
|
||||
isCollapsed && "size-9 justify-center px-0",
|
||||
view === "settings" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={openSettings}
|
||||
title="Settings"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
{!isCollapsed ? "Settings" : null}
|
||||
</Button>
|
||||
)}
|
||||
{!isCollapsed ? (
|
||||
<button
|
||||
aria-label="Account settings"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sidebar-foreground transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
view === "settings" &&
|
||||
settingsSection === "Account" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
onClick={() => openSettingsSection("Account")}
|
||||
title={user?.email || undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex gap-2 items-center">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
|
||||
{accountInitial}
|
||||
</span>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{accountName}
|
||||
<span className="pl-1 truncate text-[11px] text-muted-foreground">
|
||||
{accountScope}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
|
||||
@@ -28,9 +28,9 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
// The collapsed desktop sidebar still owns the macOS title-bar controls when
|
||||
// the native title bar overlays the webview.
|
||||
const SIDEBAR_WIDTH_ICON = "4.5rem";
|
||||
// macOS title-bar controls overlay the webview, so the collapsed rail only
|
||||
// needs to reserve enough width for its own controls.
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
||||
const SIDEBAR_MIN_WIDTH = 224;
|
||||
@@ -215,6 +215,7 @@ function SidebarProvider({
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
data-state={state}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": `${desktopWidth}px`,
|
||||
@@ -357,7 +358,7 @@ function SidebarTrigger({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<PanelLeftIcon className="size-3.5" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -106,20 +106,92 @@ describe("ChatInputBar", () => {
|
||||
);
|
||||
expect(trigger?.textContent).toContain("High");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
expect(
|
||||
trigger?.querySelector('[data-slot="select-value"]')?.parentElement
|
||||
?.className,
|
||||
).toContain("max-[560px]:sr-only");
|
||||
});
|
||||
const compactModelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model and provider"]',
|
||||
);
|
||||
expect(compactModelTrigger?.disabled).toBe(false);
|
||||
await act(async () => compactModelTrigger?.click());
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label="Provider"]'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label="Model"]'),
|
||||
).toHaveLength(2);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Close model selector"]')
|
||||
?.click(),
|
||||
);
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
const promptInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[role="combobox"]',
|
||||
);
|
||||
expect(promptInput?.className).toContain("overflow-y-hidden");
|
||||
expect(promptInput?.className).not.toContain("overflow-y-auto");
|
||||
|
||||
await act(async () => {
|
||||
if (!promptInput) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(promptInput, "first line\nsecond line");
|
||||
promptInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(promptInput?.className).toContain("overflow-y-auto");
|
||||
|
||||
await render("starting");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
|
||||
await render("running");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
|
||||
|
||||
expect(onReasoningChange).not.toHaveBeenCalled();
|
||||
const workspaceTrigger = container.querySelector("#git-branch-btn");
|
||||
expect(workspaceTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"overflow-visible",
|
||||
const providerTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Provider"]',
|
||||
);
|
||||
expect(providerTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"max-[560px]:hidden",
|
||||
);
|
||||
expect(compactModelTrigger?.className).toContain("max-[560px]:inline-flex");
|
||||
expect(compactModelTrigger?.querySelector(".lucide-cpu")).not.toBeNull();
|
||||
const workspaceTrigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
const attachTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Attach files"]',
|
||||
);
|
||||
const thinkingTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
const leftControls = attachTrigger?.parentElement;
|
||||
expect(leftControls?.className).toContain("max-[560px]:flex-nowrap");
|
||||
expect(leftControls?.contains(compactModelTrigger ?? null)).toBe(true);
|
||||
expect(leftControls?.contains(thinkingTrigger ?? null)).toBe(true);
|
||||
|
||||
expect(workspaceTrigger?.disabled).toBe(true);
|
||||
expect(workspaceTrigger?.className).toContain("max-[560px]:size-7");
|
||||
expect(workspaceTrigger?.textContent).toContain("cline");
|
||||
expect(workspaceTrigger?.textContent).toContain("main");
|
||||
const workspaceFooterSlot =
|
||||
workspaceTrigger?.parentElement?.parentElement?.parentElement;
|
||||
expect(workspaceFooterSlot?.className).toContain("overflow-visible");
|
||||
expect(workspaceFooterSlot?.className).not.toContain("truncate");
|
||||
expect(workspaceFooterSlot?.className).not.toContain("hidden");
|
||||
expect(workspaceFooterSlot?.className).not.toContain("max-w-");
|
||||
const rightControls = workspaceFooterSlot?.parentElement;
|
||||
expect(rightControls?.contains(workspaceTrigger ?? null)).toBe(true);
|
||||
expect(
|
||||
workspaceTrigger?.parentElement?.parentElement?.className,
|
||||
).not.toContain("truncate");
|
||||
rightControls?.contains(
|
||||
container.querySelector('[aria-label="Send message"]'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(leftControls?.parentElement).toBe(rightControls?.parentElement);
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CircleStop,
|
||||
Clock3,
|
||||
Coins,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Trash2,
|
||||
@@ -965,7 +966,12 @@ export function ChatInputBar({
|
||||
}
|
||||
aria-expanded={slashOpen || mentionOpen}
|
||||
aria-haspopup="listbox"
|
||||
className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
className={cn(
|
||||
"max-h-60 min-h-5 flex-1 resize-none bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none",
|
||||
promptInput.includes("\n")
|
||||
? "overflow-y-auto"
|
||||
: "overflow-y-hidden",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setPromptInput(e.target.value);
|
||||
setCursorIndex(
|
||||
@@ -1094,11 +1100,11 @@ export function ChatInputBar({
|
||||
</div>
|
||||
|
||||
{/* Composer settings and submit */}
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground max-[560px]:grid max-[560px]:grid-cols-[auto_auto_minmax(0,1fr)_auto] max-[560px]:items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 max-[560px]:contents">
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
|
||||
<button
|
||||
aria-label="Attach files"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-[560px]:col-start-1 max-[560px]:row-start-1"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
@@ -1116,7 +1122,7 @@ export function ChatInputBar({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5 max-[560px]:col-start-2 max-[560px]:row-start-1">
|
||||
<div className="hidden flex shrink-0 items-center rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
@@ -1148,7 +1154,7 @@ export function ChatInputBar({
|
||||
Act
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0 max-[560px]:col-start-3 max-[560px]:col-end-5 max-[560px]:row-start-1">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
@@ -1167,7 +1173,7 @@ export function ChatInputBar({
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
|
||||
className="h-7 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
@@ -1176,7 +1182,9 @@ export function ChatInputBar({
|
||||
}
|
||||
>
|
||||
<Brain className="size-3" />
|
||||
<SelectValue>{effortLabel}</SelectValue>
|
||||
<span className="max-[560px]:sr-only">
|
||||
<SelectValue>{effortLabel}</SelectValue>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
@@ -1197,20 +1205,23 @@ export function ChatInputBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
|
||||
<div className="hidden max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[560px]:col-start-4 max-[560px]:row-start-2">
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 max-[560px]:shrink-0">
|
||||
{variant === "conversation" ? (
|
||||
<div className="min-w-0 overflow-visible">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
disabled
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canAbort && (
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
@@ -1278,6 +1289,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
const [lastSelection, setLastSelection] = useState(() =>
|
||||
readModelSelectionStorageFromWindow(),
|
||||
);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const visibleProviderModels = useMemo(() => {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const providerId of enabledProviderIds) {
|
||||
@@ -1485,47 +1497,115 @@ const ModelSelector = memo(function ModelSelector({
|
||||
reasoningCapabilitySource,
|
||||
]);
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
if (
|
||||
rememberedModel &&
|
||||
providerModelIds.includes(rememberedModel) &&
|
||||
rememberedModel !== model
|
||||
) {
|
||||
onModelChange(rememberedModel);
|
||||
return;
|
||||
}
|
||||
const firstModel = providerModelIds[0];
|
||||
if (firstModel && firstModel !== model) {
|
||||
onModelChange(firstModel);
|
||||
}
|
||||
},
|
||||
[
|
||||
lastSelection.lastModelByProvider,
|
||||
model,
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
visibleProviderModels,
|
||||
],
|
||||
);
|
||||
const renderProviderSelect = (triggerClassName: string) => (
|
||||
<SearchableSelect
|
||||
ariaLabel="Provider"
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyLabel="No providers found."
|
||||
items={providers}
|
||||
onSelect={handleProviderSelect}
|
||||
placeholder="Provider"
|
||||
searchPlaceholder="Search providers"
|
||||
triggerClassName={triggerClassName}
|
||||
value={resolvedProvider}
|
||||
/>
|
||||
);
|
||||
const renderModelSelect = (
|
||||
triggerClassName: string,
|
||||
closeMobileMenu = false,
|
||||
) => (
|
||||
<SearchableSelect
|
||||
ariaLabel="Model"
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyLabel="No models found."
|
||||
items={modelsForProvider}
|
||||
onSelect={(value) => {
|
||||
onModelChange(value);
|
||||
if (closeMobileMenu) setMobileOpen(false);
|
||||
}}
|
||||
placeholder="Model"
|
||||
searchPlaceholder="Search models"
|
||||
triggerClassName={triggerClassName}
|
||||
value={resolvedModel}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<SearchableSelect
|
||||
ariaLabel="Provider"
|
||||
<div className="relative min-w-0 shrink-0 text-[11px]">
|
||||
<button
|
||||
aria-expanded={mobileOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Model and provider"
|
||||
className="hidden size-7 items-center justify-center rounded-md text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 max-[560px]:inline-flex"
|
||||
disabled={isBusy || providers.length === 0}
|
||||
emptyLabel="No providers found."
|
||||
items={providers}
|
||||
onSelect={(value) => {
|
||||
onProviderChange(value);
|
||||
const rememberedModel = lastSelection.lastModelByProvider[value];
|
||||
const providerModelIds = visibleProviderModels[value] ?? [];
|
||||
if (
|
||||
rememberedModel &&
|
||||
providerModelIds.includes(rememberedModel) &&
|
||||
rememberedModel !== model
|
||||
) {
|
||||
onModelChange(rememberedModel);
|
||||
return;
|
||||
}
|
||||
const firstModel = providerModelIds[0];
|
||||
if (firstModel && firstModel !== model) {
|
||||
onModelChange(firstModel);
|
||||
}
|
||||
}}
|
||||
placeholder="Provider"
|
||||
searchPlaceholder="Search providers"
|
||||
triggerClassName="max-w-28 text-[11px]"
|
||||
value={resolvedProvider}
|
||||
/>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<SearchableSelect
|
||||
ariaLabel="Model"
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
emptyLabel="No models found."
|
||||
items={modelsForProvider}
|
||||
onSelect={(value) => onModelChange(value)}
|
||||
placeholder="Model"
|
||||
searchPlaceholder="Search models"
|
||||
triggerClassName="max-w-52 text-[11px]"
|
||||
value={resolvedModel}
|
||||
/>
|
||||
onClick={() => setMobileOpen((current) => !current)}
|
||||
title={`${resolvedProvider || "Provider"} / ${resolvedModel || "Model"}`}
|
||||
type="button"
|
||||
>
|
||||
<Cpu className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Close model selector"
|
||||
className="fixed inset-0 z-40 hidden cursor-default opacity-0 max-[560px]:block"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="absolute bottom-full left-0 z-50 mb-2 hidden w-64 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-border bg-popover p-3 shadow-xl max-[560px]:block">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Provider
|
||||
</div>
|
||||
{renderProviderSelect(
|
||||
"w-full max-w-none justify-between text-xs",
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Model
|
||||
</div>
|
||||
{renderModelSelect(
|
||||
"w-full max-w-none justify-between text-xs",
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
|
||||
{renderProviderSelect("max-w-28 text-[11px]")}
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
{renderModelSelect("max-w-52 text-[11px]")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,54 @@ function buttonWithText(text: string): HTMLButtonElement {
|
||||
}
|
||||
|
||||
describe("WorkspaceSelector", () => {
|
||||
it("shows the current workspace and follows branch changes while disabled", async () => {
|
||||
const onListGitBranches = vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main", "feature/review"],
|
||||
}));
|
||||
const render = async (currentBranch: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceSelector
|
||||
currentBranch={currentBranch}
|
||||
disabled
|
||||
onListGitBranches={onListGitBranches}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/workspace/one"
|
||||
workspaces={["/workspace/one"]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render("main");
|
||||
const trigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
expect(trigger?.textContent).toContain("one");
|
||||
expect(trigger?.textContent).toContain("main");
|
||||
const branchLabel = trigger?.querySelector("span:last-child");
|
||||
expect(branchLabel?.className).toContain("min-w-0");
|
||||
expect(branchLabel?.className).toContain("truncate");
|
||||
expect(branchLabel?.className).toContain("max-[560px]:sr-only");
|
||||
expect(branchLabel?.className).not.toContain("max-w-");
|
||||
expect(trigger?.className).toContain("max-[560px]:size-7");
|
||||
expect(trigger?.parentElement?.dataset.slot).toBe("tooltip-trigger");
|
||||
expect(trigger?.parentElement?.className).toContain(
|
||||
"[&>button]:pointer-events-none",
|
||||
);
|
||||
|
||||
await click(trigger as Element);
|
||||
expect(onListGitBranches).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('input[placeholder*="Search"]')).toBeNull();
|
||||
|
||||
await render("feature/review");
|
||||
expect(trigger?.textContent).toContain("feature/review");
|
||||
});
|
||||
|
||||
it("switches both workspace and branch choices from the opened menu", async () => {
|
||||
const onSwitchWorkspace = vi.fn(async () => true);
|
||||
const onSwitchGitBranch = vi.fn(async () => true);
|
||||
|
||||
@@ -5,6 +5,11 @@ import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
|
||||
|
||||
@@ -31,6 +36,7 @@ export function WorkspaceSelector({
|
||||
onSwitchWorkspace,
|
||||
onPickWorkspaceDirectory,
|
||||
onCreateGitBranch,
|
||||
disabled = false,
|
||||
}: {
|
||||
currentBranch: string;
|
||||
workspaceRoot: string;
|
||||
@@ -41,6 +47,7 @@ export function WorkspaceSelector({
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
|
||||
onCreateGitBranch?: (branchName: string) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -71,6 +78,9 @@ export function WorkspaceSelector({
|
||||
);
|
||||
|
||||
const openMenu = async () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
setSearch("");
|
||||
setShowWorkspacePathInput(false);
|
||||
@@ -194,30 +204,51 @@ export function WorkspaceSelector({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors"
|
||||
disabled={switching}
|
||||
id="git-branch-btn"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
return;
|
||||
}
|
||||
void openMenu();
|
||||
}}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
<span className="max-w-20 truncate">{workspaceName}</span>
|
||||
<span className="text-muted-foreground/60">/</span>
|
||||
<span className="max-w-20 truncate">{currentBranch}</span>
|
||||
</Button>
|
||||
<div className="relative min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-full",
|
||||
(disabled || switching) && "[&>button]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Workspace ${workspaceName}, branch ${currentBranch}`}
|
||||
className="flex max-w-full min-w-0 items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0"
|
||||
disabled={disabled || switching}
|
||||
id="git-branch-btn"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setShowCreateBranch(false);
|
||||
setNewBranchName("");
|
||||
return;
|
||||
}
|
||||
void openMenu();
|
||||
}}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
<span className="max-w-20 shrink-0 truncate max-[560px]:sr-only">
|
||||
{workspaceName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground/60 max-[560px]:sr-only">
|
||||
/
|
||||
</span>
|
||||
<span className="min-w-0 truncate max-[560px]:sr-only">
|
||||
{currentBranch}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="top" sideOffset={6}>
|
||||
{workspaceRoot || workspaceName} / {currentBranch}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{open && (
|
||||
{open && !disabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
APP_ICONS,
|
||||
type AppIconId,
|
||||
appIconAssetPath,
|
||||
DEFAULT_APP_ICON,
|
||||
readStoredAppIcon,
|
||||
setStoredAppIcon,
|
||||
} from "@/lib/app-icon";
|
||||
@@ -494,7 +495,7 @@ function GeneralSettingsContent() {
|
||||
return readStoredHubAccent();
|
||||
});
|
||||
const [appIcon, setAppIcon] = useState<AppIconId>(() => {
|
||||
if (typeof window === "undefined") return "classic";
|
||||
if (typeof window === "undefined") return DEFAULT_APP_ICON;
|
||||
return readStoredAppIcon();
|
||||
});
|
||||
const [appIconError, setAppIconError] = useState<string | null>(null);
|
||||
|
||||
@@ -29,7 +29,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("app icon", () => {
|
||||
it("defaults to classic and validates stored values", () => {
|
||||
it("defaults to the bundled midnight icon and validates stored values", () => {
|
||||
expect(DEFAULT_APP_ICON).toBe("midnight");
|
||||
expect(readStoredAppIcon()).toBe(DEFAULT_APP_ICON);
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, "bogus");
|
||||
expect(readStoredAppIcon()).toBe(DEFAULT_APP_ICON);
|
||||
@@ -55,14 +56,14 @@ describe("app icon", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("set_app_icon", { icon: "midnight" });
|
||||
});
|
||||
|
||||
it("re-applies only non-default choices at boot", async () => {
|
||||
it("re-applies only non-bundled choices at boot", async () => {
|
||||
isTauriAvailable.mockReturnValue(true);
|
||||
invoke.mockResolvedValue(true);
|
||||
await syncAppIcon();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, "sunrise");
|
||||
window.localStorage.setItem(APP_ICON_STORAGE_KEY, "classic");
|
||||
await syncAppIcon();
|
||||
expect(invoke).toHaveBeenCalledWith("set_app_icon", { icon: "sunrise" });
|
||||
expect(invoke).toHaveBeenCalledWith("set_app_icon", { icon: "classic" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
|
||||
export const APP_ICON_STORAGE_KEY = "cline.code.app-icon.v1";
|
||||
|
||||
/**
|
||||
* App icon variants selectable in Settings. "classic" is the icon bundled
|
||||
* App icon variants selectable in Settings. "midnight" is the icon bundled
|
||||
* with the app; the others live in webview/public/app-icons (picker +
|
||||
* browser favicon) and src-tauri/icons/dock (runtime dock icon resources).
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ export const APP_ICONS = [
|
||||
|
||||
export type AppIconId = (typeof APP_ICONS)[number]["id"];
|
||||
|
||||
export const DEFAULT_APP_ICON: AppIconId = "classic";
|
||||
export const DEFAULT_APP_ICON: AppIconId = "midnight";
|
||||
|
||||
export function isAppIconId(value: unknown): value is AppIconId {
|
||||
return APP_ICONS.some((icon) => icon.id === value);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLatestSuccessfulRequestGate } from "./latest-successful-request";
|
||||
|
||||
describe("createLatestSuccessfulRequestGate", () => {
|
||||
it("allows an earlier successful request to commit when a newer request fails", () => {
|
||||
const gate = createLatestSuccessfulRequestGate();
|
||||
const successfulRequest = gate.begin();
|
||||
gate.begin();
|
||||
|
||||
expect(gate.commit(successfulRequest)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a successful result older than the latest committed result", () => {
|
||||
const gate = createLatestSuccessfulRequestGate();
|
||||
const olderRequest = gate.begin();
|
||||
const newerRequest = gate.begin();
|
||||
|
||||
expect(gate.commit(newerRequest)).toBe(true);
|
||||
expect(gate.commit(olderRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects in-flight results after an explicit invalidation", () => {
|
||||
const gate = createLatestSuccessfulRequestGate();
|
||||
const staleRequest = gate.begin();
|
||||
|
||||
gate.invalidate();
|
||||
|
||||
expect(gate.commit(staleRequest)).toBe(false);
|
||||
expect(gate.commit(gate.begin())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
export type LatestSuccessfulRequestGate = {
|
||||
begin: () => number;
|
||||
commit: (requestId: number) => boolean;
|
||||
invalidate: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Coordinates overlapping reads so failures do not invalidate a known-good
|
||||
* result. A successful read may commit unless a newer successful read or an
|
||||
* explicit invalidation has already advanced the commit watermark.
|
||||
*/
|
||||
export function createLatestSuccessfulRequestGate(): LatestSuccessfulRequestGate {
|
||||
let issuedRequestId = 0;
|
||||
let committedRequestId = 0;
|
||||
|
||||
return {
|
||||
begin: () => {
|
||||
issuedRequestId += 1;
|
||||
return issuedRequestId;
|
||||
},
|
||||
commit: (requestId) => {
|
||||
if (requestId <= committedRequestId) {
|
||||
return false;
|
||||
}
|
||||
committedRequestId = requestId;
|
||||
return true;
|
||||
},
|
||||
invalidate: () => {
|
||||
issuedRequestId += 1;
|
||||
committedRequestId = issuedRequestId;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyHubAccent,
|
||||
DEFAULT_HUB_ACCENT,
|
||||
DEFAULT_HUB_THEME,
|
||||
HUB_ACCENT_STORAGE_KEY,
|
||||
HUB_THEME_BOOTSTRAP_SCRIPT,
|
||||
HUB_THEME_STORAGE_KEY,
|
||||
isHubAccent,
|
||||
readStoredHubAccent,
|
||||
readStoredHubTheme,
|
||||
readSystemHubTheme,
|
||||
setStoredHubAccent,
|
||||
syncHubAccent,
|
||||
syncHubTheme,
|
||||
} from "./theme";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
delete document.body.dataset.vscodeThemeKind;
|
||||
document.documentElement.classList.remove("dark");
|
||||
delete document.documentElement.dataset.clineAccent;
|
||||
delete document.documentElement.dataset.clineHubTheme;
|
||||
Reflect.deleteProperty(window, "matchMedia");
|
||||
});
|
||||
|
||||
function setSystemTheme(theme: "light" | "dark" | null): void {
|
||||
window.matchMedia = ((query: string) =>
|
||||
({
|
||||
matches: theme !== null && query === `(prefers-color-scheme: ${theme})`,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}) as unknown as MediaQueryList) as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
function runThemeBootstrap(): void {
|
||||
runInNewContext(HUB_THEME_BOOTSTRAP_SCRIPT, { document, window });
|
||||
}
|
||||
|
||||
describe("hub theme", () => {
|
||||
it("applies a saved theme before the system preference", () => {
|
||||
setSystemTheme("light");
|
||||
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, "dark");
|
||||
|
||||
runThemeBootstrap();
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.dataset.clineHubTheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("applies the system preference before the first paint when unsaved", () => {
|
||||
setSystemTheme("light");
|
||||
|
||||
runThemeBootstrap();
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
expect(document.documentElement.dataset.clineHubTheme).toBe("light");
|
||||
});
|
||||
|
||||
it("defaults to dark when no saved or system preference is available", () => {
|
||||
expect(readStoredHubTheme()).toBeNull();
|
||||
expect(readSystemHubTheme()).toBe(DEFAULT_HUB_THEME);
|
||||
expect(syncHubTheme()).toBe("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
delete document.documentElement.dataset.clineHubTheme;
|
||||
runThemeBootstrap();
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.dataset.clineHubTheme).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub accent", () => {
|
||||
|
||||
@@ -2,21 +2,70 @@ export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
|
||||
|
||||
export type HubTheme = "light" | "dark";
|
||||
|
||||
export const DEFAULT_HUB_THEME: HubTheme = "dark";
|
||||
|
||||
/**
|
||||
* Runs from the document head before the webview paints. Keep this
|
||||
* self-contained: the browser executes it before the client bundle loads.
|
||||
*/
|
||||
export const HUB_THEME_BOOTSTRAP_SCRIPT = `(() => {
|
||||
const root = document.documentElement;
|
||||
let theme;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(${JSON.stringify(HUB_THEME_STORAGE_KEY)});
|
||||
if (stored === "light" || stored === "dark") {
|
||||
theme = stored;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!theme) {
|
||||
try {
|
||||
if (typeof window.matchMedia === "function") {
|
||||
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
||||
theme = "light";
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
theme = "dark";
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!theme) {
|
||||
theme = ${JSON.stringify(DEFAULT_HUB_THEME)};
|
||||
}
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
root.dataset.clineHubTheme = theme;
|
||||
})();`;
|
||||
|
||||
export function readStoredHubTheme(): HubTheme | null {
|
||||
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSystemHubTheme(): HubTheme {
|
||||
const kind = document.body.dataset.vscodeThemeKind;
|
||||
if (kind) {
|
||||
return kind === "vscode-dark" || kind === "vscode-high-contrast"
|
||||
? "dark"
|
||||
: "light";
|
||||
if (kind === "vscode-dark" || kind === "vscode-high-contrast") {
|
||||
return "dark";
|
||||
}
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
if (kind === "vscode-light" || kind === "vscode-high-contrast-light") {
|
||||
return "light";
|
||||
}
|
||||
try {
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: light)").matches) {
|
||||
return "light";
|
||||
}
|
||||
} catch {
|
||||
// Use the app default when the host cannot expose its color scheme.
|
||||
}
|
||||
return DEFAULT_HUB_THEME;
|
||||
}
|
||||
|
||||
export function applyHubTheme(theme: HubTheme): HubTheme {
|
||||
@@ -30,7 +79,11 @@ export function syncHubTheme(): HubTheme {
|
||||
}
|
||||
|
||||
export function setStoredHubTheme(theme: HubTheme): HubTheme {
|
||||
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
|
||||
try {
|
||||
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Applying still works for this session when persistence is unavailable.
|
||||
}
|
||||
return applyHubTheme(theme);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user