mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
656e170d11 | ||
|
|
8d9f63b06e | ||
|
|
dc44e00fe8 | ||
|
|
677bf62a09 | ||
|
|
faa96c5d1e | ||
|
|
4d86b283dc | ||
|
|
adf029e8bd | ||
|
|
c85a0be86a |
@@ -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]
|
||||
|
||||
@@ -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% {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+400
@@ -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 |
Binary file not shown.
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user