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:
Bee
2026-07-27 18:13:55 -07:00
committed by GitHub
parent 8751daaba9
commit 53a7c3e80e
18 changed files with 783 additions and 226 deletions
@@ -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 />
+51 -32
View File
@@ -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", () => {
+63 -10
View File
@@ -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);
}