mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
desktop: native-feel polish, render-path performance, and transition fixes (#13028)
* desktop: native-feel polish and render-path performance fixes - Suppress the WebView browser context menu on app chrome (keep it for editable fields and active text selections) - Make UI chrome unselectable app-wide; opt chat messages, markdown, code, diffs, and error banners back into text selection - Contain overscroll so inner scrollers don't rubber-band the window - Lazy-load Settings/Sessions/Onboarding/Diff views out of the entry chunk - Memoize ChatInputBar and AgentHeader; stabilize their props in the chat pane so stream flushes only re-render the affected message bubble - Stop refocusing the composer textarea on every keystroke (caret flicker) - Cache slash commands across menu opens (stale-while-revalidate) - Avoid rebuilding reversed message arrays and ask-question JSX per render - Drop core info/debug console logging on the streaming hot path behind a cline:debug-logs opt-in; remove leftover [webview:delete] debug logs - SearchCombobox (provider/model picker): Escape closes and restores focus - Remove unused @vercel/analytics, recharts, embla-carousel deps and the unused chart/carousel UI components * desktop: surface failed-turn errors instead of leaving the chat blank On a failed run the runtime reports its error string in result.text. The webview rendered that as an assistant bubble, which the canonical history rehydration then wiped (the failed turn is never persisted), so provider errors like a retired model id left the user staring at a silently empty chat. Route failed-turn text to a persistent error-role message added after rehydration instead. * desktop: fade the welcome/conversation swap instead of hard-cutting Sending the first message replaced the hero layout with the message grid in a single commit, which read as a white flash. A 180ms enter animation now plays when either side becomes visible; disabled under prefers-reduced-motion. * desktop: render new-chat panes instantly from the last catalog load Clicking + remounts ChatThreadPane, which refused to render until the provider catalog (a large fetch) and workspace list resolved again — about a second of blank pane plus boot spinner on every new chat. Seed remounts from a module-level snapshot of the last successful load; the mount effect still refreshes both in the background. * desktop: invalidate the provider-catalog snapshot with the cache Seeding remounted chat panes from the last catalog load left a window where a pane created right after a credential change could act on the old keys. The snapshot now lives in the catalog module and is dropped by invalidateProviderCatalogCache(), so credential edits force the next remount to wait for fresh data.
This commit is contained in:
@@ -70,7 +70,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.11",
|
||||
@@ -82,7 +81,6 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
|
||||
@@ -14,11 +14,42 @@
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
/* Keep scroll momentum inside the app: without this, hitting the end
|
||||
* of an inner scroller rubber-bands the whole window like a web page. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native-app chrome: labels, buttons, and panel text must not highlight
|
||||
* when the user drags across them. Content the user genuinely reads and
|
||||
* copies — chat messages, code, diffs, form fields — opts back in below.
|
||||
*/
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Selectable content surfaces: chat message bodies (incl. markdown and
|
||||
* code blocks), reasoning/tool panels, and diff text. */
|
||||
.cline-chat-message-content,
|
||||
.cline-markdown,
|
||||
.cline-chat-selectable,
|
||||
pre,
|
||||
code {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -103,12 +134,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Softens the welcome <-> conversation swap: the hero and the message grid
|
||||
* replace each other in a single commit, which otherwise reads as a hard
|
||||
* white flash. Plays whenever the element (re)becomes visible — display:none
|
||||
* resets animations, so toggling Tailwind's `hidden` re-triggers it. */
|
||||
@keyframes cline-view-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: cline-view-enter 180ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-streaming-title {
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DesktopErrorTelemetry } from "@/components/desktop-error-telemetry";
|
||||
import { NativeShell } from "@/components/native-shell";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { HUB_THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
@@ -47,6 +48,7 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className="h-full min-h-screen font-sans antialiased">
|
||||
<DesktopErrorTelemetry />
|
||||
<NativeShell />
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus } from "lucide-react";
|
||||
import { ImagePlus, Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -30,18 +31,10 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages";
|
||||
import { DiffView } from "@/components/views/chat/diff-view";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
|
||||
import {
|
||||
type OnboardingStep,
|
||||
OnboardingView,
|
||||
} from "@/components/views/onboarding/onboarding-view";
|
||||
import { SessionsView } from "@/components/views/sessions/sessions-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
|
||||
import type { SettingsSection } from "@/components/views/settings/sections";
|
||||
import { AccountProvider } from "@/contexts/account-context";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import { useAppUpdate } from "@/hooks/use-app-update";
|
||||
@@ -72,7 +65,9 @@ import {
|
||||
import { isProviderConnected } from "@/lib/provider-connection";
|
||||
import {
|
||||
fetchProviderCatalog,
|
||||
readProviderCatalogSnapshot,
|
||||
subscribeToProviderCatalogInvalidation,
|
||||
writeProviderCatalogSnapshot,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import {
|
||||
buildSessionAgentActivity,
|
||||
@@ -93,6 +88,51 @@ import {
|
||||
writeWorkspaceSelectionToWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
|
||||
// Lazily loaded views: none of these are needed for the first paint of the
|
||||
// chat shell, so keeping them out of the entry chunk shortens app startup.
|
||||
// Each fallback paints the same background as the loaded view so switching
|
||||
// never flashes.
|
||||
const viewLoading = () => (
|
||||
<div className="flex h-full flex-1 items-center justify-center bg-background">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/settings/settings-view").then(
|
||||
(module) => module.SettingsView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
const SessionsView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/sessions/sessions-view").then(
|
||||
(module) => module.SessionsView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
const OnboardingView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/onboarding/onboarding-view").then(
|
||||
(module) => module.OnboardingView,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="h-full w-full bg-background" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const DiffView = dynamic(
|
||||
() =>
|
||||
import("@/components/views/chat/diff-view").then(
|
||||
(module) => module.DiffView,
|
||||
),
|
||||
{ loading: viewLoading, ssr: false },
|
||||
);
|
||||
|
||||
function makeThreadId(): string {
|
||||
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
@@ -445,6 +485,13 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
// "+ new chat" remounts ChatThreadPane with a fresh thread id, and the pane
|
||||
// blocks on the provider catalog (a large fetch) before rendering anything.
|
||||
// Seed remounts from the last successful load (kept in the catalog module,
|
||||
// where credential changes invalidate it) so only the first-ever mount shows
|
||||
// the boot spinner; the effect still refreshes in the background.
|
||||
let workspacesLoadedOnce = false;
|
||||
|
||||
function ChatThreadPane({
|
||||
threadId,
|
||||
historySession,
|
||||
@@ -538,10 +585,14 @@ function ChatThreadPane({
|
||||
const [gitBranch, setGitBranch] = useState("no-git");
|
||||
const [providerCredentials, setProviderCredentials] = useState<
|
||||
Record<string, { apiKey: string }>
|
||||
>({});
|
||||
>(() => readProviderCatalogSnapshot()?.credentials ?? {});
|
||||
const [providerModelContextWindows, setProviderModelContextWindows] =
|
||||
useState<Record<string, Record<string, number>>>({});
|
||||
const [providersLoaded, setProvidersLoaded] = useState(false);
|
||||
useState<Record<string, Record<string, number>>>(
|
||||
() => readProviderCatalogSnapshot()?.contextWindows ?? {},
|
||||
);
|
||||
const [providersLoaded, setProvidersLoaded] = useState(
|
||||
() => readProviderCatalogSnapshot() !== null,
|
||||
);
|
||||
// null = unknown (catalog unavailable): never nag in that case.
|
||||
const [hasConnectedProvider, setHasConnectedProvider] = useState<
|
||||
boolean | null
|
||||
@@ -556,7 +607,9 @@ function ChatThreadPane({
|
||||
),
|
||||
),
|
||||
);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(false);
|
||||
const [workspacesLoaded, setWorkspacesLoaded] = useState(
|
||||
() => workspacesLoadedOnce,
|
||||
);
|
||||
const hydratedSessionRef = useRef<string | null>(null);
|
||||
const resetThreadRef = useRef<string | null>(null);
|
||||
const manualTitleSessionRef = useRef<string | null>(null);
|
||||
@@ -627,6 +680,10 @@ function ChatThreadPane({
|
||||
}
|
||||
nextContextWindows[id] = contextWindows;
|
||||
}
|
||||
writeProviderCatalogSnapshot({
|
||||
credentials: next,
|
||||
contextWindows: nextContextWindows,
|
||||
});
|
||||
setProviderCredentials(next);
|
||||
setProviderModelContextWindows(nextContextWindows);
|
||||
setHasConnectedProvider(anyConnected);
|
||||
@@ -778,6 +835,7 @@ function ChatThreadPane({
|
||||
: merged;
|
||||
});
|
||||
} finally {
|
||||
workspacesLoadedOnce = true;
|
||||
setWorkspacesLoaded(true);
|
||||
}
|
||||
},
|
||||
@@ -1190,11 +1248,61 @@ function ChatThreadPane({
|
||||
[handleAttachFiles],
|
||||
);
|
||||
|
||||
const attachmentList = pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
isImage: file.type.startsWith("image/"),
|
||||
}));
|
||||
const attachmentList = useMemo(
|
||||
() =>
|
||||
pendingAttachments.map((file, index) => ({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${index}`,
|
||||
name: file.name,
|
||||
isImage: file.type.startsWith("image/"),
|
||||
})),
|
||||
[pendingAttachments],
|
||||
);
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
const handleAbort = useCallback(() => {
|
||||
void abort();
|
||||
}, [abort]);
|
||||
const handleModelChange = useCallback(
|
||||
(nextModel: string) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
),
|
||||
[setConfig],
|
||||
);
|
||||
const handleModeToggle = useCallback(
|
||||
() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
})),
|
||||
[setConfig],
|
||||
);
|
||||
const handleProviderChange = useCallback(
|
||||
(nextProvider: string) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
}),
|
||||
[providerCredentials, setConfig],
|
||||
);
|
||||
const handleSendPrompt = useCallback(
|
||||
(prompt: string) => void handleSend(prompt),
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const firstUserMessage = messages.find(
|
||||
(message) => message.role === "user",
|
||||
@@ -1208,6 +1316,18 @@ function ChatThreadPane({
|
||||
: (visibleHistorySession?.prompt ?? firstUserMessage),
|
||||
});
|
||||
const hasDiffChanges = summary.additions + summary.deletions > 0;
|
||||
const headerDiff = useMemo(
|
||||
() => ({
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}),
|
||||
[summary.additions, summary.deletions],
|
||||
);
|
||||
const handleOpenDiff = useCallback(() => {
|
||||
if (summary.additions + summary.deletions > 0) {
|
||||
setShowDiffView(true);
|
||||
}
|
||||
}, [summary.additions, summary.deletions]);
|
||||
|
||||
const activeSessionForTitle = hideDeletedSessionUi
|
||||
? null
|
||||
@@ -1351,49 +1471,20 @@ function ChatThreadPane({
|
||||
const composer = (
|
||||
<ChatInputBar
|
||||
attachments={attachmentList}
|
||||
onAbort={() => void abort()}
|
||||
onAbort={handleAbort}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onListGitBranches={listGitBranches}
|
||||
onRemoveAttachment={(id) => {
|
||||
setPendingAttachments((prev) =>
|
||||
prev.filter((file, index) => {
|
||||
const fileId = `${file.name}:${file.size}:${file.lastModified}:${index}`;
|
||||
return fileId !== id;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSwitchGitBranch={switchGitBranch}
|
||||
onModelChange={(nextModel) =>
|
||||
setConfig((prev) =>
|
||||
prev.model === nextModel ? prev : { ...prev, model: nextModel },
|
||||
)
|
||||
}
|
||||
onModeToggle={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mode: prev.mode === "plan" ? "act" : "plan",
|
||||
}))
|
||||
}
|
||||
onModelChange={handleModelChange}
|
||||
onModeToggle={handleModeToggle}
|
||||
onPromptInputChange={handlePromptInputChange}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onSteerPromptInQueue={steerPromptInQueue}
|
||||
onEditPromptInQueue={updatePromptInQueue}
|
||||
onRemovePromptInQueue={handleRemoveQueuedPrompt}
|
||||
onProviderChange={(nextProvider) =>
|
||||
setConfig((prev) => {
|
||||
const selected = providerCredentials[nextProvider];
|
||||
const nextApiKey = selected?.apiKey ?? "";
|
||||
if (prev.provider === nextProvider && prev.apiKey === nextApiKey) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
provider: nextProvider,
|
||||
apiKey: nextApiKey,
|
||||
};
|
||||
})
|
||||
}
|
||||
onSend={(prompt) => void handleSend(prompt)}
|
||||
onProviderChange={handleProviderChange}
|
||||
onSend={handleSendPrompt}
|
||||
gitBranch={gitBranch}
|
||||
model={config.model}
|
||||
modelContextWindow={modelContextWindow}
|
||||
@@ -1437,7 +1528,7 @@ function ChatThreadPane({
|
||||
</div>
|
||||
) : null}
|
||||
{!isWelcomeState ? (
|
||||
<div className="z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
|
||||
<AgentHeader
|
||||
agentActivity={agentActivity}
|
||||
agents={agents}
|
||||
@@ -1450,15 +1541,10 @@ function ChatThreadPane({
|
||||
canEditTitle={Boolean(activeSessionForTitle)}
|
||||
canDeleteSession={Boolean(activeSessionToDelete)}
|
||||
deletingSession={deletingSession}
|
||||
diff={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
diff={headerDiff}
|
||||
onDeleteSession={requestDeleteSession}
|
||||
onNewThread={onNewThread}
|
||||
onOpenDiff={() => {
|
||||
if (hasDiffChanges) setShowDiffView(true);
|
||||
}}
|
||||
onOpenDiff={handleOpenDiff}
|
||||
onRenameTitle={handleRenameTitle}
|
||||
renamingTitle={renamingSession}
|
||||
status={status}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import { type CSSProperties, memo, useEffect, useMemo, useState } from "react";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import {
|
||||
agentEntryState,
|
||||
@@ -62,7 +62,7 @@ type AgentHeaderProps = {
|
||||
onOpenParentSession?: (parentSessionId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AgentHeader({
|
||||
function AgentHeaderImpl({
|
||||
title,
|
||||
canEditTitle,
|
||||
renamingTitle,
|
||||
@@ -279,6 +279,11 @@ export function AgentHeader({
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: the header sits above the streaming conversation and would
|
||||
// otherwise re-render on every stream flush; its props are kept
|
||||
// referentially stable by the chat pane.
|
||||
export const AgentHeader = memo(AgentHeaderImpl);
|
||||
|
||||
/**
|
||||
* Route from a child agent run back to the session that spawned it, in the
|
||||
* header slot the "new session" button occupies elsewhere.
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "@/components/views/settings/settings-view";
|
||||
} from "@/components/views/settings/sections";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import type {
|
||||
SessionThread,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
function isEditable(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest("input, textarea, [contenteditable='true']"));
|
||||
}
|
||||
|
||||
function hasTextSelection(): boolean {
|
||||
const selection = window.getSelection();
|
||||
return Boolean(selection && !selection.isCollapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppresses the WebView's built-in browser context menu (Back / Forward /
|
||||
* Reload / Inspect Element) so right-clicking app chrome behaves like a
|
||||
* native app instead of a web page.
|
||||
*
|
||||
* Radix context menus (e.g. on sidebar sessions) attach their own
|
||||
* `contextmenu` handlers on their triggers and call `preventDefault`
|
||||
* themselves, so they keep working. Editable fields and active text
|
||||
* selections keep the default menu for spellcheck / copy / paste.
|
||||
*/
|
||||
export function NativeShell() {
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (isEditable(event.target) || hasTextSelection()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
// Non-capture: runs after component-level handlers, so custom menus
|
||||
// that already prevented default are unaffected either way.
|
||||
window.addEventListener("contextmenu", handleContextMenu);
|
||||
return () => window.removeEventListener("contextmenu", handleContextMenu);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"section"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -1,349 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cssText = Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return <style>{cssText}</style>;
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import {
|
||||
ArrowUp,
|
||||
Brain,
|
||||
CircleStop,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -81,6 +74,11 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ name: "team", description: "Start the task with an agent team" },
|
||||
];
|
||||
|
||||
// Last known user commands, kept across composer instances so reopening the
|
||||
// slash menu paints instantly (stale-while-revalidate); the fetch that
|
||||
// follows still picks up newly installed skills and workflows.
|
||||
let cachedSlashCommands: SlashCommand[] | null = null;
|
||||
|
||||
export function buildUserInstructionSlashCommands(
|
||||
response: UserInstructionConfigResponse,
|
||||
): SlashCommand[] {
|
||||
@@ -282,7 +280,7 @@ type ChatInputBarProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export function ChatInputBar({
|
||||
function ChatInputBarImpl({
|
||||
variant = "conversation",
|
||||
status,
|
||||
provider,
|
||||
@@ -409,7 +407,7 @@ export function ChatInputBar({
|
||||
: null;
|
||||
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
|
||||
BUILTIN_SLASH_COMMANDS,
|
||||
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
|
||||
);
|
||||
const [slashLoading, setSlashLoading] = useState(false);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
@@ -453,18 +451,22 @@ export function ChatInputBar({
|
||||
}
|
||||
}, [modelSupportsReasoning, onReasoningChange, reasoningEffort, thinking]);
|
||||
|
||||
// Focus the composer on mount/variant change and when text is injected
|
||||
// from outside (quick actions, queue undo). Deliberately NOT on every
|
||||
// keystroke: refocusing an already-focused textarea per keypress causes
|
||||
// caret flicker and forced layout while typing.
|
||||
useEffect(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) return;
|
||||
if (!input || document.activeElement === input) return;
|
||||
// The textarea is controlled, so its live value mirrors promptInput;
|
||||
// reading it here keeps keystrokes out of this effect's dependencies.
|
||||
if (
|
||||
variant === "conversation" ||
|
||||
(variant === "welcome" &&
|
||||
promptInput.trim().length > 0 &&
|
||||
document.activeElement !== input)
|
||||
(variant === "welcome" && input.value.trim().length > 0)
|
||||
) {
|
||||
input.focus();
|
||||
}
|
||||
}, [promptInput, variant]);
|
||||
}, [variant]);
|
||||
|
||||
useEffect(() => {
|
||||
setCursorIndex((prev) => Math.min(prev, promptInput.length));
|
||||
@@ -573,15 +575,18 @@ export function ChatInputBar({
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSlashLoading(true);
|
||||
// Only show the loading row when there is nothing cached to show.
|
||||
setSlashLoading(cachedSlashCommands === null);
|
||||
desktopClient
|
||||
.invoke<UserInstructionConfigResponse>("list_user_instruction_configs")
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setSlashCommands([
|
||||
const next = [
|
||||
...BUILTIN_SLASH_COMMANDS,
|
||||
...buildUserInstructionSlashCommands(response),
|
||||
]);
|
||||
];
|
||||
cachedSlashCommands = next;
|
||||
setSlashCommands(next);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep built-in commands on error.
|
||||
@@ -736,7 +741,7 @@ export function ChatInputBar({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-[border-color,box-shadow] focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome" &&
|
||||
"min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
|
||||
)}
|
||||
@@ -1055,6 +1060,11 @@ export function ChatInputBar({
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: the chat pane re-renders on every stream flush (message deltas,
|
||||
// status, usage); the composer only cares about the props it receives, which
|
||||
// the pane keeps referentially stable.
|
||||
export const ChatInputBar = memo(ChatInputBarImpl);
|
||||
|
||||
// Memoized: the selectors load/hold the full provider-model catalog, so they
|
||||
// should not re-render for every keystroke in the composer textarea.
|
||||
const ModelSelector = memo(function ModelSelector({
|
||||
|
||||
@@ -307,17 +307,34 @@ function ChatMessagesImpl({
|
||||
onForkSession,
|
||||
}: ChatMessagesProps) {
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "error");
|
||||
// Scanned from the tail without copying: this component re-renders on
|
||||
// every stream flush, so a reversed array clone per render would churn
|
||||
// with transcript length.
|
||||
const { lastConversationMessage, lastErrorMessage } = useMemo(() => {
|
||||
let conversationMessage: ChatMessage | undefined;
|
||||
let errorMessage: ChatMessage | undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (!conversationMessage && message.role !== "status") {
|
||||
conversationMessage = message;
|
||||
}
|
||||
if (!errorMessage && message.role === "error") {
|
||||
errorMessage = message;
|
||||
}
|
||||
if (conversationMessage && errorMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
lastConversationMessage: conversationMessage,
|
||||
lastErrorMessage: errorMessage,
|
||||
};
|
||||
}, [messages]);
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
// Core reports "running" as soon as the turn is dispatched, well before the
|
||||
// first streamed chunk arrives, so keep the thinking indicator up until the
|
||||
// model produces output (or something else needs the user's attention).
|
||||
const lastConversationMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== "status");
|
||||
const isAwaitingFirstOutput =
|
||||
status === "running" &&
|
||||
!streamingMessageId &&
|
||||
@@ -370,6 +387,31 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
|
||||
// Built once per pendingAskQuestions change instead of per render: the
|
||||
// list re-renders on every stream flush and these rows carry JSX.
|
||||
const askQuestionItems = useMemo(
|
||||
() =>
|
||||
pendingAskQuestions.map((item) => ({
|
||||
description: (
|
||||
<>
|
||||
Request {item.requestId}
|
||||
{item.context?.iteration != null
|
||||
? ` · Iteration ${item.context.iteration}`
|
||||
: ""}
|
||||
</>
|
||||
),
|
||||
id: item.requestId,
|
||||
meta: (
|
||||
<>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{formatApprovalTimestamp(item.createdAt)}
|
||||
</>
|
||||
),
|
||||
options: item.options,
|
||||
question: item.question,
|
||||
})),
|
||||
[pendingAskQuestions],
|
||||
);
|
||||
const previousTimestampByMessage = useMemo(
|
||||
() => buildPreviousTimestampMap(messages),
|
||||
[messages],
|
||||
@@ -580,6 +622,14 @@ function ChatMessagesImpl({
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Stable identity so memoized MessageBubbles skip re-rendering on stream
|
||||
// flushes; an inline lambda here would invalidate every bubble per flush.
|
||||
const requestRestoreCheckpoint = useCallback(
|
||||
(messageId: string, runCount: number) => {
|
||||
setCheckpointConfirmation({ messageId, runCount });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExpandImage = useCallback(
|
||||
(image: ChatMessageImage) => {
|
||||
@@ -655,28 +705,10 @@ function ChatMessagesImpl({
|
||||
requestErrors={toolApprovalErrors}
|
||||
/>
|
||||
) : null}
|
||||
{pendingAskQuestions.length > 0 ? (
|
||||
{askQuestionItems.length > 0 ? (
|
||||
<AgentAskQuestion
|
||||
errors={askQuestionErrors}
|
||||
items={pendingAskQuestions.map((item) => ({
|
||||
description: (
|
||||
<>
|
||||
Request {item.requestId}
|
||||
{item.context?.iteration != null
|
||||
? ` · Iteration ${item.context.iteration}`
|
||||
: ""}
|
||||
</>
|
||||
),
|
||||
id: item.requestId,
|
||||
meta: (
|
||||
<>
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{formatApprovalTimestamp(item.createdAt)}
|
||||
</>
|
||||
),
|
||||
options: item.options,
|
||||
question: item.question,
|
||||
}))}
|
||||
items={askQuestionItems}
|
||||
onAnswer={handleAskQuestionAnswer}
|
||||
pendingAnswers={askQuestionActions}
|
||||
/>
|
||||
@@ -724,13 +756,7 @@ function ChatMessagesImpl({
|
||||
editError={editErrors[message.id]}
|
||||
editPending={editingMessageId === message.id}
|
||||
onRestoreCheckpoint={
|
||||
onRestoreCheckpoint
|
||||
? (messageId, runCount) =>
|
||||
setCheckpointConfirmation({
|
||||
messageId,
|
||||
runCount,
|
||||
})
|
||||
: undefined
|
||||
onRestoreCheckpoint ? requestRestoreCheckpoint : undefined
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
@@ -815,7 +841,7 @@ function ChatMessagesImpl({
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
<div className="mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -323,7 +323,7 @@ function DiffHunk({ hunk }: { hunk: SessionFileDiff["hunks"][number] }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
|
||||
<div className="cline-chat-selectable overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
|
||||
{oldLineEntries.map((entry) => (
|
||||
<div className="flex bg-destructive/10" key={entry.key}>
|
||||
<span className="hidden w-12 shrink-0 select-none items-center justify-end border-r border-border px-2 text-muted-foreground/40 sm:flex">
|
||||
|
||||
@@ -88,7 +88,7 @@ export function WelcomeScreen({
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="cline-view-enter">
|
||||
<AgentHeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
@@ -104,11 +104,15 @@ export function WelcomeScreen({
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={active ? "hidden" : "h-full min-h-0 overflow-hidden"}
|
||||
className={
|
||||
active
|
||||
? "hidden"
|
||||
: "cline-view-enter h-full min-h-0 overflow-hidden"
|
||||
}
|
||||
key="conversation-body"
|
||||
>
|
||||
{body}
|
||||
@@ -126,7 +130,7 @@ export function WelcomeScreen({
|
||||
{active ? (
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
className="mt-11"
|
||||
className="cline-view-enter mt-11"
|
||||
onSelect={(action) => onStartChat(action.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Settings navigation constants, split from settings-view.tsx so the sidebar
|
||||
* (always mounted) can reference section names without pulling the entire
|
||||
* settings module graph — providers, MCP, marketplace, schedules — into the
|
||||
* initial chat bundle. The heavy views load on demand via next/dynamic.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Models",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Skills",
|
||||
"MCP",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
"Tools",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof SETTINGS_SECTIONS)[number]
|
||||
| (typeof CUSTOMIZATION_SECTIONS)[number];
|
||||
@@ -43,34 +43,17 @@ import {
|
||||
ProviderListContent,
|
||||
} from "./provider-list-view";
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import type { SettingsSection } from "./sections";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Settings nav categories
|
||||
// -----------------------------------------------------------
|
||||
// Nav categories live in ./sections so the always-mounted sidebar can import
|
||||
// them without pulling this module graph into the initial bundle.
|
||||
export {
|
||||
CUSTOMIZATION_SECTIONS,
|
||||
SETTINGS_SECTIONS,
|
||||
type SettingsSection,
|
||||
} from "./sections";
|
||||
|
||||
export const SETTINGS_SECTIONS = [
|
||||
"General",
|
||||
"Models",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
// Mirrors the Cline Hub dashboard's Customizations nav group.
|
||||
export const CUSTOMIZATION_SECTIONS = [
|
||||
"Plugins",
|
||||
"Skills",
|
||||
"MCP",
|
||||
"Hooks",
|
||||
"Rules",
|
||||
"Agents",
|
||||
"Tools",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection =
|
||||
| (typeof SETTINGS_SECTIONS)[number]
|
||||
| (typeof CUSTOMIZATION_SECTIONS)[number];
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
|
||||
@@ -141,18 +141,24 @@ describe("useChatSession", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
// On success result.text is assistant content; on a failed run it is
|
||||
// the runtime's error string and must surface as an error message
|
||||
// (assistant bubbles for unpersisted turns are wiped by rehydration).
|
||||
{
|
||||
finishReason: "completed",
|
||||
expectedRole: "assistant",
|
||||
expected:
|
||||
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]',
|
||||
},
|
||||
{
|
||||
finishReason: "error",
|
||||
expectedRole: "error",
|
||||
expected:
|
||||
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]',
|
||||
},
|
||||
])("handles schema-like assistant text for $finishReason responses", async ({
|
||||
finishReason,
|
||||
expectedRole,
|
||||
expected,
|
||||
}) => {
|
||||
const schemaLikeText =
|
||||
@@ -189,10 +195,13 @@ describe("useChatSession", () => {
|
||||
|
||||
await act(async () => current.sendPrompt("Explain this validation error"));
|
||||
|
||||
// Error-role content goes through the turn-failure reporter, which
|
||||
// wraps the detail in user-facing copy — assert containment, not
|
||||
// equality, so the schema text is preserved either way.
|
||||
expect(
|
||||
current.messages.findLast((message) => message.role === "assistant")
|
||||
current.messages.findLast((message) => message.role === expectedRole)
|
||||
?.content,
|
||||
).toBe(expected);
|
||||
).toContain(expected);
|
||||
});
|
||||
|
||||
it("publishes the first user message before cold session startup resolves", async () => {
|
||||
@@ -1347,9 +1356,9 @@ describe("useChatSession", () => {
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
expect(
|
||||
current.messages.some((message) => message.role === "error"),
|
||||
).toBe(true);
|
||||
expect(current.messages.some((message) => message.role === "error")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveSend?.({ ok: true });
|
||||
@@ -1476,9 +1485,9 @@ describe("useChatSession", () => {
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
expect(
|
||||
current.messages.some((message) => message.role === "error"),
|
||||
).toBe(true);
|
||||
expect(current.messages.some((message) => message.role === "error")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// A later turn appends new messages after the failure bubble; its
|
||||
// hydration must not re-pin the stale error to the bottom of the
|
||||
|
||||
@@ -254,15 +254,35 @@ const LOG_DISPATCH: Record<string, typeof console.info> = {
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
// Core streams a steady flow of info/debug log chunks during a session.
|
||||
// Serializing them through the console on the streaming hot path costs real
|
||||
// CPU (DevTools keeps every entry alive), so anything below warn is dropped
|
||||
// unless the user opts in via `localStorage.setItem("cline:debug-logs", "1")`.
|
||||
let verboseCoreLogs: boolean | undefined;
|
||||
|
||||
function shouldLogVerboseCoreLogs(): boolean {
|
||||
if (verboseCoreLogs === undefined) {
|
||||
try {
|
||||
verboseCoreLogs = window.localStorage.getItem("cline:debug-logs") === "1";
|
||||
} catch {
|
||||
verboseCoreLogs = false;
|
||||
}
|
||||
}
|
||||
return verboseCoreLogs;
|
||||
}
|
||||
|
||||
function dispatchCoreLog(chunk: string): void {
|
||||
let parsed: CoreLogChunk | undefined;
|
||||
try {
|
||||
parsed = JSON.parse(chunk) as CoreLogChunk;
|
||||
} catch {
|
||||
console.info("[core]", chunk);
|
||||
if (shouldLogVerboseCoreLogs()) console.info("[core]", chunk);
|
||||
return;
|
||||
}
|
||||
const level = parsed.level?.trim().toLowerCase() || "info";
|
||||
if (level !== "error" && level !== "warn" && !shouldLogVerboseCoreLogs()) {
|
||||
return;
|
||||
}
|
||||
const message = parsed.message?.trim() || chunk;
|
||||
(LOG_DISPATCH[level] ?? console.info)("[core]", message, parsed.metadata);
|
||||
}
|
||||
@@ -1781,7 +1801,12 @@ export function useChatSession() {
|
||||
setStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
const assistantText = (result?.text ?? "").trim();
|
||||
// On a failed run the runtime reports the error string in
|
||||
// result.text — it is not assistant content and must not be
|
||||
// rendered as an assistant bubble (canonical rehydration would
|
||||
// silently wipe it, leaving the user with a blank chat).
|
||||
const isErrorResult = result?.finishReason === "error";
|
||||
const assistantText = isErrorResult ? "" : (result?.text ?? "").trim();
|
||||
const fallbackAssistantTurn = extractAssistantTurnDataFromRpcMessages(
|
||||
result?.messages,
|
||||
);
|
||||
@@ -1952,12 +1977,18 @@ export function useChatSession() {
|
||||
if (abortedRef.current) {
|
||||
setStatus("cancelled");
|
||||
} else if (result?.finishReason === "error") {
|
||||
if (!resolvedAssistantText) {
|
||||
const toolError = Array.isArray(result?.toolCalls)
|
||||
? result.toolCalls.find((c) => c.error)?.error
|
||||
: undefined;
|
||||
appendTurnFailureMessage(activeSessionId, toolError?.trim() ?? "");
|
||||
}
|
||||
// On a failed run result.text is the runtime's error string
|
||||
// (never assistant content — see isErrorResult above), so it
|
||||
// is the best failure detail available. The reporter dedupes
|
||||
// against the chat_done stream path.
|
||||
const runError = (result?.text ?? "").trim();
|
||||
const toolError = Array.isArray(result?.toolCalls)
|
||||
? result.toolCalls.find((c) => c.error)?.error
|
||||
: undefined;
|
||||
appendTurnFailureMessage(
|
||||
activeSessionId,
|
||||
runError || toolError?.trim() || "",
|
||||
);
|
||||
setStatus("failed");
|
||||
} else if (result?.finishReason === "aborted") {
|
||||
setStatus("cancelled");
|
||||
|
||||
@@ -125,11 +125,34 @@ export function subscribeToProviderCatalogInvalidation(
|
||||
|
||||
export function invalidateProviderCatalogCache(): void {
|
||||
providerCatalogCache = null;
|
||||
// Credentials may have just changed: a pane remounting off the snapshot
|
||||
// must not act on the old keys, so drop it until a fresh load lands.
|
||||
providerCatalogSnapshot = null;
|
||||
for (const listener of providerCatalogInvalidationListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
// "+ new chat" remounts the chat pane, which otherwise blocks its first
|
||||
// paint on a full catalog fetch. The last successful load is kept here (not
|
||||
// in the pane module) so credential changes invalidate it with the cache.
|
||||
export type ProviderCatalogSnapshot = {
|
||||
credentials: Record<string, { apiKey: string }>;
|
||||
contextWindows: Record<string, Record<string, number>>;
|
||||
};
|
||||
|
||||
let providerCatalogSnapshot: ProviderCatalogSnapshot | null = null;
|
||||
|
||||
export function readProviderCatalogSnapshot(): ProviderCatalogSnapshot | null {
|
||||
return providerCatalogSnapshot;
|
||||
}
|
||||
|
||||
export function writeProviderCatalogSnapshot(
|
||||
snapshot: ProviderCatalogSnapshot,
|
||||
): void {
|
||||
providerCatalogSnapshot = snapshot;
|
||||
}
|
||||
|
||||
export async function loadProviderModelCatalog(): Promise<ProviderModelCatalog> {
|
||||
const payload = await fetchProviderCatalog();
|
||||
return buildProviderModelCatalog(payload.providers ?? []);
|
||||
|
||||
@@ -209,7 +209,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.11",
|
||||
@@ -221,7 +220,6 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
@@ -354,7 +352,7 @@
|
||||
},
|
||||
"apps/vscode": {
|
||||
"name": "claude-dev",
|
||||
"version": "4.1.3",
|
||||
"version": "4.1.6",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
@@ -5104,8 +5102,6 @@
|
||||
|
||||
"@cline/code/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||
|
||||
"@cline/code/recharts": ["recharts@2.15.0", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.0", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw=="],
|
||||
|
||||
"@cline/code/sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
|
||||
|
||||
"@cline/code/tw-animate-css": ["tw-animate-css@1.3.3", "", {}, "sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q=="],
|
||||
|
||||
@@ -43,6 +43,7 @@ export function SearchCombobox({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -76,6 +77,11 @@ export function SearchCombobox({
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const closeAndRestoreFocus = () => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cline-ui-search-combobox relative min-w-0"
|
||||
@@ -94,6 +100,7 @@ export function SearchCombobox({
|
||||
.join(" ")}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
ref={triggerRef}
|
||||
title={displayedValue}
|
||||
type="button"
|
||||
>
|
||||
@@ -113,6 +120,13 @@ export function SearchCombobox({
|
||||
align === "start" ? "left-0" : "right-0",
|
||||
placement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
].join(" ")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeAndRestoreFocus();
|
||||
}
|
||||
}}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="cline-ui-search-combobox__search-row border-cline-ui-border border-b p-2">
|
||||
|
||||
@@ -68,6 +68,37 @@ describe("SearchCombobox", () => {
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("closes on Escape and returns focus to the trigger", async () => {
|
||||
const onValueChange = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<SearchCombobox
|
||||
ariaLabel="Repository"
|
||||
onValueChange={onValueChange}
|
||||
options={options}
|
||||
value="cline"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const trigger = container.querySelector("button");
|
||||
await act(async () => trigger?.click());
|
||||
const panel = container.querySelector('[role="dialog"]');
|
||||
expect(panel).not.toBeNull();
|
||||
|
||||
const search = container.querySelector("input");
|
||||
await act(async () => {
|
||||
search?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders loading and disabled states", async () => {
|
||||
const onValueChange = vi.fn();
|
||||
const render = (disabled = false, loading = false) =>
|
||||
|
||||
Reference in New Issue
Block a user