Compare commits

...
Author SHA1 Message Date
abeatrix 656e170d11 custom bg 2026-07-17 17:22:37 -07:00
abeatrix 8d9f63b06e pet overlay 2026-07-17 16:59:14 -07:00
abeatrix dc44e00fe8 poc(desktop): pet 2026-07-17 16:26:37 -07:00
abeatrix 677bf62a09 home page update 2026-07-17 14:47:32 -07:00
abeatrix faa96c5d1e Merge remote-tracking branch 'origin/main' into bee/filter-paths
# Conflicts:
#	apps/examples/desktop-app/sidecar/commands.ts
2026-07-16 20:01:21 -07:00
abeatrix 4d86b283dc dedup normalizeWorkspacePath 2026-07-16 14:03:18 -07:00
abeatrix adf029e8bd feat(desktop-app): add account context and window title utilities
- Add AccountContext provider and hooks for managing Cline account identity
- Add account-context.tsx and account-context.test.tsx
- Add desktop-window-title.ts and desktop-window-title.test.ts
- Update workspace-paths.ts with new utility functions
- Update agent-sidebar.tsx and agent-sidebar.test.tsx to use account context
- Update page.tsx to integrate account context
- Update sidecar/commands.ts to support account operations
- Update core SDK exports

This adds proper account identity management and window title utilities for the desktop app.
2026-07-16 13:50:02 -07:00
abeatrix c85a0be86a fix(desktop): filter project paths
Best effort to remove desktop and user's home directory from showing up in project list in the desktop app.
2026-07-16 12:42:55 -07:00
32 changed files with 2713 additions and 399 deletions
+36 -2
View File
@@ -6,6 +6,7 @@ import {
rmSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -34,6 +35,7 @@ import {
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveLocalClineAuthToken,
resolvePluginConfigSearchPaths,
@@ -51,6 +53,7 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -185,6 +188,31 @@ function removePathIfExists(
return true;
}
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
): Promise<string | undefined> {
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces the
// auth failure to the caller.
}
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
}
async function listSessionsFromSidecarManager(
ctx: SidecarContext,
limit: number,
@@ -757,7 +785,13 @@ export async function handleCommand(
// ── Process context ───────────────────────────────────────────────
if (command === "get_process_context") {
return { workspaceRoot: ctx.workspaceRoot, cwd: ctx.workspaceRoot };
return {
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
homeDir: homedir(),
platform: process.platform,
appVersion: packageJson.version,
};
}
if (command === "get_chat_ws_endpoint") {
return "";
@@ -954,7 +988,7 @@ export async function handleCommand(
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
getAuthToken: async () => resolveFreshClineAuthToken(manager),
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -9,7 +9,7 @@ tauri-build = { version = "2.0.0", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = [] }
tauri = { version = "2.11.1", features = ["macos-private-api", "tray-icon"] }
rfd = "0.15"
[features]
+106 -2
View File
@@ -8,7 +8,9 @@ use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{Manager, RunEvent, State};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{Manager, RunEvent, State, WindowEvent};
#[derive(Clone)]
struct AppContext {
@@ -473,6 +475,51 @@ fn open_mcp_settings_file() -> Result<String, String> {
Ok(settings_path.to_string_lossy().to_string())
}
/// Begin an OS-level drag of the calling window (used by the floating pet so it
/// can be dragged anywhere on screen without window decorations).
#[tauri::command]
fn start_pet_drag(window: tauri::WebviewWindow) -> Result<(), String> {
window.start_dragging().map_err(|e| e.to_string())
}
/// Show the floating pet window and (re)assert its always-on-top / all-Spaces
/// presence so it floats above other apps even when the main window is hidden.
#[tauri::command]
fn show_pet(app: tauri::AppHandle) -> Result<(), String> {
if let Some(pet) = app.get_webview_window("pet") {
pet.show().map_err(|e| e.to_string())?;
let _ = pet.set_always_on_top(true);
let _ = pet.set_visible_on_all_workspaces(true);
}
Ok(())
}
/// Hide the floating pet window (its dismiss button and the settings toggle).
#[tauri::command]
fn hide_pet(app: tauri::AppHandle) -> Result<(), String> {
if let Some(pet) = app.get_webview_window("pet") {
pet.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
fn is_pet_visible(app: tauri::AppHandle) -> bool {
app.get_webview_window("pet")
.and_then(|pet| pet.is_visible().ok())
.unwrap_or(false)
}
/// Bring the main window back after it was hidden by closing it.
#[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(main) = app.get_webview_window("main") {
main.show().map_err(|e| e.to_string())?;
let _ = main.set_focus();
}
Ok(())
}
fn main() {
let desktop_backend = Arc::new(DesktopBackendState::default());
let launch_cwd = std::env::current_dir()
@@ -502,13 +549,63 @@ fn main() {
eprintln!("[desktop-backend] health check failed: {error}");
}
});
// Tray icon so the app can keep running after the main window is
// closed, with explicit Show / Quit actions.
if let Some(icon) = app.default_window_icon().cloned() {
let show_item =
MenuItem::with_id(app, "show_window", "Show Window", true, None::<&str>)?;
let quit_item =
MenuItem::with_id(app, "quit", "Quit Cline Code", true, None::<&str>)?;
let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?;
TrayIconBuilder::with_id("cline-tray")
.icon(icon)
.tooltip("Cline Code")
.menu(&tray_menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| match event.id().as_ref() {
"show_window" => {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
}
// Keep the pet floating above other apps and on every Space, so it
// stays visible even when the main window is minimized or hidden.
if let Some(pet) = app.get_webview_window("pet") {
let _ = pet.set_always_on_top(true);
let _ = pet.set_visible_on_all_workspaces(true);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_desktop_backend_endpoint,
pick_workspace_directory,
open_mcp_settings_file
open_mcp_settings_file,
start_pet_drag,
show_pet,
hide_pet,
is_pet_visible,
show_main_window
])
.on_window_event(|window, event| {
// Closing the main window hides it and keeps the app (and sidecar)
// running in the background; the tray or Dock reopens it, and
// Cmd+Q / the tray Quit item performs the real shutdown.
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
let _ = window.hide();
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri app")
.run(|app_handle, event| match event {
@@ -518,6 +615,13 @@ fn main() {
.inner()
.stop();
}
// Clicking the Dock icon on macOS reopens the hidden main window.
RunEvent::Reopen { .. } => {
if let Some(main) = app_handle.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
}
_ => {}
});
}
@@ -10,6 +10,7 @@
"frontendDist": "../webview/out"
},
"app": {
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
@@ -17,6 +18,21 @@
"width": 1500,
"height": 980,
"resizable": true
},
{
"label": "pet",
"title": "Nyan Pet",
"width": 184,
"height": 120,
"resizable": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": false,
"focus": false,
"maximizable": false,
"minimizable": false
}
],
"security": {
@@ -19,6 +19,60 @@
}
}
/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */
@keyframes hero-word-in {
0% {
opacity: 0;
transform: translateY(0.42em);
filter: blur(6px);
}
60% {
filter: blur(0);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.hero-word-char {
display: inline-block;
white-space: pre;
/* Solid fallback so the word is never invisible if text clipping is unsupported. */
color: var(--brand-violet);
animation: hero-word-in 0.5s cubic-bezier(0.2, 0.65, 0.3, 1) both;
}
/*
* Gradient fill per character. The clip lives on each animated span (not a
* shared parent) because WebKit — used by the Tauri webview on macOS — drops
* the parent's background when a child paints on its own transform/filter
* layer, which would leave the animating letters blank. The -webkit- prefixes
* are required by WebKit; @supports keeps the solid fallback above otherwise.
*/
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-word-char {
background-image: linear-gradient(
135deg,
var(--brand-periwinkle),
var(--brand-violet) 55%,
var(--brand-magenta)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-word-char {
/* biome-ignore lint/complexity/noImportantStyles: reduced motion must override inline animation delay */
animation: none !important;
}
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0% {
+207 -120
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import { NyanCat, PetWindowView } from "@/components/nyan-cat";
import {
AlertDialog,
AlertDialogAction,
@@ -29,13 +30,20 @@ import {
type SettingsSection,
SettingsView,
} from "@/components/views/settings/settings-view";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import {
readChatBackground,
subscribeChatBackground,
} from "@/lib/chat-background";
import type { ChatSessionConfig } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import { syncDesktopWindowTitle } from "@/lib/desktop-window-title";
import { getCurrentWindowLabel, isTauri } from "@/lib/pet-window";
import {
getSessionMetadataTitle,
type SessionHistoryItem,
@@ -43,6 +51,7 @@ import {
} from "@/lib/session-history";
import { syncHubTheme, watchSystemHubTheme } from "@/lib/theme";
import {
filterWorkspacePaths,
mergeWorkspacePaths,
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
@@ -71,6 +80,35 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
// Both the main and the floating-pet windows load this same page; branch on
// the Tauri window label so the pet window renders only the pet. Render
// nothing until resolved so the client-only branches never hydrate wrong.
const [windowKind, setWindowKind] = useState<"pending" | "main" | "pet">(
"pending",
);
useEffect(() => {
let active = true;
void getCurrentWindowLabel().then((label) => {
if (active) {
setWindowKind(label === "pet" ? "pet" : "main");
}
});
return () => {
active = false;
};
}, []);
if (windowKind === "pending") {
return null;
}
if (windowKind === "pet") {
return <PetWindowView />;
}
return <MainApp />;
}
function MainApp() {
const [view, setView] = useState<"chat" | "sessions" | "settings">("chat");
const [settingsSection, setSettingsSection] =
useState<SettingsSection>("General");
@@ -86,6 +124,10 @@ export default function Home() {
return watchSystemHubTheme();
}, []);
useEffect(() => {
void syncDesktopWindowTitle();
}, []);
const handleNewThread = useCallback(() => {
const id = makeThreadId();
setThreads((prev) => [...prev, { id }]);
@@ -219,63 +261,69 @@ export default function Home() {
);
return (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar className="border-r border-sidebar-border" collapsible="icon">
<AgentSidebar
activeSessionId={activeHistorySessionId}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
<AccountProvider>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
{!isTauri() ? <NyanCat /> : null}
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
history={sessionHistory}
isHomeActive={
view === "chat" &&
!activeThread?.historySession &&
!activeThread?.hasStarted
}
onHome={handleHome}
onNewThread={handleNewThread}
onSettingsSectionChange={setSettingsSection}
sessionHistory={sessionHistory}
setView={setView}
settingsSection={settingsSection}
view={view}
/>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-3 top-3 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
knownWorkspacePaths={historyWorkspacePaths}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onThreadStarted={handleThreadStarted}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={setSettingsSection}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</SidebarProvider>
</AccountProvider>
);
}
@@ -341,14 +389,26 @@ function ChatThreadPane({
string | null
>(null);
const [gitBranch, setGitBranch] = useState("no-git");
const [chatBackground, setChatBackground] = useState<string | null>(null);
useEffect(() => {
setChatBackground(readChatBackground());
return subscribeChatBackground(() =>
setChatBackground(readChatBackground()),
);
}, []);
const [providerCredentials, setProviderCredentials] = useState<
Record<string, { apiKey: string }>
>({});
const [providersLoaded, setProvidersLoaded] = useState(false);
// History paths lead each merge: they are ordered by session recency, so
// stored or stale entries only append after them.
const [workspaces, setWorkspaces] = useState<string[]>(() =>
mergeWorkspacePaths(
readWorkspaceSelectionFromWindow().workspaces,
knownWorkspacePaths,
filterWorkspacePaths(
mergeWorkspacePaths(
knownWorkspacePaths,
readWorkspaceSelectionFromWindow().workspaces,
),
),
);
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
@@ -366,7 +426,9 @@ function ChatThreadPane({
useEffect(() => {
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, knownWorkspacePaths);
const merged = filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, current),
);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -508,7 +570,12 @@ function ChatThreadPane({
workspaceRef.current.cwd ||
""
).trim();
return mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]);
// The active workspace can be an excluded path (restored session,
// process cwd fallback); it renders via its own registration in the
// selector and welcome screen instead of joining the catalog.
return filterWorkspacePaths(
mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]),
);
},
[knownWorkspacePaths],
);
@@ -518,7 +585,7 @@ function ChatThreadPane({
try {
const results = await listWorkspaces(preferredWorkspace);
setWorkspaces((current) => {
const merged = mergeWorkspacePaths(current, results);
const merged = mergeWorkspacePaths(results, current);
return current.length === merged.length &&
current.every((workspace, index) => workspace === merged[index])
? current
@@ -562,7 +629,9 @@ function ChatThreadPane({
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspaces((prev) => mergeWorkspacePaths(prev, [nextWorkspace]));
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
);
// Fire git branch + workspace list refresh in the background
desktopClient
@@ -1045,68 +1114,86 @@ function ChatThreadPane({
return (
<WorkspaceProvider value={workspaceContextValue}>
<div
className={
isWelcomeState
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
>
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{chatBackground ? (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url("${chatBackground}")` }}
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-background/80"
/>
</>
) : null}
<WelcomeScreen
active={isWelcomeState}
body={
showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
/>
)
<div
className={
isWelcomeState
? "relative z-10 grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "relative z-10 grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
}
composer={composer}
onStartChat={setPromptInput}
quickActions={[]}
/>
>
{!isWelcomeState ? (
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={{
additions: summary.additions,
deletions: summary.deletions,
}}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={() => {
if (hasDiffChanges) setShowDiffView(true);
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
/>
</div>
) : null}
<WelcomeScreen
active={isWelcomeState}
body={
showDiffView ? (
<DiffView
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
) : (
<ChatMessages
onAnswerAskQuestion={handleAnswerAskQuestion}
onApproveToolApproval={handleApproveToolApproval}
onRejectToolApproval={handleRejectToolApproval}
chatTransportState={chatTransportState}
error={displayedError}
messages={displayedMessages}
onRestoreCheckpoint={(runCount) =>
void restoreCheckpoint(runCount)
}
onForkSession={handleForkSession}
pendingToolApprovals={pendingToolApprovals}
pendingAskQuestions={pendingAskQuestions}
sessionId={displayedSessionId}
streamingMessageId={activeAssistantMessageId}
isSessionSwitching={displayedIsSwitching}
status={displayedStatus}
/>
)
}
composer={composer}
gitBranch={gitBranch}
onListGitBranches={listGitBranches}
onStartChat={setPromptInput}
onSwitchGitBranch={switchGitBranch}
quickActions={[]}
/>
</div>
</div>
<AlertDialog
open={deleteConfirmOpen}
@@ -5,11 +5,15 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSidebar } from "@/components/agent-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AccountProvider } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
let container: HTMLDivElement;
let root: Root;
@@ -78,6 +82,9 @@ function sessionIsVisible(title: string): boolean {
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
@@ -173,4 +180,154 @@ describe("AgentSidebar session organization", () => {
await click(buttonWithText("Load older projects"));
expect(loadOlderSessions).toHaveBeenCalledOnce();
});
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"],
},
],
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Beatrix");
expect(container.textContent).toContain("Cline Bot Inc");
});
expect(container.textContent).not.toContain("Cline Desktop");
expect(container.textContent).not.toContain("Local");
});
it("opens the Account settings section when the footer account row is clicked", async () => {
const setView = vi.fn();
const onSettingsSectionChange = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={onSettingsSectionChange}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={setView}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const accountButton = container.querySelector(
'[aria-label="Account settings"]',
);
expect(accountButton).not.toBeNull();
await click(accountButton as Element);
expect(onSettingsSectionChange).toHaveBeenCalledWith("Account");
expect(setView).toHaveBeenCalledWith("settings");
});
it("shows the desktop app version in a popover when the Cline logo is clicked", async () => {
const onHome = vi.fn();
invoke.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { appVersion: "1.2.3" };
}
throw new Error("No Cline account auth token found");
});
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={onHome}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
const logoButton = container.querySelector('[aria-label="Cline home"]');
expect(logoButton).not.toBeNull();
expect(document.body.textContent).not.toContain("Version 1.2.3");
await click(logoButton as Element);
await vi.waitFor(() => {
expect(document.body.textContent).toContain("Version 1.2.3");
});
expect(onHome).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith("get_process_context");
});
it("falls back to a signed-out footer without account data", async () => {
await act(async () => {
root.render(
<AccountProvider>
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
isHomeActive
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory([], vi.fn())}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Cline Desktop");
});
expect(container.textContent).not.toContain("Local");
});
});
@@ -10,7 +10,6 @@ import {
Filter,
FolderTree,
GitFork,
Home,
Loader2,
MessageSquare,
PanelLeftOpen,
@@ -64,6 +63,11 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useSidebar } from "@/components/ui/sidebar";
import { normalizeTitle } from "@/components/utils";
@@ -71,11 +75,13 @@ import {
SETTINGS_SECTIONS,
type SettingsSection,
} from "@/components/views/settings/settings-view";
import { useAccount } from "@/contexts/account-context";
import type {
SessionThread,
UseSessionHistoryResult,
} from "@/hooks/use-session-history";
import { formatCostUsd, formatTokenCount } from "@/hooks/use-session-history";
import { desktopClient } from "@/lib/desktop-client";
import {
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
@@ -172,6 +178,14 @@ export function AgentSidebar({
}) {
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
const isCollapsed = !isMobile && state === "collapsed";
const { user, activeOrganization } = useAccount();
const { displayName, email } = user || {};
const username = displayName?.split(" ")?.[0] || email?.split("@")?.[0];
const accountName = username?.trim() || "Cline Desktop";
const accountScope = user
? (activeOrganization?.name ?? "Personal")
: undefined;
const accountInitial = accountName.charAt(0).toUpperCase();
const {
deleteThread: deleteHistoryThread,
forkThread: forkHistoryThread,
@@ -205,6 +219,26 @@ export function AgentSidebar({
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
const [appVersion, setAppVersion] = useState<string | null>(null);
const loadAppVersion = useCallback(async () => {
try {
const context = await desktopClient.invoke<{ appVersion?: unknown }>(
"get_process_context",
);
const version =
typeof context?.appVersion === "string"
? context.appVersion.trim()
: "";
setAppVersion(version || null);
} catch {
// Leave the version hidden; an older sidecar build has no appVersion.
}
}, []);
useEffect(() => {
void loadAppVersion();
}, [loadAppVersion]);
useEffect(() => {
if (isCollapsed && searchOpen) {
@@ -446,14 +480,31 @@ export function AgentSidebar({
isCollapsed && "justify-center px-0",
)}
>
<button
aria-label="Cline home"
className="rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
onClick={openHome}
type="button"
<Popover
onOpenChange={(open) => {
if (open && !appVersion) {
void loadAppVersion();
}
}}
>
<ClineLogo className="h-6 w-6" />
</button>
<PopoverTrigger asChild>
<button
aria-label="Cline home"
className="flex items-center gap-2 rounded-md p-1 text-sidebar-foreground transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
type="button"
onClick={openHome}
title="Home"
>
<ClineLogo className="h-6 w-6" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-3" side="bottom">
<p className="text-sm font-medium">Cline Code</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{appVersion ? `Version ${appVersion}` : "Version unavailable"}
</p>
</PopoverContent>
</Popover>
</div>
<div className={cn("shrink-0 px-3", isCollapsed && "px-1.5")}>
@@ -465,13 +516,13 @@ export function AgentSidebar({
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
aria-label="Home"
aria-label="New Session"
onClick={openHome}
title="Home"
title="New Session"
variant="sidebarItem"
>
<Home className="size-4" />
{!isCollapsed ? "Home" : null}
<Plus className="size-4" />
{!isCollapsed ? "New Session" : null}
</Button>
</div>
@@ -542,17 +593,6 @@ export function AgentSidebar({
</Button>
{sortMenu}
{filterMenu}
<Button
aria-label="New session"
className="m-0! size-8 p-0! text-muted-foreground hover:text-sidebar-foreground"
onClick={openNewThread}
size="icon"
title="New session"
type="button"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</div>
</div>
{searchOpen ? (
@@ -679,36 +719,47 @@ export function AgentSidebar({
)}
<div className="shrink-0 border-t border-sidebar-border/70 px-2 py-3">
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
view === "settings" &&
"bg-sidebar-accent text-sidebar-accent-foreground",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
{view !== "settings" && (
<Button
aria-label="Settings"
type="button"
variant="sidebarItem"
className={cn(
"min-w-0 justify-start",
isCollapsed && "mx-auto size-9 justify-center px-0",
)}
onClick={openSettings}
title="Settings"
>
<Settings className="size-4" />
{!isCollapsed ? "Settings" : null}
</Button>
)}
{!isCollapsed ? (
<div className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sidebar-foreground">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">
C
</span>
<span className="min-w-0">
<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">
Cline Desktop
</span>
<span className="block text-[11px] text-muted-foreground">
Local
{accountName}
<span className="pl-1 truncate text-[11px] text-muted-foreground">
{accountScope}
</span>
</span>
</span>
</div>
</button>
) : null}
</div>
</div>
@@ -881,7 +932,7 @@ function ThreadItem({
<HoverCardTrigger asChild>
<button
className={cn(
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
"group grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/50",
@@ -0,0 +1,238 @@
"use client";
import { AppWindow, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_NYAN_PET_SRC,
getNyanPetSrc,
subscribeNyanPet,
} from "@/lib/nyan-pet";
import { hidePet, showMainWindow, startPetDrag } from "@/lib/pet-window";
const NYAN_WIDTH = 160;
const NYAN_HEIGHT = 96;
const MARGIN = 32;
/**
* Shared pet media: the current gif source (kept in sync with Settings) plus an
* audio element that plays only while the pet is hovered or being dragged.
*/
function useNyanPetMedia() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [petSrc, setPetSrc] = useState(DEFAULT_NYAN_PET_SRC);
const [hovering, setHovering] = useState(false);
const [dragging, setDragging] = useState(false);
useEffect(() => {
setPetSrc(getNyanPetSrc());
return subscribeNyanPet(() => setPetSrc(getNyanPetSrc()));
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (hovering || dragging) {
void audio.play().catch(() => {});
} else {
audio.pause();
audio.currentTime = 0;
}
}, [hovering, dragging]);
return { audioRef, petSrc, hovering, setHovering, dragging, setDragging };
}
/**
* In-page pet used in plain web/dev mode (no Tauri). Free-floating and draggable
* within the window; its theme song plays while hovered or dragged. In the
* desktop app the pet lives in its own always-on-top window (see PetWindowView).
*/
export function NyanCat() {
const [visible, setVisible] = useState(true);
const [position, setPosition] = useState({ x: MARGIN, y: MARGIN });
const { audioRef, petSrc, dragging, setDragging, setHovering } =
useNyanPetMedia();
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
setPosition({
x: Math.max(MARGIN, window.innerWidth - NYAN_WIDTH - MARGIN),
y: Math.max(MARGIN, window.innerHeight - NYAN_HEIGHT - MARGIN),
});
}, []);
const clamp = useCallback((x: number, y: number) => {
const maxX = Math.max(0, window.innerWidth - NYAN_WIDTH);
const maxY = Math.max(0, window.innerHeight - NYAN_HEIGHT);
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
}, []);
useEffect(() => {
if (!dragging) {
return;
}
const handleMove = (event: PointerEvent) => {
const offset = dragOffsetRef.current;
if (!offset) {
return;
}
setPosition(clamp(event.clientX - offset.x, event.clientY - offset.y));
};
const stop = () => {
dragOffsetRef.current = null;
setDragging(false);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging, clamp, setDragging]);
useEffect(() => {
const handleResize = () =>
setPosition((current) => clamp(current.x, current.y));
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [clamp]);
if (!visible) {
return null;
}
return (
<div
className="group fixed z-50 cursor-grab select-none active:cursor-grabbing"
onPointerDown={(event) => {
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
return;
}
event.preventDefault();
dragOffsetRef.current = {
x: event.clientX - position.x,
y: event.clientY - position.y,
};
setDragging(true);
}}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
style={{ left: position.x, top: position.y, width: NYAN_WIDTH }}
>
<button
aria-label="Hide Nyan Cat"
className="absolute -right-2 -top-2 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => setVisible(false)}
type="button"
>
<X className="size-3" />
</button>
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
<img
alt="Desktop pet"
className="pointer-events-none w-full drop-shadow-lg"
draggable={false}
height={NYAN_HEIGHT}
src={petSrc}
width={NYAN_WIDTH}
/>
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
</div>
);
}
/**
* The pet as rendered inside its own transparent, always-on-top Tauri window.
* Dragging moves the OS window (so it can go anywhere on screen, even when the
* main window is minimized), and the dismiss button hides the window.
*/
export function PetWindowView() {
const { audioRef, petSrc, dragging, setDragging, setHovering } =
useNyanPetMedia();
// Make the window chrome see-through so only the pet shows.
useEffect(() => {
const root = document.documentElement;
const body = document.body;
const prevRoot = root.style.background;
const prevBody = body.style.background;
root.style.background = "transparent";
body.style.background = "transparent";
return () => {
root.style.background = prevRoot;
body.style.background = prevBody;
};
}, []);
// The OS drag can swallow the pointerup; reset on any pointer release.
useEffect(() => {
if (!dragging) {
return;
}
const stop = () => setDragging(false);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging, setDragging]);
return (
<div className="group fixed inset-0 flex select-none items-center justify-center">
<div
className="relative cursor-grab active:cursor-grabbing"
onPointerDown={(event) => {
if ((event.target as HTMLElement).closest("[data-nyan-control]")) {
return;
}
setDragging(true);
void startPetDrag();
}}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
>
<button
aria-label="Open Cline window"
className="absolute -left-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => void showMainWindow()}
title="Open Cline"
type="button"
>
<AppWindow className="size-3" />
</button>
<button
aria-label="Hide desktop pet"
className="absolute -right-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-background/90 text-foreground shadow ring-1 ring-border group-hover:flex"
data-nyan-control=""
onClick={() => void hidePet()}
title="Hide pet"
type="button"
>
<X className="size-3" />
</button>
{/* biome-ignore lint/performance/noImgElement: static public asset, not statically optimizable */}
<img
alt="Desktop pet"
className="pointer-events-none drop-shadow-lg"
draggable={false}
height={NYAN_HEIGHT}
src={petSrc}
width={NYAN_WIDTH}
/>
</div>
{/* biome-ignore lint/a11y/useMediaCaption: decorative background music */}
<audio loop preload="auto" ref={audioRef} src="/nyantune.mp3" />
</div>
);
}
@@ -313,8 +313,8 @@ function Sidebar({
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
? "left-0 group-data-[collapsible=offcanvas]:-left-(--sidebar-width)"
: "right-0 group-data-[collapsible=offcanvas]:-right-(--sidebar-width)",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@@ -13,14 +13,6 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Select,
SelectContent,
@@ -42,6 +34,7 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { SearchableSelect } from "./searchable-select";
import { WorkspaceSelector } from "./workspace-selector";
type ActiveMention = {
@@ -1045,7 +1038,7 @@ export function ChatInputBar({
ref={fileInputRef}
type="file"
/>
<div className="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 max-[560px]:col-start-2 max-[560px]:row-start-1">
<button
aria-pressed={mode === "plan"}
className={cn(
@@ -1087,7 +1080,6 @@ export function ChatInputBar({
}
onProviderChange={onProviderChange}
provider={provider}
variant={variant}
/>
</div>
<Select
@@ -1097,7 +1089,7 @@ export function ChatInputBar({
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 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]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
size="sm"
title={
modelSupportsReasoning === false
@@ -1128,7 +1120,7 @@ export function ChatInputBar({
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2 max-[560px]:contents">
<div className="max-w-48 overflow-visible max-[720px]:max-w-36 max-[560px]:col-start-3 max-[560px]:row-start-2">
<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}
@@ -1181,7 +1173,6 @@ function ModelSelector({
provider,
model,
isBusy,
variant,
onProviderChange,
onModelChange,
onModelSupportsReasoningChange,
@@ -1189,7 +1180,6 @@ function ModelSelector({
provider: string;
model: string;
isBusy: boolean;
variant: "conversation" | "welcome";
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
@@ -1416,13 +1406,13 @@ function ModelSelector({
]);
return (
<div className="flex min-w-0 shrink-0 items-center gap-1 text-[11px]">
<Combobox
<div className="flex min-w-0 shrink-0 items-center gap-0.5 text-[11px]">
<SearchableSelect
ariaLabel="Provider"
disabled={isBusy || providers.length === 0}
emptyLabel="No providers found."
items={providers}
onValueChange={(value) => {
if (!value) {
return;
}
onSelect={(value) => {
onProviderChange(value);
const rememberedModel = lastSelection.lastModelByProvider[value];
const providerModelIds = visibleProviderModels[value] ?? [];
@@ -1439,63 +1429,23 @@ function ModelSelector({
onModelChange(firstModel);
}
}}
placeholder="Provider"
searchPlaceholder="Search providers"
triggerClassName="max-w-28 text-[11px]"
value={resolvedProvider}
>
<ComboboxInput
aria-label="Provider"
className={cn(
"h-7 text-[11px] max-[560px]:w-20",
variant === "welcome" && "w-24 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || providers.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No providers found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox
/>
<span className="text-muted-foreground/50">/</span>
<SearchableSelect
ariaLabel="Model"
disabled={isBusy || modelsForProvider.length === 0}
emptyLabel="No models found."
items={modelsForProvider}
onValueChange={(value) => {
if (!value) {
return;
}
onModelChange(value);
}}
onSelect={(value) => onModelChange(value)}
placeholder="Model"
searchPlaceholder="Search models"
triggerClassName="max-w-52 text-[11px]"
value={resolvedModel}
>
<ComboboxInput
aria-label="Model"
className={cn(
"h-7 text-[11px] max-[560px]:w-32",
variant === "welcome" && "w-52 border-0 bg-transparent shadow-none",
)}
disabled={isBusy || modelsForProvider.length === 0}
readOnly
showClear={false}
showTrigger
/>
<ComboboxContent>
<ComboboxEmpty>No models found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem className="text-[11px]" key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
/>
</div>
);
}
@@ -0,0 +1,147 @@
"use client";
import { Check, Search } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
/**
* A button-styled select whose menu is a searchable, filterable list — the same
* interaction the workspace and branch pickers use. The trigger shows the
* current value with no chevron; clicking it opens the popover.
*/
export function SearchableSelect({
value,
items,
onSelect,
disabled = false,
ariaLabel,
searchPlaceholder = "Search...",
emptyLabel = "No results",
placeholder = "Select",
icon,
triggerClassName,
align = "start",
placement = "top",
}: {
value: string;
items: string[];
onSelect: (value: string) => void;
disabled?: boolean;
ariaLabel: string;
searchPlaceholder?: string;
emptyLabel?: string;
placeholder?: string;
icon?: ReactNode;
triggerClassName?: string;
align?: "start" | "end";
placement?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click; reset the filter each time the menu opens.
useEffect(() => {
if (!open) {
setSearch("");
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
// pointerdown in the capture phase so we still fire before a portaled menu
// (e.g. the Radix effort Select) handles its own trigger's pointerdown and
// calls preventDefault, which would otherwise suppress a mousedown listener.
document.addEventListener("pointerdown", handlePointerDown, true);
return () =>
document.removeEventListener("pointerdown", handlePointerDown, true);
}, [open]);
const filtered = useMemo(
() =>
items.filter((item) => item.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleSelect = (item: string) => {
if (item !== value) onSelect(item);
setOpen(false);
};
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="listbox"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-medium 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",
triggerClassName,
)}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
title={value}
type="button"
>
{icon}
<span className="truncate">{value || placeholder}</span>
</button>
{open && (
<div
className={cn(
"absolute z-50 w-64 rounded-lg border border-border bg-popover shadow-xl",
align === "end" ? "right-0" : "left-0",
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
value={search}
/>
</div>
</div>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{emptyLabel}
</div>
) : (
filtered.map((item) => (
<button
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
item === value ? "bg-accent" : "hover:bg-accent/50",
)}
key={item}
onClick={() => handleSelect(item)}
type="button"
>
<span className="truncate text-xs text-foreground">
{item}
</span>
{item === value && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,12 @@
"use client";
import { ArrowRight, FolderPlus, Plus } from "lucide-react";
import { ArrowRight } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import { useWorkspace } from "@/contexts/workspace-context";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
interface QuickAction {
id: string;
@@ -15,6 +15,9 @@ interface QuickAction {
prompt: string;
}
const HERO_VERBS = ["build", "create", "fix", "know"] as const;
const HERO_CYCLE_MS = 2600;
const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{
id: "review-changes",
@@ -30,33 +33,44 @@ const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
},
];
function toWorkspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "Workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "Workspace";
}
function HeroHeading() {
const [verbIndex, setVerbIndex] = useState(0);
function workspaceLabels(paths: string[]): Map<string, string> {
const segments = paths.map((path) =>
path
.trim()
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.filter(Boolean),
);
return new Map(
paths.map((path, index) => {
const parts = segments[index] ?? [];
for (let depth = 1; depth <= parts.length; depth += 1) {
const candidate = parts.slice(-depth).join("/");
const matches = segments.filter(
(other) => other.slice(-depth).join("/") === candidate,
).length;
if (matches === 1) return [path, candidate];
}
return [path, toWorkspaceName(path)];
}),
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
if (media.matches) return;
const interval = setInterval(() => {
setVerbIndex((prev) => (prev + 1) % HERO_VERBS.length);
}, HERO_CYCLE_MS);
return () => clearInterval(interval);
}, []);
const verb = HERO_VERBS[verbIndex];
return (
<h1
id="hero-header"
className="text-balance text-left text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-tight text-foreground"
>
<span className="sr-only">What would you like to build?</span>
<span aria-hidden="true">
What would you like to{" "}
{/* key remounts the word each cycle so the chars re-trigger their entrance */}
<span key={verb}>
{verb.split("").map((char, index) => (
<span
className="hero-word-char"
// biome-ignore lint/suspicious/noArrayIndexKey: the word remounts via the parent key each cycle, so char position is a stable, non-reordering identity
key={`${verb}-${index}`}
style={{ animationDelay: `${index * 45}ms` }}
>
{char}
</span>
))}
</span>
?
</span>
</h1>
);
}
@@ -66,12 +80,18 @@ export function WelcomeScreen({
composer,
onStartChat,
quickActions,
gitBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
active: boolean;
body: ReactNode;
composer: ReactNode;
onStartChat: (prompt: string) => void;
quickActions: QuickAction[];
gitBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const {
workspaceRoot,
@@ -80,61 +100,13 @@ export function WelcomeScreen({
switchWorkspace,
pickWorkspaceDirectory,
} = useWorkspace();
const [switchingWorkspace, setSwitchingWorkspace] = useState<string | null>(
null,
);
const [addingWorkspace, setAddingWorkspace] = useState(false);
const availableWorkspaces = useMemo(() => {
const next = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) next.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const workspacePath of workspaces) register(workspacePath);
return [...next.values()];
}, [workspaceRoot, workspaces]);
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
const labelsByWorkspace = useMemo(
() => workspaceLabels(availableWorkspaces),
[availableWorkspaces],
);
useEffect(() => {
if (active) void refreshWorkspaces();
}, [active, refreshWorkspaces]);
const handleSelectWorkspace = useCallback(
async (path: string) => {
if (
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot) ||
switchingWorkspace
) {
return;
}
setSwitchingWorkspace(path);
try {
await switchWorkspace(path);
} finally {
setSwitchingWorkspace(null);
}
},
[switchWorkspace, switchingWorkspace, workspaceRoot],
);
const handleAddWorkspace = useCallback(async () => {
if (addingWorkspace) return;
setAddingWorkspace(true);
try {
const selected = await pickWorkspaceDirectory(workspaceRoot || undefined);
if (selected) await switchWorkspace(selected);
} finally {
setAddingWorkspace(false);
}
}, [addingWorkspace, pickWorkspaceDirectory, switchWorkspace, workspaceRoot]);
return (
<div
className={cn(
@@ -154,60 +126,25 @@ export function WelcomeScreen({
<div
className={cn(
active
? "mx-auto flex w-full max-w-[960px] flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(8rem,26vh,17rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
: "contents",
)}
>
{active ? (
<>
<h1 className="text-balance text-center text-[clamp(2rem,3vw,2.6rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">
What would you like to build?
</h1>
<HeroHeading />
<div className="mt-11 flex min-w-0 items-center gap-1.5 text-sm">
<fieldset className="flex min-h-8 min-w-0 flex-1 items-center gap-1.5 overflow-x-auto pb-1">
<legend className="sr-only">Workspaces</legend>
{availableWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) ===
normalizeWorkspacePath(workspaceRoot);
const isSwitching = switchingWorkspace === path;
return (
<button
aria-pressed={isActive}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
disabled={Boolean(switchingWorkspace)}
key={path}
onClick={() => void handleSelectWorkspace(path)}
title={path}
type="button"
>
{isSwitching
? "Switching..."
: (labelsByWorkspace.get(path) ??
toWorkspaceName(path))}
</button>
);
})}
</fieldset>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-[480px]:px-2"
disabled={addingWorkspace}
onClick={() => void handleAddWorkspace()}
type="button"
>
{addingWorkspace ? (
<FolderPlus className="size-4 animate-pulse" />
) : (
<Plus className="size-4" />
)}
New project
</button>
<div className="mt-11 flex min-w-0 items-center">
<WelcomeWorkspaceControls
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
onPickWorkspaceDirectory={pickWorkspaceDirectory}
onRefreshWorkspaces={refreshWorkspaces}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
</div>
</>
) : null}
@@ -0,0 +1,400 @@
"use client";
import { Check, Folder, GitBranch, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
if (unixHome) return unixHome[1] ? `~/${unixHome[1]}` : "~";
const linuxHome = path.match(/^\/home\/[^/]+\/(.*)$/);
if (linuxHome) return linuxHome[1] ? `~/${linuxHome[1]}` : "~";
const windowsHome = path.match(/^[A-Za-z]:\\Users\\[^\\]+\\(.*)$/);
if (windowsHome) {
const tail = windowsHome[1]?.replaceAll("\\", "/") || "";
return tail ? `~/${tail}` : "~";
}
return path;
}
function workspaceName(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "workspace";
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
}
const TRIGGER_CLASS =
"inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const PANEL_CLASS =
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
function SearchInput({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<div className="border-b border-border p-2">
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
<Search className="size-3 shrink-0 text-muted-foreground" />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Input
autoFocus
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
</div>
</div>
);
}
function WorkspacePicker({
open,
onToggle,
onClose,
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
}) {
const [search, setSearch] = useState("");
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspacePath(workspaceRoot),
[workspaceRoot],
);
// Refresh the catalog and clear the filter each time the menu opens.
useEffect(() => {
if (!open) return;
setSearch("");
void onRefreshWorkspaces();
}, [open, onRefreshWorkspaces]);
// The active workspace can be an excluded path (restored session, process
// cwd fallback); register it explicitly so it stays visible while active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((path) =>
path.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (path: string) => {
const next = path.trim();
if (!next || normalizeWorkspacePath(next) === normalizedWorkspaceRoot) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchWorkspace(next);
setSwitching(false);
if (switched) onClose();
};
const handleAddWorkspace = async () => {
if (picking || switching) return;
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
if (picked?.trim()) await handleSelect(picked.trim());
} finally {
setPicking(false);
}
};
return (
<div className="relative shrink-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={TRIGGER_CLASS}
onClick={onToggle}
title={workspaceRoot}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">
{workspaceName(workspaceRoot)}
</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search workspaces"
value={search}
/>
<div className="p-1.5">
<div className="flex max-h-48 flex-col gap-0.5 overflow-y-auto">
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No workspaces found
</div>
) : (
filteredWorkspaces.map((path) => {
const isActive =
normalizeWorkspacePath(path) === normalizedWorkspaceRoot;
return (
<Button
className={cn(
"flex h-auto w-full items-center justify-between rounded-md p-2 text-left",
isActive ? "bg-accent" : "hover:bg-accent/50",
)}
disabled={switching}
key={path}
onClick={() => void handleSelect(path)}
variant="ghost"
>
<span className="flex min-w-0 items-center gap-2">
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-foreground">
{formatWorkspacePath(path)}
</span>
</span>
{isActive && (
<Check className="ml-2 size-3 shrink-0 text-foreground" />
)}
</Button>
);
})
)}
</div>
<Button
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking}
onClick={() => void handleAddWorkspace()}
size="sm"
variant="ghost"
>
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
</Button>
</div>
</div>
)}
</div>
);
}
function BranchPicker({
open,
onToggle,
onClose,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
open: boolean;
onToggle: () => void;
onClose: () => void;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [search, setSearch] = useState("");
const [branches, setBranches] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [switching, setSwitching] = useState(false);
// Load branches fresh each time the menu opens.
useEffect(() => {
if (!open) return;
let cancelled = false;
setSearch("");
setLoading(true);
onListGitBranches()
.then((payload) => {
if (!cancelled) setBranches(payload.branches);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, onListGitBranches]);
const hasGit = currentBranch !== "no-git";
const branchLabel = hasGit ? currentBranch : "No branch";
const filteredBranches = branches.filter((branch) =>
branch.toLowerCase().includes(search.toLowerCase()),
);
const handleSelect = async (branch: string) => {
if (branch === currentBranch) {
onClose();
return;
}
if (switching) return;
setSwitching(true);
const switched = await onSwitchGitBranch(branch);
setSwitching(false);
if (switched) onClose();
};
return (
<div className="relative min-w-0">
<button
aria-expanded={open}
aria-haspopup="menu"
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
onClick={onToggle}
title={branchLabel}
type="button"
>
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{branchLabel}</span>
</button>
{open && (
<div className={PANEL_CLASS}>
<SearchInput
onChange={setSearch}
placeholder="Search branches"
value={search}
/>
<div className="p-1.5">
{loading ? (
<div className="px-2 py-4 text-xs text-muted-foreground">
Loading...
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
</div>
) : (
filteredBranches.map((branch) => (
<Button
className={cn(
"flex h-auto items-center gap-2 rounded-md px-2 py-2 text-left",
currentBranch === branch
? "bg-accent"
: "hover:bg-accent/50",
)}
disabled={switching}
key={branch}
onClick={() => void handleSelect(branch)}
variant="ghost"
>
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{branch}
</span>
{currentBranch === branch && (
<Check className="ml-auto size-3 shrink-0 text-foreground" />
)}
</Button>
))
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
export function WelcomeWorkspaceControls({
workspaceRoot,
workspaces,
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
}: {
workspaceRoot: string;
workspaces: string[];
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Close whichever menu is open when clicking outside the control row.
useEffect(() => {
if (!openMenu) return;
const handlePointerDown = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpenMenu(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [openMenu]);
return (
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
<WorkspacePicker
onClose={() => setOpenMenu(null)}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={onRefreshWorkspaces}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
current === "workspace" ? null : "workspace",
)
}
open={openMenu === "workspace"}
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
</div>
);
}
@@ -79,4 +79,30 @@ describe("WorkspaceSelector", () => {
expect(onSwitchGitBranch).toHaveBeenCalledWith("feature/review");
});
});
it("lists the active workspace even when the catalog excludes it", async () => {
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => true)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot="/Users/beatrix/Desktop"
workspaces={["/workspace/one"]}
/>,
);
});
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("~/Desktop");
expect(container.textContent).toContain("/workspace/one");
});
});
});
@@ -5,6 +5,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
function formatWorkspacePath(path: string): string {
const unixHome = path.match(/^\/Users\/[^/]+\/(.*)$/);
@@ -19,17 +20,6 @@ function formatWorkspacePath(path: string): string {
return path;
}
function normalizeWorkspacePath(path: string): string {
const normalized = path.trim().replace(/[\\/]+$/, "");
if (!normalized) {
return "";
}
if (/^[A-Za-z]:/.test(normalized)) {
return normalized.toLowerCase();
}
return normalized;
}
export function WorkspaceSelector({
currentBranch,
workspaceRoot,
@@ -181,7 +171,20 @@ export function WorkspaceSelector({
b.toLowerCase().includes(search.toLowerCase()),
);
const filteredWorkspaces = workspaces.filter((w) =>
// The catalog excludes non-project paths (home, Desktop, ~/.cline), but an
// explicitly opened workspace must stay visible while it is active.
const availableWorkspaces = useMemo(() => {
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((w) =>
w.toLowerCase().includes(search.toLowerCase()),
);
@@ -1,6 +1,19 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
MAX_CHAT_BACKGROUND_BYTES,
readChatBackground,
setChatBackground,
} from "@/lib/chat-background";
import { desktopClient } from "@/lib/desktop-client";
import {
DEFAULT_NYAN_PET_SRC,
MAX_NYAN_PET_BYTES,
readStoredPetGif,
setStoredPetGif,
} from "@/lib/nyan-pet";
import { hidePet, isPetVisible, isTauri, showPet } from "@/lib/pet-window";
import type {
Provider,
ProviderCatalogResponse,
@@ -581,7 +594,261 @@ function GeneralSettingsContent() {
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
<NyanPetSetting />
<PetWindowToggle />
<ChatBackgroundSetting />
</section>
</PageFrame>
);
}
function ChatBackgroundSetting() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [background, setBackground] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setBackground(readChatBackground());
}, []);
const handleFile = useCallback((file: File | undefined) => {
if (!file) {
return;
}
setError(null);
if (!file.type.startsWith("image/")) {
setError("Please choose an image file.");
return;
}
if (file.size > MAX_CHAT_BACKGROUND_BYTES) {
setError("That image is too large. Please pick one under 3 MB.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
if (!dataUrl) {
setError("Could not read that image.");
return;
}
try {
setChatBackground(dataUrl);
setBackground(dataUrl);
} catch {
setError("Could not save that image — it may be too large to store.");
}
};
reader.onerror = () => setError("Could not read that image.");
reader.readAsDataURL(file);
}, []);
const handleReset = useCallback(() => {
setChatBackground(null);
setBackground(null);
setError(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, []);
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Chat background
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Upload an image to show behind your chat conversation.
</p>
{error ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
<div className="flex h-14 w-24 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{background ? (
// biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable
<img
alt="MCP background preview"
className="h-full w-full object-cover"
src={background}
/>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</div>
<input
accept="image/gif,image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
handleFile(event.target.files?.[0]);
event.target.value = "";
}}
ref={inputRef}
type="file"
/>
<Button
onClick={() => inputRef.current?.click()}
type="button"
variant="outline"
>
Upload image
</Button>
{background ? (
<Button onClick={handleReset} type="button" variant="ghost">
Reset
</Button>
) : null}
</div>
</div>
);
}
function PetWindowToggle() {
const [inDesktopApp, setInDesktopApp] = useState(false);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!isTauri()) {
return;
}
setInDesktopApp(true);
void isPetVisible().then(setVisible);
}, []);
const toggle = useCallback(async (next: boolean) => {
setVisible(next);
if (next) {
await showPet();
} else {
await hidePet();
}
}, []);
// Only meaningful in the desktop app, where the pet is its own OS window.
if (!inDesktopApp) {
return null;
}
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Show floating pet
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Float the pet on top of your screen so it stays visible even when this
window is minimized or closed.
</p>
</div>
<Switch
aria-label="Show floating pet"
checked={visible}
onCheckedChange={(checked) => void toggle(checked)}
/>
</div>
);
}
function NyanPetSetting() {
const inputRef = useRef<HTMLInputElement | null>(null);
const [petGif, setPetGif] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPetGif(readStoredPetGif());
}, []);
const handleFile = useCallback((file: File | undefined) => {
if (!file) {
return;
}
setError(null);
if (!file.type.startsWith("image/")) {
setError("Please choose an image file — an animated GIF works best.");
return;
}
if (file.size > MAX_NYAN_PET_BYTES) {
setError("That image is too large. Please pick one under 3 MB.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
if (!dataUrl) {
setError("Could not read that image.");
return;
}
try {
setStoredPetGif(dataUrl);
setPetGif(dataUrl);
} catch {
setError("Could not save that image — it may be too large to store.");
}
};
reader.onerror = () => setError("Could not read that image.");
reader.readAsDataURL(file);
}, []);
const handleReset = useCallback(() => {
setStoredPetGif(null);
setPetGif(null);
setError(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, []);
const previewSrc = petGif ?? DEFAULT_NYAN_PET_SRC;
return (
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">Desktop pet</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Replace Nyan Cat with your own GIF. It floats on top of the app, and
its tune plays while you hover or drag it.
</p>
{error ? (
<p className="mt-2 text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 max-[720px]:justify-end">
<div className="flex h-14 w-20 items-center justify-center overflow-hidden rounded-md border bg-muted/40">
{/* biome-ignore lint/performance/noImgElement: user-provided data URL, not statically optimizable */}
<img
alt="Desktop pet preview"
className="max-h-full max-w-full object-contain"
src={previewSrc}
/>
</div>
<input
accept="image/gif,image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
handleFile(event.target.files?.[0]);
event.target.value = "";
}}
ref={inputRef}
type="file"
/>
<Button
onClick={() => inputRef.current?.click()}
type="button"
variant="outline"
>
Upload GIF
</Button>
{petGif ? (
<Button onClick={handleReset} type="button" variant="ghost">
Reset
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import type { ClineAccountUser } from "@cline/core";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ACCOUNT_IDENTITY_STORAGE_KEY,
AccountProvider,
isSignedOutAccountError,
parseCachedAccountUser,
useAccount,
} from "./account-context";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
function makeUser(overrides: Partial<ClineAccountUser> = {}): ClineAccountUser {
return {
id: "user-1",
email: "beatrix@cline.bot",
displayName: "Beatrix",
photoUrl: "",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
organizations: [],
...overrides,
};
}
function Probe() {
const { user, activeOrganization } = useAccount();
return (
<div>
<span data-testid="account-name">{user?.displayName ?? "none"}</span>
<span data-testid="account-org">
{activeOrganization?.name ?? "none"}
</span>
</div>
);
}
let container: HTMLDivElement;
let root: Root;
function probeText(testId: string): string | null | undefined {
return container.querySelector(`[data-testid="${testId}"]`)?.textContent;
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.localStorage.clear();
invoke.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
describe("account context", () => {
it("parses only cached payloads that look like an account user", () => {
expect(parseCachedAccountUser(null)).toBeNull();
expect(parseCachedAccountUser("not json")).toBeNull();
expect(parseCachedAccountUser(JSON.stringify({ user: 42 }))).toBeNull();
expect(
parseCachedAccountUser(JSON.stringify({ user: makeUser() }))?.displayName,
).toBe("Beatrix");
});
it("classifies signed-out errors separately from transient failures", () => {
expect(
isSignedOutAccountError(new Error("No Cline account auth token found")),
).toBe(true);
expect(
isSignedOutAccountError(
new Error(
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
),
),
).toBe(true);
expect(isSignedOutAccountError(new Error("fetch failed"))).toBe(false);
});
it("fetches the signed-in user on mount and caches the identity", async () => {
invoke.mockResolvedValue(
makeUser({
organizations: [
{
active: true,
memberId: "member-1",
name: "Cline Bot Inc",
organizationId: "org-1",
roles: ["admin"],
},
],
}),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("Beatrix");
expect(probeText("account-org")).toBe("Cline Bot Inc");
});
expect(invoke).toHaveBeenCalledWith("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
expect(
parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
)?.email,
).toBe("beatrix@cline.bot");
});
it("clears the cached identity when the account is signed out", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(new Error("No Cline account auth token found"));
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(probeText("account-name")).toBe("none");
});
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).toBeNull();
});
it("keeps the cached identity when the refresh fails transiently", async () => {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user: makeUser() }),
);
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await act(async () => {
root.render(
<AccountProvider>
<Probe />
</AccountProvider>,
);
});
await vi.waitFor(() => {
expect(invoke).toHaveBeenCalled();
});
expect(probeText("account-name")).toBe("Beatrix");
expect(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
).not.toBeNull();
});
});
@@ -0,0 +1,144 @@
"use client";
import type { ClineAccountOrganization, ClineAccountUser } from "@cline/core";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { desktopClient } from "@/lib/desktop-client";
export const ACCOUNT_IDENTITY_STORAGE_KEY = "cline.code.account-identity.v1";
const SIGNED_OUT_ERROR_MARKERS = [
"No Cline account auth token found",
"no longer valid",
];
type AccountContextValue = {
user: ClineAccountUser | null;
organizations: ClineAccountOrganization[];
activeOrganization: ClineAccountOrganization | null;
refreshAccount: () => Promise<void>;
};
const AccountContext = createContext<AccountContextValue>({
user: null,
organizations: [],
activeOrganization: null,
refreshAccount: async () => undefined,
});
export function parseCachedAccountUser(
raw: string | null,
): ClineAccountUser | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as { user?: ClineAccountUser | null };
const user = parsed?.user;
if (!user || typeof user !== "object") {
return null;
}
if (
typeof user.email !== "string" &&
typeof user.displayName !== "string"
) {
return null;
}
return user;
} catch {
return null;
}
}
function readCachedAccountUser(): ClineAccountUser | null {
if (typeof window === "undefined") {
return null;
}
try {
return parseCachedAccountUser(
window.localStorage.getItem(ACCOUNT_IDENTITY_STORAGE_KEY),
);
} catch {
return null;
}
}
function writeCachedAccountUser(user: ClineAccountUser | null): void {
if (typeof window === "undefined") {
return;
}
try {
if (user) {
window.localStorage.setItem(
ACCOUNT_IDENTITY_STORAGE_KEY,
JSON.stringify({ user }),
);
} else {
window.localStorage.removeItem(ACCOUNT_IDENTITY_STORAGE_KEY);
}
} catch {
// Account identity still works for this session without the cache.
}
}
export function isSignedOutAccountError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return SIGNED_OUT_ERROR_MARKERS.some((marker) => message.includes(marker));
}
export function AccountProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ClineAccountUser | null>(null);
const refreshAccount = useCallback(async () => {
try {
const me = await desktopClient.invoke<ClineAccountUser>("cline_account", {
action: "clineAccount",
operation: "fetchMe",
});
setUser(me ?? null);
writeCachedAccountUser(me ?? null);
} catch (error) {
if (isSignedOutAccountError(error)) {
setUser(null);
writeCachedAccountUser(null);
}
// Transient failures (offline, sidecar restarting) keep the cached
// identity rather than flashing a signed-out state.
}
}, []);
useEffect(() => {
// Seed from the cached identity after mount so the signed-in name renders
// without waiting on the network fetch, which revalidates it right after.
// localStorage must not be read during the initial render: the server
// renders the signed-out state, and a differing first client render would
// be a hydration mismatch.
setUser((current) => current ?? readCachedAccountUser());
void refreshAccount();
}, [refreshAccount]);
const value = useMemo<AccountContextValue>(() => {
const organizations = user?.organizations ?? [];
return {
user,
organizations,
activeOrganization:
organizations.find((organization) => organization.active) ?? null,
refreshAccount,
};
}, [refreshAccount, user]);
return (
<AccountContext.Provider value={value}>{children}</AccountContext.Provider>
);
}
export function useAccount(): AccountContextValue {
return useContext(AccountContext);
}
@@ -3,6 +3,9 @@ import type { SessionHookEvent } from "@/lib/session-diff";
export type ProcessContext = {
workspaceRoot: string;
cwd: string;
homeDir?: string;
platform?: string;
appVersion?: string;
};
export type AgentChunkEvent = {
@@ -46,6 +46,7 @@ import type {
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
} from "@/lib/workspace-paths";
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
@@ -475,6 +476,9 @@ export function useChatSession() {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (ctx.homeDir) {
registerHostHomeDirectory(ctx.homeDir);
}
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
const validation = rememberedWorkspace
@@ -0,0 +1,55 @@
export const CHAT_BACKGROUND_STORAGE_KEY = "cline-chat-background";
const CHAT_BACKGROUND_CHANGE_EVENT = "cline:chat-background-changed";
/**
* Upper bound on the uploaded background. localStorage caps around ~5 MB per
* origin and base64 inflates bytes by ~33%, so keep the raw file under that
* (shared with the pet gif, so leave headroom for both).
*/
export const MAX_CHAT_BACKGROUND_BYTES = 3 * 1024 * 1024;
/** The custom chat background data URL, or null when none is set. */
export function readChatBackground(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(CHAT_BACKGROUND_STORAGE_KEY);
} catch {
return null;
}
}
/**
* Persist (or clear, when passed null) the chat background and notify listeners.
* Throws if the value exceeds the localStorage quota — callers should validate
* size first and surface a friendly error.
*/
export function setChatBackground(dataUrl: string | null): void {
if (typeof window === "undefined") {
return;
}
if (dataUrl) {
window.localStorage.setItem(CHAT_BACKGROUND_STORAGE_KEY, dataUrl);
} else {
window.localStorage.removeItem(CHAT_BACKGROUND_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(CHAT_BACKGROUND_CHANGE_EVENT));
}
/**
* Subscribe to background changes from this tab (settings edits) or another one
* (native `storage` event). Returns a cleanup function.
*/
export function subscribeChatBackground(listener: () => void): () => void {
if (typeof window === "undefined") {
return () => {};
}
const handle = () => listener();
window.addEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
window.addEventListener("storage", handle);
return () => {
window.removeEventListener(CHAT_BACKGROUND_CHANGE_EVENT, handle);
window.removeEventListener("storage", handle);
};
}
@@ -99,7 +99,7 @@ const RECONNECT_MAX_DELAY_MS = 4_000;
// Commands that should be routed to Tauri's native invoke bridge instead of
// the WebSocket transport — only applicable in the full Tauri app shell.
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
function isTauriAvailable(): boolean {
export function isTauriAvailable(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invoke, setTitle } = vi.hoisted(() => ({
invoke: vi.fn(),
setTitle: vi.fn(async () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
isTauriAvailable: () => window.__TAURI_INTERNALS__ !== undefined,
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ setTitle }),
}));
async function importFresh() {
vi.resetModules();
return await import("./desktop-window-title");
}
beforeEach(() => {
invoke.mockReset();
setTitle.mockClear();
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
delete (window as any).__TAURI_INTERNALS__;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("desktop window title", () => {
it("builds a versioned title, falling back to the base title without a version", async () => {
const { buildDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
expect(buildDesktopWindowTitle("1.2.3")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(" 1.2.3 ")).toBe(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
expect(buildDesktopWindowTitle(undefined)).toBe(
DEFAULT_DESKTOP_WINDOW_TITLE,
);
expect(buildDesktopWindowTitle("")).toBe(DEFAULT_DESKTOP_WINDOW_TITLE);
});
it("does nothing outside the Tauri shell", async () => {
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(invoke).not.toHaveBeenCalled();
expect(setTitle).not.toHaveBeenCalled();
});
it("sets the native window title once the sidecar reports a version", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({
workspaceRoot: "",
cwd: "",
appVersion: "1.2.3",
});
const { syncDesktopWindowTitle, DEFAULT_DESKTOP_WINDOW_TITLE } =
await importFresh();
await syncDesktopWindowTitle();
expect(invoke).toHaveBeenCalledWith("get_process_context");
expect(setTitle).toHaveBeenCalledWith(
`${DEFAULT_DESKTOP_WINDOW_TITLE} v1.2.3`,
);
});
it("leaves the title alone when the version is missing or the sidecar call fails", async () => {
// biome-ignore lint/suspicious/noExplicitAny: test-only global shim for the Tauri bridge marker
(window as any).__TAURI_INTERNALS__ = {};
invoke.mockResolvedValue({ workspaceRoot: "", cwd: "" });
const { syncDesktopWindowTitle } = await importFresh();
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
invoke.mockRejectedValue(
new Error("Desktop backend transport unavailable"),
);
await syncDesktopWindowTitle();
expect(setTitle).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import type { ProcessContext } from "@/hooks/chat-session/types";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
export const DEFAULT_DESKTOP_WINDOW_TITLE = "Cline Code";
export function buildDesktopWindowTitle(version: string | undefined): string {
const trimmed = version?.trim();
return trimmed
? `${DEFAULT_DESKTOP_WINDOW_TITLE} v${trimmed}`
: DEFAULT_DESKTOP_WINDOW_TITLE;
}
/**
* Tauri's window title is static in tauri.conf.json; append the running app
* version once the sidecar reports it. No-op outside the Tauri shell (e.g.
* sidecar/web dev mode), where there is no native window to retitle.
*/
export async function syncDesktopWindowTitle(): Promise<void> {
if (!isTauriAvailable()) {
return;
}
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (!ctx.appVersion?.trim()) {
return;
}
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setTitle(buildDesktopWindowTitle(ctx.appVersion));
} catch {
// Keep the default static title if the sidecar or window API is unavailable.
}
}
@@ -0,0 +1,62 @@
export const NYAN_PET_STORAGE_KEY = "cline-nyan-pet-gif";
const NYAN_PET_CHANGE_EVENT = "cline:nyan-pet-changed";
/** Bundled default pet, served from webview/public. */
export const DEFAULT_NYAN_PET_SRC = "/nyancat.gif";
/**
* Upper bound on an uploaded pet. localStorage caps around ~5 MB per origin and
* base64 inflates bytes by ~33%, so keep the raw file comfortably under that.
*/
export const MAX_NYAN_PET_BYTES = 3 * 1024 * 1024;
/** The custom pet data URL the user uploaded, or null when using the default. */
export function readStoredPetGif(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(NYAN_PET_STORAGE_KEY);
} catch {
return null;
}
}
/** The image source the pet should render — custom upload or bundled default. */
export function getNyanPetSrc(): string {
return readStoredPetGif() ?? DEFAULT_NYAN_PET_SRC;
}
/**
* Persist (or clear, when passed null) the custom pet and notify live listeners.
* Throws if the value exceeds the localStorage quota — callers should validate
* size first and surface a friendly error.
*/
export function setStoredPetGif(dataUrl: string | null): void {
if (typeof window === "undefined") {
return;
}
if (dataUrl) {
window.localStorage.setItem(NYAN_PET_STORAGE_KEY, dataUrl);
} else {
window.localStorage.removeItem(NYAN_PET_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(NYAN_PET_CHANGE_EVENT));
}
/**
* Subscribe to pet changes from this tab (settings edits) or another one
* (native `storage` event). Returns a cleanup function.
*/
export function subscribeNyanPet(listener: () => void): () => void {
if (typeof window === "undefined") {
return () => {};
}
const handle = () => listener();
window.addEventListener(NYAN_PET_CHANGE_EVENT, handle);
window.addEventListener("storage", handle);
return () => {
window.removeEventListener(NYAN_PET_CHANGE_EVENT, handle);
window.removeEventListener("storage", handle);
};
}
@@ -0,0 +1,57 @@
"use client";
import { isTauriAvailable } from "@/lib/desktop-client";
/** Whether we're running inside the Tauri desktop shell (vs plain web/dev). */
export function isTauri(): boolean {
return isTauriAvailable();
}
async function invokeTauri<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T | null> {
if (!isTauriAvailable()) {
return null;
}
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(command, args);
} catch (error) {
console.error(`pet-window: ${command} failed`, error);
return null;
}
}
/**
* The label of the Tauri window this document is running in ("main" or "pet"),
* or null when not running under Tauri. Used to decide whether to render the
* full app or just the floating pet.
*/
export async function getCurrentWindowLabel(): Promise<string | null> {
if (!isTauriAvailable()) {
return null;
}
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
return getCurrentWindow().label;
} catch {
return null;
}
}
/** Begin an OS-level drag of the pet window (called from the pet's webview). */
export const startPetDrag = () => invokeTauri("start_pet_drag");
/** Show the floating pet window and reassert its always-on-top presence. */
export const showPet = () => invokeTauri("show_pet");
/** Hide the floating pet window. */
export const hidePet = () => invokeTauri("hide_pet");
/** Whether the floating pet window is currently visible. */
export const isPetVisible = async (): Promise<boolean> =>
(await invokeTauri<boolean>("is_pet_visible")) ?? false;
/** Reopen (show + focus) the main app window after it was closed/hidden. */
export const showMainWindow = () => invokeTauri("show_main_window");
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
filterWorkspacePaths,
isExcludedWorkspacePath,
mergeWorkspacePaths,
normalizeWorkspacePath,
parseWorkspaceSelectionStorage,
registerHostHomeDirectory,
workspacePathsFromSessions,
} from "./workspace-paths";
@@ -45,6 +48,36 @@ describe("workspace paths", () => {
]);
});
it("keeps the first-seen order so earlier groups rank first", () => {
expect(
mergeWorkspacePaths(["/projects/zulu", "/projects/mike"], [
"/projects/alpha",
"/projects/zulu/",
]),
).toEqual(["/projects/zulu", "/projects/mike", "/projects/alpha"]);
});
it("orders the catalog by the most recent session in each workspace", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" },
{
workspaceRoot: "/projects/active",
startedAt: "2026-02-01T00:00:00Z",
endedAt: "2026-02-01T01:00:00Z",
},
{ workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" },
{ workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" },
{ workspaceRoot: "/projects/undated" },
]);
expect(paths).toEqual([
"/projects/old",
"/projects/mid",
"/projects/active",
"/projects/undated",
]);
});
it("builds the project catalog from every loaded history workspace", () => {
const sessions = Array.from({ length: 25 }, (_, index) => ({
workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`,
@@ -74,4 +107,93 @@ describe("workspace paths", () => {
workspaces: [],
});
});
it("excludes .cline-internal paths from the workspace catalog", () => {
expect(
isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"),
).toBe(true);
expect(
isExcludedWorkspacePath(
"/Users/beatrix/.cline/plugins/_installed/git/github.com/example-plugin",
),
).toBe(true);
expect(
isExcludedWorkspacePath("C:\\Users\\Saoud\\.cline\\worktrees\\abc"),
).toBe(true);
});
describe("with a registered host home directory", () => {
afterEach(() => {
registerHostHomeDirectory("");
});
it("excludes a non-standard home and its Desktop but keeps projects inside them", () => {
registerHostHomeDirectory("/srv/homes/bea/");
expect(isExcludedWorkspacePath("/srv/homes/bea")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/srv/homes/bea/projects/app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/srv/homes/beatrix")).toBe(false);
});
it("matches Windows homes case-insensitively", () => {
registerHostHomeDirectory("D:\\Homes\\Bea");
expect(isExcludedWorkspacePath("d:\\homes\\bea\\")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("D:\\Homes\\Bea\\cline")).toBe(false);
});
});
it("excludes home and Desktop directories but keeps projects inside them", () => {
expect(isExcludedWorkspacePath("/Users/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/")).toBe(true);
expect(isExcludedWorkspacePath("/home/beatrix")).toBe(true);
expect(isExcludedWorkspacePath("/root")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud")).toBe(true);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Desktop")).toBe(true);
expect(isExcludedWorkspacePath("/Users/beatrix/dev/cline")).toBe(false);
expect(isExcludedWorkspacePath("/Users/beatrix/Desktop/my-app")).toBe(
false,
);
expect(isExcludedWorkspacePath("/home/beatrix/projects")).toBe(false);
expect(isExcludedWorkspacePath("/workspace/cline")).toBe(false);
expect(isExcludedWorkspacePath("C:\\Users\\Saoud\\Cline")).toBe(false);
});
it("filters excluded paths out of session-derived workspaces", () => {
const paths = workspacePathsFromSessions([
{ workspaceRoot: "/projects/app" },
{ workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" },
{ cwd: "/Users/beatrix/Desktop" },
{ cwd: "/Users/beatrix" },
{ cwd: "/projects/tool" },
]);
expect(paths).toEqual(["/projects/app", "/projects/tool"]);
});
it("scrubs excluded paths from the stored catalog while keeping the selection", () => {
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: [
"/projects/one",
"/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip",
"/Users/beatrix",
],
}),
),
).toEqual({
lastWorkspace: "/Users/beatrix/Desktop",
workspaces: ["/projects/one"],
});
expect(
filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]),
).toEqual(["/projects/one"]);
});
});
@@ -9,6 +9,8 @@ export type WorkspaceSelectionStorage = {
export type WorkspacePathSource = {
cwd?: string;
workspaceRoot?: string;
startedAt?: string;
endedAt?: string;
};
export function normalizeWorkspacePath(path: string): string {
@@ -21,6 +23,11 @@ export function normalizeWorkspacePath(path: string): string {
return /^[A-Za-z]:/.test(normalized) ? normalized.toLowerCase() : normalized;
}
/**
* Dedupes paths across groups, keeping the first spelling seen and the
* first-seen position, so callers control the ranking (e.g. session recency)
* through argument order.
*/
export function mergeWorkspacePaths(
...pathGroups: ReadonlyArray<readonly string[]>
): string[] {
@@ -34,15 +41,98 @@ export function mergeWorkspacePaths(
}
}
}
return [...byNormalizedPath.values()].sort((a, b) => a.localeCompare(b));
return [...byNormalizedPath.values()];
}
const POSIX_HOME_OR_DESKTOP_PATTERN =
/^(?:\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/Desktop)?$/;
const WINDOWS_HOME_OR_DESKTOP_PATTERN =
/^[a-z]:[\\/]users[\\/][^\\/]+(?:[\\/]desktop)?$/i;
let hostHomePath = "";
/**
* The webview bundle has no usable `process.env`, so standard home locations
* are matched by the patterns above and the sidecar reports the real host
* home directory through `get_process_context` to cover non-standard ones.
*/
export function registerHostHomeDirectory(path: string): void {
hostHomePath = normalizeWorkspacePath(path);
}
function isRegisteredHomeOrDesktop(normalized: string): boolean {
if (!hostHomePath) {
return false;
}
if (normalized === hostHomePath) {
return true;
}
return (
normalized.startsWith(hostHomePath) &&
/^[\\/]desktop$/i.test(normalized.slice(hostHomePath.length))
);
}
/**
* Sessions can run anywhere (Cline-internal worktrees and plugin installs
* under `.cline`, or a shell's default cwd like the home or Desktop
* directory), but those locations are not projects to offer in the
* workspace catalog. The active workspace root is registered separately,
* so an explicitly opened directory still shows while selected.
*/
export function isExcludedWorkspacePath(path: string): boolean {
const normalized = normalizeWorkspacePath(path);
if (!normalized) {
return false;
}
if (normalized.split(/[\\/]/).includes(".cline")) {
return true;
}
return (
isRegisteredHomeOrDesktop(normalized) ||
POSIX_HOME_OR_DESKTOP_PATTERN.test(normalized) ||
WINDOWS_HOME_OR_DESKTOP_PATTERN.test(normalized)
);
}
export function filterWorkspacePaths(paths: readonly string[]): string[] {
return paths.filter((path) => !isExcludedWorkspacePath(path));
}
/**
* Workspaces with the most recent session activity come first; paths whose
* sessions carry no parseable timestamp fall back to alphabetical order at
* the end.
*/
export function workspacePathsFromSessions(
sessions: readonly WorkspacePathSource[],
): string[] {
return mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
);
const lastActivityByPath = new Map<string, number>();
for (const session of sessions) {
const normalized = normalizeWorkspacePath(
session.workspaceRoot || session.cwd || "",
);
if (!normalized) {
continue;
}
const activity = Date.parse(session.endedAt ?? session.startedAt ?? "");
if (Number.isNaN(activity)) {
continue;
}
const known = lastActivityByPath.get(normalized);
if (known === undefined || activity > known) {
lastActivityByPath.set(normalized, activity);
}
}
return filterWorkspacePaths(
mergeWorkspacePaths(
sessions.map((session) => session.workspaceRoot || session.cwd || ""),
),
).sort((a, b) => {
const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0;
const bTime = lastActivityByPath.get(normalizeWorkspacePath(b)) ?? 0;
return bTime === aTime ? a.localeCompare(b) : bTime - aTime;
});
}
export function parseWorkspaceSelectionStorage(
@@ -67,7 +157,9 @@ export function parseWorkspaceSelectionStorage(
: [];
return {
lastWorkspace,
workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(workspaces, [lastWorkspace]),
),
};
} catch {
return { lastWorkspace: "", workspaces: [] };
@@ -98,9 +190,9 @@ export function writeWorkspaceSelectionToWindow(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace: value.lastWorkspace.trim(),
workspaces: mergeWorkspacePaths(value.workspaces, [
value.lastWorkspace,
]),
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
),
}),
);
} catch {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+5
View File
@@ -461,6 +461,11 @@ export {
createTeamName,
DefaultRuntimeBuilder,
} from "./runtime/orchestration/runtime-builder";
export {
OAuthReauthRequiredError,
type RuntimeOAuthResolution,
RuntimeOAuthTokenManager,
} from "./runtime/orchestration/runtime-oauth-token-manager";
export type {
BuiltRuntime,
RuntimeBuilder,