mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7cddb9d9 | ||
|
|
55e169f1f8 | ||
|
|
c227e1ae36 | ||
|
|
d0a0c802af | ||
|
|
f9227fead4 | ||
|
|
bd83980359 | ||
|
|
4d3b161b9a | ||
|
|
255be9ad29 | ||
|
|
77b181a472 | ||
|
|
45476650b7 | ||
|
|
f847b06cfd | ||
|
|
598b3af7eb | ||
|
|
fc936b7305 | ||
|
|
9247dc30bb | ||
|
|
1b499161cc | ||
|
|
0e5cb433b0 | ||
|
|
53a46c309b | ||
|
|
36c78267c6 | ||
|
|
76c30b1c60 | ||
|
|
b496c5a1f3 | ||
|
|
2fc06ce446 | ||
|
|
64c50380f3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -1,5 +1,19 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.47
|
||||
|
||||
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
|
||||
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
|
||||
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
|
||||
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
|
||||
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
|
||||
- Aborting a task no longer risks killing the shared hub daemon
|
||||
- Connector status delivery failures are no longer fatal to the turn
|
||||
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
|
||||
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
|
||||
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
|
||||
- Updated the bundled model catalog (from SDK v0.0.66)
|
||||
|
||||
## 3.0.46
|
||||
|
||||
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
|
||||
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
|
||||
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
|
||||
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
|
||||
| `--kanban` | Run the external `kanban` app |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.46",
|
||||
"version": "3.0.47",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { relative, sep } from "node:path";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createProgram } from "./program";
|
||||
|
||||
/** Render an absolute path under `home` the way help text does: `~/...`. */
|
||||
function tildePath(absolutePath: string, home: string): string {
|
||||
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
|
||||
}
|
||||
|
||||
describe("root option help text", () => {
|
||||
const FAKE_HOME = "/home/cline-help-test";
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeAll(() => {
|
||||
// Pin the resolver inputs so the defaults below are the true defaults
|
||||
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
|
||||
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
setHomeDir(FAKE_HOME);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the actual resolver defaults for --config and --data-dir", () => {
|
||||
// A wide help width keeps each option description on one line so the
|
||||
// full default text can be matched.
|
||||
const help = createProgram()
|
||||
.configureHelp({ helpWidth: 500 })
|
||||
.helpInformation();
|
||||
|
||||
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
|
||||
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
|
||||
|
||||
// Sanity-check the resolvers themselves so the assertions below can't
|
||||
// silently drift along with a resolver regression.
|
||||
expect(configDefault).toBe("~/.cline");
|
||||
expect(dataDirDefault).toBe("~/.cline/data");
|
||||
|
||||
expect(help).toContain(
|
||||
`Configuration directory (default: ${configDefault})`,
|
||||
);
|
||||
expect(help).toContain(
|
||||
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"--acp",
|
||||
"Run in Agent Client Protocol (ACP) mode for editor integration",
|
||||
)
|
||||
.option(
|
||||
"--config <path>",
|
||||
"Configuration directory (default: ~/.cline/data/settings)",
|
||||
)
|
||||
.option("--config <path>", "Configuration directory (default: ~/.cline)")
|
||||
.option(
|
||||
"--data-dir <path>",
|
||||
"Use isolated local state at this directory path (default: ~/.cline)",
|
||||
"Use isolated local state at this directory path (default: ~/.cline/data)",
|
||||
)
|
||||
.option(
|
||||
"--hooks-dir <path>",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- New system tray icon showing app status and how many agent sessions are currently running.
|
||||
- Session history is now paginated in ten-session pages, fetching older history only when you reach the end.
|
||||
- You can favorite sessions, and sessions are now ordered by most recent activity with consistent status dot colors across views.
|
||||
- Subagent and teammate runs from a session now show up in the app with their status and results.
|
||||
- Chat polish: tool-specific icons on tool disclosures, elapsed thinking time and restyled reasoning sections, aligned timestamps, and message actions that no longer shift the layout while scrolling stays anchored to the conversation viewport.
|
||||
- Free Cline models are now supported and labeled "(free)" in model pickers, with a clear message — including reset time — when you hit the free-tier limit.
|
||||
- Fixed the China/international endpoint toggles for Qwen, Moonshot, Z AI, and MiniMax being ignored, which silently routed regional users to the wrong host.
|
||||
- Fixed tool calls failing when a model emitted a line number as a string (e.g. `insert_line: "3"`), forcing the agent to waste a round trip retrying.
|
||||
- Refreshed the bundled provider and model catalog.
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -1282,6 +1282,44 @@ export async function handleCommand(
|
||||
if (liveSession) liveSession.title = title;
|
||||
return true;
|
||||
}
|
||||
if (command === "update_chat_session_metadata") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
const patch = args?.metadata;
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
throw new Error("metadata patch is required");
|
||||
}
|
||||
// updateSession replaces metadata wholesale in both the session row and
|
||||
// the manifest, so merge over what each already holds. A null value
|
||||
// removes the key, which is how callers clear a flag.
|
||||
const store = new SqliteSessionStore();
|
||||
const asRecord = (value: unknown): JsonRecord =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: {};
|
||||
const existing = store.get(sessionId);
|
||||
const merged: JsonRecord = {
|
||||
...asRecord(readSessionManifest(sessionId)?.metadata),
|
||||
...asRecord(existing?.metadata),
|
||||
};
|
||||
for (const [key, value] of Object.entries(patch as JsonRecord)) {
|
||||
if (value === null) delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
const backend = await resolveSessionBackend({ backendMode: "local" });
|
||||
const result = await backend.updateSession({ sessionId, metadata: merged });
|
||||
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
|
||||
// Annotating a session is not session activity. updateSession stamps
|
||||
// updated_at, which clients sort and label rows by, so a favorite would
|
||||
// otherwise make an old session look like it just ran.
|
||||
if (existing?.updatedAt) {
|
||||
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
||||
existing.updatedAt,
|
||||
sessionId,
|
||||
]);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
if (command === "delete_chat_session" || command === "delete_cli_session") {
|
||||
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
|
||||
if (!sessionId) throw new Error("session id is required");
|
||||
|
||||
@@ -2,6 +2,63 @@ import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("preserves each stored message timestamp across projected blocks", async () => {
|
||||
const sessionId = `timestamp-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_282;
|
||||
const assistantTimestamp = 1_781_041_621_946;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-message",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Question" }],
|
||||
ts: userTimestamp,
|
||||
},
|
||||
{
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Consider it" },
|
||||
{ type: "text", text: "Answer" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-use",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
ts: assistantTimestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "user-message_text_0",
|
||||
createdAt: userTimestamp,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_text_0",
|
||||
createdAt: assistantTimestamp,
|
||||
reasoning: "Consider it",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_tool_use_2",
|
||||
createdAt: assistantTimestamp,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects image content blocks without replacing them with placeholder text", async () => {
|
||||
const sessionId = `image-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
|
||||
@@ -29,6 +29,17 @@ type ChatTurnResult = {
|
||||
|
||||
const nowMs = () => Date.now();
|
||||
|
||||
function resolveMessageCreatedAt(
|
||||
message: JsonRecord,
|
||||
fallbackCreatedAt: number,
|
||||
): number {
|
||||
return (
|
||||
parseU64Value(message.ts) ??
|
||||
parseU64Value(message.createdAt) ??
|
||||
fallbackCreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageMetadata(message: JsonRecord): JsonRecord | undefined {
|
||||
return message.metadata && typeof message.metadata === "object"
|
||||
? (message.metadata as JsonRecord)
|
||||
@@ -333,7 +344,6 @@ export async function readSessionMessages(
|
||||
const out: JsonRecord[] = [];
|
||||
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
|
||||
const pendingToolMessages = new Map<string, [number, string, unknown]>();
|
||||
let nextCreatedAt = baseTs;
|
||||
let userRunCount = 0;
|
||||
|
||||
for (let idx = start; idx < messages.length; idx += 1) {
|
||||
@@ -342,6 +352,7 @@ export async function readSessionMessages(
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
let textMeta = extractMessageUsageMeta(message);
|
||||
const storedMeta = extractStoredMessageMeta(message);
|
||||
if (storedMeta) {
|
||||
@@ -381,7 +392,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
continue;
|
||||
@@ -407,7 +418,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role,
|
||||
content: joined,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textSegmentIndex += 1;
|
||||
@@ -437,7 +448,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson(toolName, input, null, false),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName,
|
||||
hookEventName: "history_tool_use",
|
||||
@@ -477,7 +488,7 @@ export async function readSessionMessages(
|
||||
sessionId,
|
||||
role: "tool",
|
||||
content: buildToolPayloadJson("tool_result", null, result, isError),
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: {
|
||||
toolName: "tool_result",
|
||||
hookEventName: "history_tool_result",
|
||||
@@ -528,7 +539,7 @@ export async function readSessionMessages(
|
||||
role,
|
||||
content: "",
|
||||
images,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
@@ -554,7 +565,7 @@ export async function readSessionMessages(
|
||||
content: "",
|
||||
reasoning: reasoning || undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
createdAt: nextCreatedAt++,
|
||||
createdAt,
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Loader2,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Pin,
|
||||
Plug,
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -94,7 +94,7 @@ import { cn } from "@/lib/utils";
|
||||
type Thread = SessionThread;
|
||||
type AppView = "chat" | "sessions" | "settings";
|
||||
|
||||
const filterOptions = ["All", "Running", "Schedules", "Pinned"] as const;
|
||||
const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
type SidebarSortMode = "time" | "project";
|
||||
type DesktopProcessContext = {
|
||||
@@ -244,6 +244,7 @@ export function AgentSidebar({
|
||||
openThread: openHistoryThread,
|
||||
pendingAction,
|
||||
renameThread,
|
||||
setThreadPinned,
|
||||
threads,
|
||||
unreadSessionIds,
|
||||
} = sessionHistory;
|
||||
@@ -329,7 +330,7 @@ export function AgentSidebar({
|
||||
return filtered.filter((t) => t.status === "running");
|
||||
case "Schedules":
|
||||
return filtered.filter((t) => t.source === SCHEDULED_SESSION_SOURCE);
|
||||
case "Pinned":
|
||||
case "Favorites":
|
||||
return filtered.filter((t) => t.pinned);
|
||||
default:
|
||||
return filtered;
|
||||
@@ -404,6 +405,13 @@ export function AgentSidebar({
|
||||
[forkHistoryThread],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (thread: Thread) => {
|
||||
await setThreadPinned(thread.id, !thread.pinned);
|
||||
},
|
||||
[setThreadPinned],
|
||||
);
|
||||
|
||||
const requestDeleteThread = useCallback((thread: Thread) => {
|
||||
setDeleteConfirmThread(thread);
|
||||
}, []);
|
||||
@@ -533,6 +541,7 @@ export function AgentSidebar({
|
||||
onEditTitleChange={setEditingTitle}
|
||||
onFork={() => void forkThread(thread)}
|
||||
onRename={() => startRenameThread(thread)}
|
||||
onToggleFavorite={() => void toggleFavorite(thread)}
|
||||
pendingAction={
|
||||
pendingAction?.sessionId === thread.id ? pendingAction.action : null
|
||||
}
|
||||
@@ -759,7 +768,7 @@ export function AgentSidebar({
|
||||
.map(threadItem)}
|
||||
{project.threads.length > visibleCount ? (
|
||||
<Button
|
||||
className="pl-2"
|
||||
className="pl-2!"
|
||||
onClick={() =>
|
||||
showMoreForProject(project.id)
|
||||
}
|
||||
@@ -787,7 +796,11 @@ export function AgentSidebar({
|
||||
)}
|
||||
{sortMode === "time" && showTimeShowMore && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
// `pl-0!`: the default button size adds
|
||||
// `has-[>svg]:px-3`, and that modifier beats a plain
|
||||
// `pl-0` on specificity, so the icon child was
|
||||
// re-indenting the row.
|
||||
className="pl-0!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => {
|
||||
const nextCount =
|
||||
@@ -816,7 +829,7 @@ export function AgentSidebar({
|
||||
!searchQuery &&
|
||||
mayHaveMoreSessions && (
|
||||
<Button
|
||||
className="pl-0"
|
||||
className="pl-0!"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadOlderSessions()}
|
||||
type="button"
|
||||
@@ -1003,6 +1016,7 @@ function ThreadItem({
|
||||
onCommitRename,
|
||||
onEditTitleChange,
|
||||
onRename,
|
||||
onToggleFavorite,
|
||||
onFork,
|
||||
onDelete,
|
||||
pendingAction,
|
||||
@@ -1017,6 +1031,7 @@ function ThreadItem({
|
||||
onCommitRename: () => void;
|
||||
onEditTitleChange: (title: string) => void;
|
||||
onRename: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onFork: () => void;
|
||||
onDelete: () => void;
|
||||
pendingAction: "rename" | "fork" | "delete" | null;
|
||||
@@ -1079,7 +1094,10 @@ function ThreadItem({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{thread.pinned ? (
|
||||
<Pin aria-label="Pinned" className="size-3" />
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
className="size-3 fill-current"
|
||||
/>
|
||||
) : statusDotClass ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -1119,9 +1137,11 @@ function ThreadItem({
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<SessionContextMenuContent
|
||||
favorited={Boolean(thread.pinned)}
|
||||
onDelete={onDelete}
|
||||
onFork={onFork}
|
||||
onRename={onRename}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ContextMenu>
|
||||
@@ -1210,12 +1230,16 @@ function EditableSessionTitle({
|
||||
}
|
||||
|
||||
function SessionContextMenuContent({
|
||||
favorited,
|
||||
onRename,
|
||||
onToggleFavorite,
|
||||
onFork,
|
||||
onDelete,
|
||||
pendingAction,
|
||||
}: {
|
||||
favorited: boolean;
|
||||
onRename: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onFork: () => void;
|
||||
onDelete: () => void;
|
||||
pendingAction: "rename" | "fork" | "delete" | null;
|
||||
@@ -1223,6 +1247,10 @@ function SessionContextMenuContent({
|
||||
const pending = pendingAction !== null;
|
||||
return (
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
{favorited ? "Unfavorite" : "Favorite"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={pending} onSelect={onRename}>
|
||||
{pendingAction === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
|
||||
@@ -47,6 +47,53 @@ async function renderMessages(
|
||||
}
|
||||
|
||||
describe("ChatMessages tool disclosures", () => {
|
||||
it.each([
|
||||
["run_commands", "lucide-terminal"],
|
||||
["read_files", "lucide-files"],
|
||||
["search_codebase", "lucide-search-code"],
|
||||
["editor", "lucide-pencil"],
|
||||
["apply_patch", "lucide-pencil"],
|
||||
["ask_question", "lucide-message-circle-question-mark"],
|
||||
["fetch_web_content", "lucide-panels-top-left"],
|
||||
["skills", "lucide-library"],
|
||||
["mcp", "lucide-box"],
|
||||
["plugins", "lucide-blocks"],
|
||||
["submit_and_exit", "lucide-square-arrow-right"],
|
||||
["spawn_agent", "lucide-user"],
|
||||
["spawn-agent", "lucide-user"],
|
||||
["spawn_agent_tool", "lucide-user"],
|
||||
["subagent_subagent", "lucide-user"],
|
||||
["subagent_code_reviewer", "lucide-user"],
|
||||
["team_status", "lucide-users"],
|
||||
["bash", "lucide-terminal"],
|
||||
["file_read", "lucide-files"],
|
||||
["file-read", "lucide-files"],
|
||||
["edit", "lucide-pencil"],
|
||||
["edit_file", "lucide-pencil"],
|
||||
["apply-patch", "lucide-pencil"],
|
||||
["search", "lucide-search-code"],
|
||||
["web-fetch", "lucide-panels-top-left"],
|
||||
["web_fetch", "lucide-panels-top-left"],
|
||||
["unknown_tool", "lucide-wrench"],
|
||||
])("uses the expected icon for %s", async (toolName, iconClass) => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: `tool-icon-${toolName}`,
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName,
|
||||
input: {},
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const icon = container.querySelector(".cline-chat-tool-icon svg");
|
||||
expect(icon?.classList.contains(iconClass)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a detail-less tool summary as static text", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
@@ -64,6 +111,9 @@ describe("ChatMessages tool disclosures", () => {
|
||||
);
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
expect(
|
||||
container.querySelector(".cline-chat-tool")?.classList.contains("my-0"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes and toggles expandable tool details", async () => {
|
||||
@@ -249,6 +299,92 @@ describe("ChatMessages tool disclosures", () => {
|
||||
container.querySelector('button[aria-label="Copy assistant message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("positions hidden message actions outside the message layout", async () => {
|
||||
await renderMessages(
|
||||
[
|
||||
{
|
||||
id: "user-actions",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "User message",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "assistant-actions",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
{ onForkSession: vi.fn() },
|
||||
);
|
||||
|
||||
const userMessage = container.querySelector(
|
||||
'.cline-chat-message[data-role="user"]',
|
||||
);
|
||||
const userActions = userMessage?.querySelector(
|
||||
":scope > .cline-chat-message-actions",
|
||||
);
|
||||
const assistantMessage = container.querySelector(
|
||||
'.cline-chat-message[data-role="assistant"]',
|
||||
);
|
||||
const assistantActions = assistantMessage?.querySelector(
|
||||
":scope > .cline-chat-message-actions",
|
||||
);
|
||||
|
||||
expect(userMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(userActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(userActions?.classList.contains("right-0")).toBe(true);
|
||||
expect(userActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(userActions?.classList.contains("-translate-y-2")).toBe(true);
|
||||
expect(assistantMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("left-0")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("-translate-y-2")).toBe(true);
|
||||
expect(assistantActions?.getAttribute("data-visible")).toBe("true");
|
||||
const userAction = userActions?.querySelector(".cline-chat-message-action");
|
||||
expect(userAction?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(userAction?.classList.contains("p-0")).toBe(true);
|
||||
const assistantActionButtons = [
|
||||
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
|
||||
[]),
|
||||
];
|
||||
expect(assistantActionButtons).toHaveLength(2);
|
||||
expect(
|
||||
assistantActionButtons.every(
|
||||
(action) =>
|
||||
action.classList.contains("min-w-0") &&
|
||||
action.classList.contains("p-0"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(userActions?.querySelector("time")?.getAttribute("datetime")).toBe(
|
||||
new Date(1).toISOString(),
|
||||
);
|
||||
expect(
|
||||
assistantActions?.querySelector("time")?.getAttribute("datetime"),
|
||||
).toBe(new Date(2).toISOString());
|
||||
});
|
||||
|
||||
it("leaves vertical scrolling to the conversation viewport", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "assistant-scroll",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const content = container.querySelector(".cline-chat-conversation-content");
|
||||
const messageList = content?.querySelector(":scope > div");
|
||||
|
||||
expect(content?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
expect(messageList?.classList.contains("overflow-x-hidden")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages image attachments", () => {
|
||||
@@ -270,8 +406,8 @@ describe("ChatMessages image attachments", () => {
|
||||
'img[alt="Attachment 1"]',
|
||||
);
|
||||
expect(image?.src).toBe("data:image/png;base64,aGVsbG8=");
|
||||
expect(image?.className).toContain("max-h-[225px]");
|
||||
expect(image?.className).toContain("max-w-[225px]");
|
||||
expect(image?.className).toContain("max-h-56.25");
|
||||
expect(image?.className).toContain("max-w-56.25");
|
||||
expect(container.textContent).toContain("Describe this");
|
||||
});
|
||||
|
||||
@@ -312,6 +448,147 @@ describe("ChatMessages image attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages reasoning disclosure", () => {
|
||||
it("shows elapsed thinking time with the border-left disclosure style", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Solve this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Done",
|
||||
reasoning: "Carefully considered the request.",
|
||||
createdAt: 7_500,
|
||||
},
|
||||
]);
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((element) =>
|
||||
element.textContent?.includes("Thought for 7s"),
|
||||
);
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger?.querySelector(".lucide-brain")).not.toBeNull();
|
||||
expect(trigger?.querySelector(".cline-chat-disclosure-icon")).toBeNull();
|
||||
expect(trigger?.classList.contains("text-sm")).toBe(true);
|
||||
expect(trigger?.classList.contains("text-xs")).toBe(false);
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
|
||||
const content = container.querySelector(".cline-chat-reasoning-content");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(content?.textContent).toContain("Carefully considered the request.");
|
||||
expect(content?.classList.contains("border-l")).toBe(true);
|
||||
expect(content?.classList.contains("pl-4")).toBe(true);
|
||||
expect(content?.classList.contains("rounded-none")).toBe(true);
|
||||
expect(content?.classList.contains("bg-transparent")).toBe(true);
|
||||
});
|
||||
|
||||
it("combines consecutive assistant reasoning into one disclosure", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-combined-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Investigate this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-first",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "First reasoning segment.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-second",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Investigation complete.",
|
||||
reasoning: "Second reasoning segment.",
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const disclosures = container.querySelectorAll(".cline-chat-reasoning");
|
||||
expect(disclosures).toHaveLength(1);
|
||||
const trigger = disclosures[0]?.querySelector("button");
|
||||
expect(trigger?.textContent).toContain("Thought for 2s");
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
|
||||
const content = disclosures[0]?.querySelector(
|
||||
".cline-chat-reasoning-content",
|
||||
);
|
||||
const contentText = content?.textContent ?? "";
|
||||
expect(contentText).toContain("First reasoning segment.");
|
||||
expect(contentText).toContain("Second reasoning segment.");
|
||||
expect(contentText.indexOf("First reasoning segment.")).toBeLessThan(
|
||||
contentText.indexOf("Second reasoning segment."),
|
||||
);
|
||||
expect(container.textContent).toContain("Investigation complete.");
|
||||
});
|
||||
|
||||
it("keeps reasoning disclosures separate across tool activity", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-before-separated-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Investigate this",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-before-tool",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Reasoning before the tool.",
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "tool-between-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
createdAt: 2_500,
|
||||
meta: { toolName: "search" },
|
||||
},
|
||||
{
|
||||
id: "assistant-reasoning-after-tool",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "Investigation complete.",
|
||||
reasoning: "Reasoning after the tool.",
|
||||
createdAt: 3_000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.querySelectorAll(".cline-chat-reasoning")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to Thinking when there is no previous timestamp", async () => {
|
||||
await renderMessages([
|
||||
{
|
||||
id: "first-reasoning",
|
||||
sessionId: "session-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "Starting from scratch.",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Thought for");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatMessages thinking indicator", () => {
|
||||
const userMessage: ChatMessage = {
|
||||
id: "user-1",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
Message as AgentMessage,
|
||||
type AgentMessageRole,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
@@ -20,20 +21,29 @@ import {
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BlocksIcon,
|
||||
BoxIcon,
|
||||
BrainIcon,
|
||||
Check,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
FileIcon,
|
||||
FileSearch,
|
||||
FilesIcon,
|
||||
LibraryIcon,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
MessagesSquare,
|
||||
Search,
|
||||
PanelsTopLeftIcon,
|
||||
PencilIcon,
|
||||
SearchCodeIcon,
|
||||
ShieldAlert,
|
||||
SplitIcon,
|
||||
SquareTerminalIcon,
|
||||
SquareArrowRightIcon,
|
||||
TerminalIcon,
|
||||
UndoIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -98,14 +108,114 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
|
||||
type ChatRenderItem =
|
||||
| { type: "message"; message: ChatMessage }
|
||||
| {
|
||||
type: "message";
|
||||
agentRole: AgentMessageRole;
|
||||
message: ChatMessage;
|
||||
reasoningMessages: ChatMessage[];
|
||||
}
|
||||
| { type: "tools"; messages: ChatMessage[] };
|
||||
|
||||
function groupConsecutiveToolMessages(
|
||||
function hasMessageReasoning(message: ChatMessage): boolean {
|
||||
return Boolean(message.reasoning?.trim() || message.reasoningRedacted);
|
||||
}
|
||||
|
||||
function isReasoningOnlyAssistantMessage(message: ChatMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant" &&
|
||||
hasMessageReasoning(message) &&
|
||||
!message.content.trim() &&
|
||||
!message.images?.length
|
||||
);
|
||||
}
|
||||
|
||||
function buildPreviousTimestampMap(
|
||||
messages: ChatMessage[],
|
||||
): ChatRenderItem[] {
|
||||
const items: ChatRenderItem[] = [];
|
||||
): Map<ChatMessage, number | undefined> {
|
||||
const previousTimestampByMessage = new Map<ChatMessage, number | undefined>();
|
||||
let previousTimestamp: number | undefined;
|
||||
|
||||
for (const message of messages) {
|
||||
previousTimestampByMessage.set(message, previousTimestamp);
|
||||
if (Number.isFinite(message.createdAt)) {
|
||||
previousTimestamp = message.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
return previousTimestampByMessage;
|
||||
}
|
||||
|
||||
function getThoughtDurationMilliseconds(
|
||||
previousTimestamp: number | undefined,
|
||||
thinkingTimestamp: number,
|
||||
): number | undefined {
|
||||
if (
|
||||
previousTimestamp === undefined ||
|
||||
!Number.isFinite(previousTimestamp) ||
|
||||
!Number.isFinite(thinkingTimestamp) ||
|
||||
thinkingTimestamp < previousTimestamp
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return thinkingTimestamp - previousTimestamp;
|
||||
}
|
||||
|
||||
function formatThoughtLabel(durationMilliseconds?: number): string {
|
||||
if (durationMilliseconds === undefined) {
|
||||
return "Thinking";
|
||||
}
|
||||
|
||||
const seconds =
|
||||
durationMilliseconds === 0
|
||||
? 0
|
||||
: Math.max(1, Math.round(durationMilliseconds / 1000));
|
||||
|
||||
return `Thought for ${seconds}s`;
|
||||
}
|
||||
|
||||
function groupChatMessages(messages: ChatMessage[]): ChatRenderItem[] {
|
||||
const items: ChatRenderItem[] = [];
|
||||
let pendingReasoningMessages: ChatMessage[] = [];
|
||||
|
||||
const pushMessage = (
|
||||
message: ChatMessage,
|
||||
agentRole: AgentMessageRole,
|
||||
reasoningMessages = hasMessageReasoning(message) ? [message] : [],
|
||||
) => {
|
||||
items.push({
|
||||
type: "message",
|
||||
agentRole,
|
||||
message,
|
||||
reasoningMessages,
|
||||
});
|
||||
};
|
||||
|
||||
const flushPendingReasoning = () => {
|
||||
const message = pendingReasoningMessages.at(-1);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
pushMessage(message, "assistant", pendingReasoningMessages);
|
||||
pendingReasoningMessages = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (isReasoningOnlyAssistantMessage(message)) {
|
||||
pendingReasoningMessages.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && pendingReasoningMessages.length > 0) {
|
||||
const reasoningMessages = hasMessageReasoning(message)
|
||||
? [...pendingReasoningMessages, message]
|
||||
: pendingReasoningMessages;
|
||||
pushMessage(message, "assistant", reasoningMessages);
|
||||
pendingReasoningMessages = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingReasoning();
|
||||
const previous = items.at(-1);
|
||||
if (message.role === "tool") {
|
||||
if (previous?.type === "tools") {
|
||||
@@ -115,8 +225,9 @@ function groupConsecutiveToolMessages(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push({ type: "message", message });
|
||||
pushMessage(message, message.role);
|
||||
}
|
||||
flushPendingReasoning();
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -186,8 +297,9 @@ function ChatMessagesImpl({
|
||||
expandedImage?.sessionId === sessionId ? expandedImage.image : null;
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
const renderItems = useMemo(
|
||||
() => groupConsecutiveToolMessages(messages),
|
||||
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
|
||||
const previousTimestampByMessage = useMemo(
|
||||
() => buildPreviousTimestampMap(messages),
|
||||
[messages],
|
||||
);
|
||||
|
||||
@@ -397,12 +509,12 @@ function ChatMessagesImpl({
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2">
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
@@ -443,9 +555,20 @@ function ChatMessagesImpl({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { message } = item;
|
||||
const { agentRole, message, reasoningMessages } = item;
|
||||
const firstReasoningMessage = reasoningMessages[0];
|
||||
const lastReasoningMessage = reasoningMessages.at(-1);
|
||||
const reasoningContent = reasoningMessages
|
||||
.map((reasoningMessage) => reasoningMessage.reasoning?.trim())
|
||||
.filter((content): content is string => Boolean(content))
|
||||
.join("\n\n");
|
||||
return (
|
||||
<MessageBubble
|
||||
agentRole={agentRole}
|
||||
isLastAssistantMessage={
|
||||
message.role === "assistant" &&
|
||||
lastConversationMessage === message
|
||||
}
|
||||
isStreaming={streamingMessageId === message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -469,6 +592,21 @@ function ChatMessagesImpl({
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
reasoningContent={reasoningContent}
|
||||
reasoningRedacted={reasoningMessages.some(
|
||||
(reasoningMessage) =>
|
||||
reasoningMessage.reasoningRedacted === true,
|
||||
)}
|
||||
thoughtDurationMilliseconds={
|
||||
firstReasoningMessage && lastReasoningMessage
|
||||
? getThoughtDurationMilliseconds(
|
||||
previousTimestampByMessage.get(
|
||||
firstReasoningMessage,
|
||||
),
|
||||
lastReasoningMessage.createdAt,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -756,6 +894,7 @@ function AskQuestionPanel({
|
||||
// object that received a delta changes identity, so all other bubbles skip
|
||||
// re-rendering (and re-running their Markdown pipeline) per flush.
|
||||
const MessageBubble = memo(function MessageBubble({
|
||||
agentRole,
|
||||
message,
|
||||
isStreaming = false,
|
||||
onCopyMessage,
|
||||
@@ -768,7 +907,12 @@ const MessageBubble = memo(function MessageBubble({
|
||||
onForkSession,
|
||||
forkPending = false,
|
||||
forkError,
|
||||
isLastAssistantMessage = false,
|
||||
reasoningContent,
|
||||
reasoningRedacted,
|
||||
thoughtDurationMilliseconds,
|
||||
}: {
|
||||
agentRole: AgentMessageRole;
|
||||
message: ChatMessage;
|
||||
isStreaming?: boolean;
|
||||
onCopyMessage?: (messageId: string, content: string) => void | Promise<void>;
|
||||
@@ -784,6 +928,10 @@ const MessageBubble = memo(function MessageBubble({
|
||||
onForkSession?: (messageId: string) => void | Promise<void>;
|
||||
forkPending?: boolean;
|
||||
forkError?: string;
|
||||
isLastAssistantMessage?: boolean;
|
||||
reasoningContent: string;
|
||||
reasoningRedacted: boolean;
|
||||
thoughtDurationMilliseconds?: number;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
@@ -801,17 +949,35 @@ const MessageBubble = memo(function MessageBubble({
|
||||
const shouldRenderUserActions =
|
||||
isUser && Boolean(onCopyMessage || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const keepAssistantActionsVisible =
|
||||
isLastAssistantMessage || forkPending || Boolean(forkError);
|
||||
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
const messageDate = new Date(message.createdAt);
|
||||
const hasValidMessageDate = !Number.isNaN(messageDate.getTime());
|
||||
const messageTime = hasValidMessageDate
|
||||
? messageDate.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: null;
|
||||
const messageTimestamp = messageTime ? (
|
||||
<time
|
||||
className="shrink-0 whitespace-nowrap text-[11px] leading-none text-muted-foreground"
|
||||
dateTime={messageDate.toISOString()}
|
||||
title={messageDate.toLocaleString()}
|
||||
>
|
||||
{messageTime}
|
||||
</time>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<AgentMessage from={message.role}>
|
||||
<AgentMessage className="relative" from={agentRole}>
|
||||
<MessageContent className="space-y-2 wrap-break-word">
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
{reasoningContent || reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
durationMilliseconds={thoughtDurationMilliseconds}
|
||||
redacted={reasoningRedacted}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
@@ -829,7 +995,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
{/* biome-ignore lint/performance/noImgElement: User-provided data URLs do not have dimensions and cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt={`Attachment ${index + 1}`}
|
||||
className="max-h-[225px] max-w-[225px] object-contain"
|
||||
className="max-h-56.25 max-w-56.25 object-contain"
|
||||
src={`data:${image.mediaType};base64,${image.data}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -849,9 +1015,13 @@ const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions visible={keepUserActionsVisible}>
|
||||
<MessageActions
|
||||
className="absolute right-0 top-full z-10 -translate-y-2"
|
||||
visible={keepUserActionsVisible}
|
||||
>
|
||||
{onCopyMessage ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0"
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={() => void onCopyMessage(message.id, message.content)}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
@@ -865,6 +1035,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() =>
|
||||
@@ -879,6 +1050,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{messageTimestamp}
|
||||
</MessageActions>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
@@ -889,9 +1061,13 @@ const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions visible={keepAssistantActionsVisible}>
|
||||
<MessageActions
|
||||
className="absolute left-0 top-full z-10 -translate-y-2"
|
||||
visible={keepAssistantActionsVisible}
|
||||
>
|
||||
{onCopyMessage ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0"
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
@@ -909,6 +1085,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0"
|
||||
disabled={forkPending}
|
||||
label="Fork session"
|
||||
onClick={() => void onForkSession(message.id)}
|
||||
@@ -921,6 +1098,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{messageTimestamp}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
@@ -932,22 +1110,33 @@ const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
function ReasoningBlock({
|
||||
content,
|
||||
durationMilliseconds,
|
||||
redacted,
|
||||
streaming = false,
|
||||
}: {
|
||||
content: string;
|
||||
durationMilliseconds?: number;
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
const label = streaming
|
||||
? "Thinking"
|
||||
: formatThoughtLabel(durationMilliseconds);
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Reasoning isStreaming={streaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<Reasoning className="my-0" isStreaming={streaming}>
|
||||
<ReasoningTrigger
|
||||
aria-label={label}
|
||||
className="gap-2 py-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<BrainIcon aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</ReasoningTrigger>
|
||||
<ReasoningContent className="ml-2 mt-2 max-h-48 overflow-y-auto rounded-none border-0 border-l border-border bg-transparent p-0 py-1 pl-4 text-sm leading-relaxed text-muted-foreground">
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
@@ -1045,39 +1234,82 @@ function parseToolPayload(raw: string): ToolPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
"apply-patch": "apply_patch",
|
||||
bash: "run_commands",
|
||||
edit: "editor",
|
||||
edit_file: "editor",
|
||||
"file-read": "read_files",
|
||||
file_read: "read_files",
|
||||
search: "search_codebase",
|
||||
"spawn-agent": "spawn_agent",
|
||||
spawn_agent_tool: "spawn_agent",
|
||||
"web-fetch": "fetch_web_content",
|
||||
web_fetch: "fetch_web_content",
|
||||
};
|
||||
|
||||
function normalizeToolName(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase();
|
||||
return TOOL_NAME_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function classifyTool(
|
||||
toolName: string,
|
||||
): "exploration" | "file-edit" | "bash" | "spawn" | "tool" {
|
||||
const normalized = toolName.toLowerCase();
|
||||
const normalized = normalizeToolName(toolName);
|
||||
if (
|
||||
[
|
||||
"search",
|
||||
"search_codebase",
|
||||
"file-read",
|
||||
"file_read",
|
||||
"read_files",
|
||||
"web-fetch",
|
||||
"web_fetch",
|
||||
"fetch_web_content",
|
||||
"skills",
|
||||
].includes(normalized)
|
||||
)
|
||||
return "exploration";
|
||||
if (
|
||||
["editor", "edit_file", "edit", "apply_patch", "apply-patch"].includes(
|
||||
["search_codebase", "read_files", "fetch_web_content", "skills"].includes(
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
return "file-edit";
|
||||
if (["bash", "run_commands"].includes(normalized)) return "bash";
|
||||
if (
|
||||
["spawn_agent", "spawn-agent", "spawn_agent_tool"].includes(normalized) ||
|
||||
normalized.startsWith("subagent_")
|
||||
)
|
||||
return "exploration";
|
||||
if (["editor", "apply_patch"].includes(normalized)) return "file-edit";
|
||||
if (normalized === "run_commands") return "bash";
|
||||
if (normalized === "spawn_agent" || normalized.startsWith("subagent_"))
|
||||
return "spawn";
|
||||
return "tool";
|
||||
}
|
||||
|
||||
const TOOL_NAME_ICONS: Record<string, LucideIcon> = {
|
||||
apply_patch: PencilIcon,
|
||||
ask_question: MessageCircleQuestionMarkIcon,
|
||||
editor: PencilIcon,
|
||||
fetch_web_content: PanelsTopLeftIcon,
|
||||
mcp: BoxIcon,
|
||||
plugins: BlocksIcon,
|
||||
read_files: FilesIcon,
|
||||
run_commands: TerminalIcon,
|
||||
search_codebase: SearchCodeIcon,
|
||||
skills: LibraryIcon,
|
||||
spawn_agent: UserIcon,
|
||||
submit_and_exit: SquareArrowRightIcon,
|
||||
};
|
||||
|
||||
const TOOL_KIND_ICONS: Record<ReturnType<typeof classifyTool>, LucideIcon> = {
|
||||
bash: TerminalIcon,
|
||||
exploration: SearchCodeIcon,
|
||||
"file-edit": PencilIcon,
|
||||
spawn: UserIcon,
|
||||
tool: WrenchIcon,
|
||||
};
|
||||
|
||||
function getToolNameIcon(toolName: string): LucideIcon {
|
||||
const normalized = normalizeToolName(toolName);
|
||||
if (normalized.startsWith("subagent_")) {
|
||||
return UserIcon;
|
||||
}
|
||||
if (
|
||||
normalized === "team" ||
|
||||
normalized === "teams" ||
|
||||
normalized.startsWith("team_")
|
||||
) {
|
||||
return UsersIcon;
|
||||
}
|
||||
return (
|
||||
TOOL_NAME_ICONS[normalized] ?? TOOL_KIND_ICONS[classifyTool(normalized)]
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
@@ -1182,10 +1414,10 @@ function buildToolSummary(
|
||||
result: unknown,
|
||||
inProgress: boolean,
|
||||
): ToolSummary {
|
||||
const normalized = toolName.toLowerCase();
|
||||
const normalized = normalizeToolName(toolName);
|
||||
const inputObject = asRecord(input);
|
||||
|
||||
if (["read_files", "file_read", "file-read"].includes(normalized)) {
|
||||
if (normalized === "read_files") {
|
||||
const files = extractReadFilePaths(input);
|
||||
if (files.length > 0) {
|
||||
return {
|
||||
@@ -1204,7 +1436,7 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["search_codebase", "search"].includes(normalized)) {
|
||||
if (normalized === "search_codebase") {
|
||||
const queries = asStringArray(inputObject?.queries);
|
||||
if (queries.length > 0) {
|
||||
return {
|
||||
@@ -1221,7 +1453,7 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["run_commands", "bash"].includes(normalized)) {
|
||||
if (normalized === "run_commands") {
|
||||
const commands = extractCommands(input);
|
||||
if (commands.length > 0) {
|
||||
return {
|
||||
@@ -1238,7 +1470,7 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["fetch_web_content", "web_fetch", "web-fetch"].includes(normalized)) {
|
||||
if (normalized === "fetch_web_content") {
|
||||
const requests = Array.isArray(inputObject?.requests)
|
||||
? inputObject.requests
|
||||
: [];
|
||||
@@ -1267,7 +1499,7 @@ function buildToolSummary(
|
||||
}
|
||||
}
|
||||
|
||||
if (["apply_patch", "apply-patch"].includes(normalized)) {
|
||||
if (normalized === "apply_patch") {
|
||||
const patchText =
|
||||
typeof input === "string"
|
||||
? input
|
||||
@@ -1300,7 +1532,7 @@ function buildToolSummary(
|
||||
};
|
||||
}
|
||||
|
||||
if (["editor", "edit_file", "edit"].includes(normalized)) {
|
||||
if (normalized === "editor") {
|
||||
// Current editor schema has no `command`; derive it from the input shape.
|
||||
const command =
|
||||
typeof inputObject?.command === "string"
|
||||
@@ -1472,26 +1704,16 @@ function buildGroupedToolLabel(presentations: ToolPresentation[]): string {
|
||||
const ToolMessageBlock = memo(
|
||||
function ToolMessageBlock({ messages }: { messages: ChatMessage[] }) {
|
||||
const presentations = messages.map(buildToolPresentation);
|
||||
const first = presentations[0];
|
||||
if (!first) return null;
|
||||
if (presentations.length === 0) return null;
|
||||
const hasError = presentations.some(({ payload }) => payload?.isError);
|
||||
const isRunning = presentations.some(({ inProgress }) => inProgress);
|
||||
const kinds = new Set(presentations.map(({ kind }) => kind));
|
||||
const kind = kinds.size === 1 ? first.kind : "tool";
|
||||
const isFileRead = presentations.every(({ toolName }) =>
|
||||
["read_files", "file_read", "file-read"].includes(toolName.toLowerCase()),
|
||||
const icons = presentations.map(({ toolName }) =>
|
||||
getToolNameIcon(toolName),
|
||||
);
|
||||
const Icon = isFileRead
|
||||
? FileIcon
|
||||
: kind === "exploration"
|
||||
? Search
|
||||
: kind === "file-edit"
|
||||
? FileEdit
|
||||
: kind === "bash"
|
||||
? SquareTerminalIcon
|
||||
: kind === "spawn"
|
||||
? Bot
|
||||
: FileSearch;
|
||||
const firstIcon = icons[0] ?? WrenchIcon;
|
||||
const Icon = icons.every((icon) => icon === firstIcon)
|
||||
? firstIcon
|
||||
: WrenchIcon;
|
||||
const details = presentations.flatMap(({ message, summary }) =>
|
||||
summary.details.map((detail) => ({
|
||||
detail,
|
||||
@@ -1527,7 +1749,7 @@ const ToolMessageBlock = memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivity className="my-0" expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={diff.additions || undefined}
|
||||
deletions={diff.deletions || undefined}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatCompactTokens,
|
||||
paginationItems,
|
||||
SessionsView,
|
||||
} from "@/components/views/sessions/sessions-view";
|
||||
import type { SessionThread } from "@/hooks/use-session-history";
|
||||
import type { SessionHistoryItem } from "@/lib/session-history";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const thread: SessionThread = {
|
||||
id: "session-1",
|
||||
title: "help me rewrite this sql",
|
||||
codebase: "ai-data-suite",
|
||||
workspacePath: "/Users/dev/ai-data-suite",
|
||||
time: "1d",
|
||||
provider: "cline-pass",
|
||||
model: "cline-pass/glm-5.2-with-a-very-long-identifier",
|
||||
inputTokens: 13_837_938,
|
||||
outputTokens: 132_579,
|
||||
status: "completed",
|
||||
};
|
||||
|
||||
const session: SessionHistoryItem = {
|
||||
sessionId: thread.id,
|
||||
status: "completed",
|
||||
provider: thread.provider,
|
||||
model: thread.model,
|
||||
cwd: thread.workspacePath,
|
||||
workspaceRoot: thread.workspacePath,
|
||||
startedAt: new Date("2026-07-24T10:00:00Z").toISOString(),
|
||||
endedAt: new Date("2026-07-26T10:00:00Z").toISOString(),
|
||||
};
|
||||
|
||||
function renderView({
|
||||
openThread = vi.fn(),
|
||||
loadAllSessions = vi.fn(async () => true),
|
||||
loadOlderSessions = vi.fn(),
|
||||
mayHaveMoreSessions = false,
|
||||
threads = [thread],
|
||||
}: {
|
||||
openThread?: ReturnType<typeof vi.fn>;
|
||||
loadAllSessions?: ReturnType<typeof vi.fn>;
|
||||
loadOlderSessions?: ReturnType<typeof vi.fn>;
|
||||
mayHaveMoreSessions?: boolean;
|
||||
threads?: SessionThread[];
|
||||
} = {}) {
|
||||
const history = {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
isLoadingHistory: false,
|
||||
isLoadingMore: false,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
mayHaveMoreSessions,
|
||||
openThread,
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
setThreadPinned: vi.fn(),
|
||||
sessionById: new Map(
|
||||
threads.map((item) => [item.id, { ...session, sessionId: item.id }]),
|
||||
),
|
||||
threads,
|
||||
};
|
||||
return {
|
||||
history,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
openThread,
|
||||
render: () =>
|
||||
act(async () => {
|
||||
root.render(
|
||||
<SessionsView
|
||||
history={
|
||||
history as unknown as React.ComponentProps<
|
||||
typeof SessionsView
|
||||
>["history"]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("formatCompactTokens", () => {
|
||||
it("abbreviates millions and thousands and leaves small counts alone", () => {
|
||||
expect(formatCompactTokens(13_837_938)).toBe("13.8m");
|
||||
expect(formatCompactTokens(132_579)).toBe("132.6k");
|
||||
expect(formatCompactTokens(17_218)).toBe("17.2k");
|
||||
expect(formatCompactTokens(11)).toBe("11");
|
||||
expect(formatCompactTokens(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView table", () => {
|
||||
it("labels the title and time columns", async () => {
|
||||
const view = renderView();
|
||||
await view.render();
|
||||
|
||||
const headers = Array.from(
|
||||
container.querySelectorAll("div > span:not(.sr-only)"),
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((node) => node.textContent);
|
||||
expect(headers).toEqual([
|
||||
"Title",
|
||||
"Workspace",
|
||||
"Model",
|
||||
"Tokens",
|
||||
"Cost",
|
||||
"Time",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows compact token counts and truncates the model to a fixed row height", async () => {
|
||||
const view = renderView();
|
||||
await view.render();
|
||||
|
||||
const row = container.querySelector<HTMLDivElement>('[role="button"]');
|
||||
expect(row?.textContent).toContain("13.8m/132.6k");
|
||||
const modelCell = Array.from(row?.children ?? []).find((node) =>
|
||||
node.textContent?.includes(thread.model),
|
||||
);
|
||||
expect(modelCell?.className).toContain("truncate");
|
||||
// Full value stays reachable on hover.
|
||||
expect(modelCell?.getAttribute("title")).toBe(
|
||||
`${thread.provider}:${thread.model}`,
|
||||
);
|
||||
expect(row?.parentElement?.className).toContain("h-14");
|
||||
expect(row?.parentElement?.className).not.toContain("min-h-14");
|
||||
});
|
||||
|
||||
it("marks favorited sessions with a star", async () => {
|
||||
const plain = renderView();
|
||||
await plain.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
const favorited = renderView({
|
||||
threads: [{ ...thread, pinned: true }],
|
||||
});
|
||||
await favorited.render();
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens a session on click but not while text is selected", async () => {
|
||||
const view = renderView({});
|
||||
await view.render();
|
||||
|
||||
const row = container.querySelector<HTMLDivElement>('[role="button"]');
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "rewrite this sql",
|
||||
} as unknown as Selection);
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(view.openThread).not.toHaveBeenCalled();
|
||||
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "",
|
||||
} as unknown as Selection);
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(view.openThread).toHaveBeenCalledWith(thread.id);
|
||||
});
|
||||
|
||||
it("loads complete history before treating search results as exhaustive", async () => {
|
||||
const view = renderView({ mayHaveMoreSessions: true });
|
||||
await view.render();
|
||||
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Search sessions"]',
|
||||
);
|
||||
expect(search).not.toBeNull();
|
||||
await act(async () => {
|
||||
if (search) {
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(search, "older match");
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("loads complete history for filters and oldest-first sorting", async () => {
|
||||
const view = renderView({ mayHaveMoreSessions: true });
|
||||
await view.render();
|
||||
|
||||
const filterButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Filter sessions"]',
|
||||
);
|
||||
await act(async () => {
|
||||
filterButton?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
|
||||
view.loadAllSessions.mockClear();
|
||||
const sortButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Sort sessions"]',
|
||||
);
|
||||
await act(async () => {
|
||||
sortButton?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const oldestItem = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent === "Oldest first");
|
||||
expect(oldestItem).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
oldestItem?.click();
|
||||
});
|
||||
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView pagination", () => {
|
||||
const manyThreads = Array.from({ length: 25 }, (_, index) => ({
|
||||
...thread,
|
||||
id: `session-${index}`,
|
||||
title: `Session ${index}`,
|
||||
}));
|
||||
|
||||
const rowTitles = () =>
|
||||
Array.from(container.querySelectorAll('[role="button"]')).map(
|
||||
(row) => row.querySelector("span > span:last-child")?.textContent,
|
||||
);
|
||||
|
||||
const clickButton = async (label: string) => {
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${label}"]`,
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const clickNext = () => clickButton("Next page");
|
||||
|
||||
it("shows ten sessions per page", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
expect(rowTitles()).toHaveLength(10);
|
||||
expect(rowTitles()[0]).toBe("Session 0");
|
||||
expect(container.textContent).toContain("1-10 of 25");
|
||||
|
||||
await clickNext();
|
||||
expect(rowTitles()[0]).toBe("Session 10");
|
||||
expect(container.textContent).toContain("11-20 of 25");
|
||||
});
|
||||
|
||||
it("only asks the backend for older sessions at the last page", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
await view.render();
|
||||
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
expect(view.loadOlderSessions).not.toHaveBeenCalled();
|
||||
|
||||
await clickNext();
|
||||
expect(view.loadOlderSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays on the last page when no older sessions come back", async () => {
|
||||
const view = renderView({
|
||||
threads: manyThreads,
|
||||
mayHaveMoreSessions: true,
|
||||
});
|
||||
await view.render();
|
||||
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
await clickNext();
|
||||
|
||||
expect(container.textContent).toContain("21-25 of 25");
|
||||
expect(rowTitles()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("jumps to a numbered page and back to the first page in one click", async () => {
|
||||
const view = renderView({ threads: manyThreads });
|
||||
await view.render();
|
||||
|
||||
const pageButtons = Array.from(
|
||||
container.querySelectorAll('button[aria-label^="Page "]'),
|
||||
).map((button) => button.textContent);
|
||||
expect(pageButtons).toEqual(["1", "2", "3"]);
|
||||
expect(container.textContent).not.toContain("Page 1 of");
|
||||
|
||||
await clickButton("Page 3");
|
||||
expect(rowTitles()[0]).toBe("Session 20");
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-label="Page 3"]')
|
||||
?.getAttribute("aria-current"),
|
||||
).toBe("page");
|
||||
|
||||
await clickButton("First page");
|
||||
expect(rowTitles()[0]).toBe("Session 0");
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="First page"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginationItems", () => {
|
||||
it("lists every page while the pager is short", () => {
|
||||
expect(paginationItems(1, 3)).toEqual([1, 2, 3]);
|
||||
expect(paginationItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
it("keeps first, last and a window around the current page", () => {
|
||||
expect(paginationItems(1, 12)).toEqual([1, 2, 3, 4, 5, "gap-end", 12]);
|
||||
expect(paginationItems(6, 12)).toEqual([
|
||||
1,
|
||||
"gap-start",
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
"gap-end",
|
||||
12,
|
||||
]);
|
||||
expect(paginationItems(12, 12)).toEqual([1, "gap-start", 8, 9, 10, 11, 12]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import { SessionStatus } from "@cline/ui";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
@@ -11,10 +14,11 @@ import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Search,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -54,6 +58,8 @@ type SessionsViewProps = {
|
||||
history: UseSessionHistoryResult;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function modelLabel(thread: SessionThread): string {
|
||||
if (thread.provider && thread.model) {
|
||||
return `${thread.provider}:${thread.model}`;
|
||||
@@ -61,11 +67,61 @@ function modelLabel(thread: SessionThread): string {
|
||||
return thread.model || thread.provider || "No model";
|
||||
}
|
||||
|
||||
export function formatCompactTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return "0";
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`;
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
const MAX_PAGE_BUTTONS = 7;
|
||||
|
||||
/**
|
||||
* Page buttons for a 1-indexed pager: every page while the list is short, and
|
||||
* first/last plus a window around the current page once it grows.
|
||||
*/
|
||||
export function paginationItems(
|
||||
currentPage: number,
|
||||
pageCount: number,
|
||||
): Array<number | "gap-start" | "gap-end"> {
|
||||
if (pageCount <= MAX_PAGE_BUTTONS) {
|
||||
return Array.from({ length: pageCount }, (_, index) => index + 1);
|
||||
}
|
||||
const windowStart = Math.max(
|
||||
2,
|
||||
Math.min(currentPage - 1, pageCount - MAX_PAGE_BUTTONS + 3),
|
||||
);
|
||||
const windowEnd = Math.min(
|
||||
pageCount - 1,
|
||||
Math.max(currentPage + 1, MAX_PAGE_BUTTONS - 2),
|
||||
);
|
||||
const items: Array<number | "gap-start" | "gap-end"> = [1];
|
||||
if (windowStart > 2) {
|
||||
items.push("gap-start");
|
||||
}
|
||||
for (let page = windowStart; page <= windowEnd; page += 1) {
|
||||
items.push(page);
|
||||
}
|
||||
if (windowEnd < pageCount - 1) {
|
||||
items.push("gap-end");
|
||||
}
|
||||
items.push(pageCount);
|
||||
return items;
|
||||
}
|
||||
|
||||
function tokensLabel(thread: SessionThread): string {
|
||||
if (thread.inputTokens == null && thread.outputTokens == null) {
|
||||
return "-";
|
||||
}
|
||||
return `${thread.inputTokens ?? 0}/${thread.outputTokens ?? 0}`;
|
||||
const input = formatCompactTokens(thread.inputTokens ?? 0);
|
||||
const output = formatCompactTokens(thread.outputTokens ?? 0);
|
||||
return `${input}/${output}`;
|
||||
}
|
||||
|
||||
function sessionFilterDetails(
|
||||
@@ -75,6 +131,7 @@ function sessionFilterDetails(
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
thread.pinned ? "favorite:yes" : undefined,
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
@@ -101,6 +158,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<SessionThread | null>(
|
||||
null,
|
||||
);
|
||||
const [page, setPage] = useState(0);
|
||||
const requiresCompleteHistory =
|
||||
query.trim().length > 0 ||
|
||||
sessionFilters.length > 0 ||
|
||||
sortDirection === "oldest";
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresCompleteHistory || !history.mayHaveMoreSessions) {
|
||||
return;
|
||||
}
|
||||
void history.loadAllSessions();
|
||||
}, [
|
||||
history.loadAllSessions,
|
||||
history.mayHaveMoreSessions,
|
||||
requiresCompleteHistory,
|
||||
]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() =>
|
||||
@@ -154,6 +227,45 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredThreads.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const pageStart = currentPage * PAGE_SIZE;
|
||||
const visibleThreads = useMemo(
|
||||
() => filteredThreads.slice(pageStart, pageStart + PAGE_SIZE),
|
||||
[filteredThreads, pageStart],
|
||||
);
|
||||
const canGoNext =
|
||||
currentPage + 1 < pageCount ||
|
||||
(history.mayHaveMoreSessions && !requiresCompleteHistory);
|
||||
|
||||
// Snap back when a page disappears (filters changed, or "next" asked the
|
||||
// backend for older sessions and there were none left).
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount - 1));
|
||||
}, [pageCount]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: restart paging whenever the result set changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [query, sessionFilters, sortDirection]);
|
||||
|
||||
const goToNextPage = async () => {
|
||||
const nextPage = currentPage + 1;
|
||||
if (
|
||||
nextPage >= pageCount &&
|
||||
history.mayHaveMoreSessions &&
|
||||
!requiresCompleteHistory
|
||||
) {
|
||||
// Only page boundaries hit the backend; the mount fetch stays small.
|
||||
// Stay put when the fetch fails so the user keeps the page they can
|
||||
// see and the same click retries.
|
||||
if (!(await history.loadOlderSessions())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPage(nextPage);
|
||||
};
|
||||
|
||||
const toggleFilter = (detail: string, checked: boolean) => {
|
||||
setSessionFilters((current) => {
|
||||
if (checked) {
|
||||
@@ -163,6 +275,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const openRow = (thread: SessionThread) => {
|
||||
if (history.pendingAction?.sessionId === thread.id) {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the click only finished a text selection.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
};
|
||||
|
||||
const startRename = (thread: SessionThread) => {
|
||||
setEditingSessionId(thread.id);
|
||||
setEditingTitle(thread.title);
|
||||
@@ -234,7 +357,17 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// Filter choices are derived from the loaded rows, so
|
||||
// complete the history as soon as the user opens this
|
||||
// menu. This keeps both the options and their results
|
||||
// global rather than limited to the newest batch.
|
||||
if (open && history.mayHaveMoreSessions) {
|
||||
void history.loadAllSessions();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Filter sessions"
|
||||
@@ -285,13 +418,13 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-auto px-18 pb-10 max-[1200px]:px-8 max-[720px]:px-4">
|
||||
<div className="min-w-240 overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Session</span>
|
||||
<div className="grid grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] gap-x-4 bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Title</span>
|
||||
<span>Workspace</span>
|
||||
<span>Model</span>
|
||||
<span>Tokens</span>
|
||||
<span>Cost</span>
|
||||
<span>Updated</span>
|
||||
<span>Time</span>
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -308,7 +441,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
: "No sessions match the current filters."}
|
||||
</div>
|
||||
) : null}
|
||||
{filteredThreads.map((thread) => {
|
||||
{visibleThreads.map((thread) => {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const isEditing = editingSessionId === thread.id;
|
||||
const isPending = history.pendingAction?.sessionId === thread.id;
|
||||
@@ -322,7 +455,9 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_2.5rem] items-center gap-x-4 border-t px-4 py-3 text-sm transition-colors",
|
||||
// Fixed height: every row is the same size so the table
|
||||
// never reflows as long values wrap or hydrate in.
|
||||
"grid h-14 grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem_1.75rem] items-center gap-x-4 border-t px-4 text-sm transition-colors",
|
||||
activeSessionId === thread.id
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-accent/30",
|
||||
@@ -381,7 +516,10 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">
|
||||
<span
|
||||
className="truncate text-muted-foreground"
|
||||
title={modelLabel(thread)}
|
||||
>
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -395,20 +533,27 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</span>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="col-span-6 grid cursor-pointer select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 border-0 bg-transparent p-0 text-left font-inherit text-inherit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
|
||||
disabled={Boolean(pendingKind)}
|
||||
onClick={() => {
|
||||
if (pendingKind) {
|
||||
// A native <button> suppresses drag-to-select, so the row is a
|
||||
// plain container with button semantics: the cells stay
|
||||
// selectable and a click that ends a selection does not open
|
||||
// the session.
|
||||
// biome-ignore lint/a11y/useSemanticElements: buttons are not text-selectable
|
||||
<div
|
||||
aria-disabled={Boolean(pendingKind)}
|
||||
className={cn(
|
||||
"col-span-6 grid select-text grid-cols-[minmax(14rem,1.35fr)_minmax(9rem,0.8fr)_minmax(12rem,1fr)_7rem_5rem_6rem] items-center gap-x-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
pendingKind ? "cursor-default" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => openRow(thread)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
// Don't open the session when the user is selecting text.
|
||||
if (window.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
history.openThread(thread.id);
|
||||
event.preventDefault();
|
||||
openRow(thread);
|
||||
}}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={pendingKind ? -1 : 0}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3 font-semibold">
|
||||
<span className="sr-only">Open session: </span>
|
||||
@@ -425,6 +570,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
tone={sessionStatusTone(thread.status)}
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.pinned ? (
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
className="size-3.5 shrink-0 fill-current text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
@@ -432,19 +583,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
{workspace ? basenamePath(workspace) : "No workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
<span
|
||||
className="truncate text-muted-foreground"
|
||||
title={modelLabel(thread)}
|
||||
>
|
||||
{modelLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{tokensLabel(thread)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{formatCostUsd(thread.totalCostUsd) ?? "-"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="truncate text-muted-foreground">
|
||||
{updated || thread.time}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
@@ -463,6 +617,22 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void history.setThreadPinned(
|
||||
thread.id,
|
||||
!thread.pinned,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
@@ -487,22 +657,81 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{history.mayHaveMoreSessions ? (
|
||||
<div className="border-t px-4 py-3">
|
||||
<Button
|
||||
className="h-8 rounded-md px-3 text-xs"
|
||||
disabled={history.isLoadingMore}
|
||||
onClick={() =>
|
||||
void history.loadMoreSessions(history.threads.length + 100)
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more
|
||||
</Button>
|
||||
{filteredThreads.length > 0 ? (
|
||||
<div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{`${pageStart + 1}-${pageStart + visibleThreads.length} of ${filteredThreads.length}`}
|
||||
{history.mayHaveMoreSessions ? "+" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="First page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={currentPage === 0 || history.isLoadingMore}
|
||||
onClick={() => setPage(0)}
|
||||
size="sm"
|
||||
title="First page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Previous page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={currentPage === 0 || history.isLoadingMore}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
size="sm"
|
||||
title="Previous page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
{paginationItems(currentPage + 1, pageCount).map((item) =>
|
||||
typeof item === "number" ? (
|
||||
<Button
|
||||
aria-current={
|
||||
item === currentPage + 1 ? "page" : undefined
|
||||
}
|
||||
aria-label={`Page ${item}`}
|
||||
className="h-8 min-w-8 rounded-md px-2 tabular-nums"
|
||||
disabled={history.isLoadingMore}
|
||||
key={item}
|
||||
onClick={() => setPage(item - 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={item === currentPage + 1 ? "default" : "ghost"}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="px-1 text-muted-foreground"
|
||||
key={item}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
aria-label="Next page"
|
||||
className="h-8 rounded-md px-2.5"
|
||||
disabled={!canGoNext || history.isLoadingMore}
|
||||
onClick={() => void goToNextPage()}
|
||||
size="sm"
|
||||
title="Next page"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{history.isLoadingMore ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -465,6 +465,56 @@ describe("useChatSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps live stream timestamps in milliseconds", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "get_process_context") {
|
||||
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
|
||||
}
|
||||
if (command === "chat_session_command") {
|
||||
const request = args?.request as
|
||||
| { action?: string; config?: { sessionId?: string } }
|
||||
| undefined;
|
||||
if (request?.action === "start") {
|
||||
return { sessionId: request.config?.sessionId };
|
||||
}
|
||||
if (request?.action === "send") {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await current.sendPrompt("Think about this");
|
||||
});
|
||||
const chatEventHandler = subscribeMock.mock.calls.find(
|
||||
([eventName]) => eventName === "chat_event",
|
||||
)?.[1] as ((payload: unknown) => void) | undefined;
|
||||
const userMessage = current.messages.find(
|
||||
(message) => message.role === "user",
|
||||
);
|
||||
expect(chatEventHandler).toBeDefined();
|
||||
expect(userMessage).toBeDefined();
|
||||
const thinkingTimestamp = (userMessage?.createdAt ?? Date.now()) + 5_000;
|
||||
|
||||
await act(async () => {
|
||||
chatEventHandler?.({
|
||||
sessionId: current.sessionId,
|
||||
stream: "chat_reasoning",
|
||||
chunk: JSON.stringify({ text: "Considering the request." }),
|
||||
ts: thinkingTimestamp,
|
||||
index: 42,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
current.messages.find((message) => message.role === "assistant")
|
||||
?.createdAt,
|
||||
).toBe(thinkingTimestamp);
|
||||
});
|
||||
|
||||
it("returns to a completed status when a queued turn finishes via chat_done", async () => {
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
@@ -132,9 +132,7 @@ function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
function chunkCreatedAt(payload: AgentChunkEvent): number {
|
||||
const ts = payload.ts || Date.now();
|
||||
const index = payload.index ?? 0;
|
||||
return ts * 1000 + index;
|
||||
return payload.ts || Date.now();
|
||||
}
|
||||
|
||||
function mergeHydratedMessagesWithLive(options: {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionHistory } from "./use-session-history";
|
||||
|
||||
const { invokeMock, subscribeMock } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(),
|
||||
subscribeMock: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
invoke: invokeMock,
|
||||
subscribe: subscribeMock,
|
||||
},
|
||||
}));
|
||||
|
||||
type SessionHistoryHook = ReturnType<typeof useSessionHistory>;
|
||||
type PendingList = {
|
||||
limit: number;
|
||||
resolve: (rows: unknown[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
function sessionRow(sessionId: string) {
|
||||
return {
|
||||
sessionId,
|
||||
status: "completed",
|
||||
provider: "cline",
|
||||
model: "glm-5.2",
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
startedAt: "2026-07-20T10:00:00.000Z",
|
||||
endedAt: "2026-07-20T11:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let current: SessionHistoryHook;
|
||||
let pendingLists: PendingList[];
|
||||
|
||||
function HookHarness() {
|
||||
current = useSessionHistory({});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Runs queued timers and lets the resulting promise chains settle. */
|
||||
async function flush(ms = 1) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
vi.useFakeTimers();
|
||||
pendingLists = [];
|
||||
invokeMock.mockReset();
|
||||
subscribeMock.mockClear();
|
||||
invokeMock.mockImplementation(
|
||||
async (command: string, args?: { limit?: number }) => {
|
||||
if (command === "list_discovered_sessions") {
|
||||
return await new Promise<unknown[]>((resolve, reject) => {
|
||||
pendingLists.push({ limit: args?.limit ?? 0, resolve, reject });
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useSessionHistory refresh coalescing", () => {
|
||||
it("reuses an in-flight refresh that already covers the requested limit", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
expect(pendingLists[0].limit).toBe(50);
|
||||
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
second = current.refreshSessions();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([]);
|
||||
await second;
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let an in-flight smaller refresh satisfy a load-more", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
expect(pendingLists).toHaveLength(1);
|
||||
expect(pendingLists[0].limit).toBe(50);
|
||||
|
||||
// Click "next" while the periodic refresh is still running.
|
||||
let loadMore: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
loadMore = current.loadMoreSessions(100);
|
||||
});
|
||||
|
||||
// The in-flight request only asked for 50 rows, so it must not be
|
||||
// reused: the larger batch has to be requested before load-more resolves.
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve([]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
let settled = false;
|
||||
void loadMore?.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve([]);
|
||||
await loadMore;
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory failed refresh", () => {
|
||||
async function renderWithSessions() {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the loaded history when the list request fails", async () => {
|
||||
await renderWithSessions();
|
||||
expect(current.sessions).toHaveLength(50);
|
||||
expect(current.mayHaveMoreSessions).toBe(true);
|
||||
|
||||
let loadMore: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
loadMore = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
let loaded: boolean | undefined;
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
loaded = await loadMore;
|
||||
});
|
||||
|
||||
// A rejected request must not read as "no sessions": the list stays put
|
||||
// and the backend is still considered to have more.
|
||||
expect(loaded).toBe(false);
|
||||
expect(current.sessions).toHaveLength(50);
|
||||
expect(current.threads).toHaveLength(50);
|
||||
expect(current.mayHaveMoreSessions).toBe(true);
|
||||
});
|
||||
|
||||
it("does not lower a limit an overlapping call already raised", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
// Two "next page" clicks overlap: the first expands to 100 and is still
|
||||
// in flight when the second expands to 150.
|
||||
let first: Promise<boolean> | undefined;
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
first = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
second = current.loadMoreSessions(150);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists).toHaveLength(2);
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
// The 100-row request fails. Rolling the shared limit back to 50 here
|
||||
// would make the waiting call fetch 50 rows and still report success.
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
expect(await first).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(pendingLists).toHaveLength(3);
|
||||
expect(pendingLists[2].limit).toBe(150);
|
||||
await act(async () => {
|
||||
pendingLists[2].resolve(
|
||||
Array.from({ length: 150 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
expect(await second).toBe(true);
|
||||
});
|
||||
expect(current.sessions).toHaveLength(150);
|
||||
});
|
||||
|
||||
it("retries the oldest unfetched batch when overlapping calls both fail", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
let first: Promise<boolean> | undefined;
|
||||
let second: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
first = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
second = current.loadMoreSessions(150);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
expect(await first).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
pendingLists[2].reject(new Error("transport closed"));
|
||||
expect(await second).toBe(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Neither batch landed, so the next attempt must go back to the first
|
||||
// unfetched one (100) rather than resuming from a limit that was only
|
||||
// ever requested.
|
||||
await act(async () => {
|
||||
const retry = current.loadOlderSessions();
|
||||
await Promise.resolve();
|
||||
pendingLists[3].resolve([]);
|
||||
await retry;
|
||||
});
|
||||
expect(pendingLists[3].limit).toBe(100);
|
||||
});
|
||||
|
||||
it("retries the same batch after a failure instead of skipping it", async () => {
|
||||
await renderWithSessions();
|
||||
|
||||
await act(async () => {
|
||||
const attempt = current.loadMoreSessions(100);
|
||||
await Promise.resolve();
|
||||
pendingLists[1].reject(new Error("transport closed"));
|
||||
await attempt;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const retry = current.loadOlderSessions();
|
||||
await Promise.resolve();
|
||||
pendingLists[2].resolve([]);
|
||||
await retry;
|
||||
});
|
||||
expect(pendingLists[2].limit).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionHistory complete history loading", () => {
|
||||
it("expands requests until the backend returns fewer rows than requested", async () => {
|
||||
await act(async () => {
|
||||
root.render(<HookHarness />);
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
pendingLists[0].resolve(
|
||||
Array.from({ length: 50 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let complete: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
complete = current.loadAllSessions();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[1].limit).toBe(100);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[1].resolve(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(pendingLists[2].limit).toBe(200);
|
||||
|
||||
await act(async () => {
|
||||
pendingLists[2].resolve(
|
||||
Array.from({ length: 120 }, (_, index) =>
|
||||
sessionRow(`session-${index}`),
|
||||
),
|
||||
);
|
||||
expect(await complete).toBe(true);
|
||||
});
|
||||
|
||||
expect(current.sessions).toHaveLength(120);
|
||||
expect(current.mayHaveMoreSessions).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
} from "@/lib/session-history";
|
||||
import {
|
||||
getSessionMetadataGitBranch,
|
||||
getSessionMetadataPinned,
|
||||
getSessionMetadataTitle,
|
||||
getSessionSource,
|
||||
PINNED_METADATA_KEY,
|
||||
} from "@/lib/session-history";
|
||||
|
||||
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
|
||||
@@ -92,7 +94,10 @@ export type UseSessionHistoryOptions = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 300;
|
||||
// Kept small on purpose: the sidebar shows 10 threads and the sessions view
|
||||
// pages 10 at a time, so the mount fetch (and every 12s poll after it) only
|
||||
// needs enough rows for the first few pages. Older pages are fetched on demand.
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 50;
|
||||
const HISTORY_REFRESH_INTERVAL_MS = 12_000;
|
||||
const MIN_EVENT_HISTORY_REFRESH_INTERVAL_MS = 2_000;
|
||||
const HISTORY_EVENT_REFRESH_DELAY_MS = 1_000;
|
||||
@@ -255,6 +260,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
|
||||
model: session.model || "",
|
||||
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
|
||||
status: normalizeDiscoveredStatus(session.status, session.prompt),
|
||||
pinned: getSessionMetadataPinned(session.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +362,8 @@ function areSessionsEquivalent(
|
||||
getSessionMetadataGitBranch(b.metadata) ||
|
||||
getSessionMetadataTitle(a.metadata) !==
|
||||
getSessionMetadataTitle(b.metadata) ||
|
||||
getSessionMetadataPinned(a.metadata) !==
|
||||
getSessionMetadataPinned(b.metadata) ||
|
||||
a.workspaceRoot !== b.workspaceRoot ||
|
||||
a.cwd !== b.cwd ||
|
||||
a.provider !== b.provider ||
|
||||
@@ -480,12 +488,18 @@ export function useSessionHistory({
|
||||
const [threads, setThreads] = useState<SessionThread[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [mayHaveMoreSessions, setMayHaveMoreSessions] = useState(false);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<SessionPendingAction>(null);
|
||||
const [unreadSessionIds, setUnreadSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const fetchLimitRef = useRef(INITIAL_HISTORY_FETCH_LIMIT);
|
||||
// Limit of the most recent refresh that actually returned sessions. Failed
|
||||
// attempts roll back to this rather than to a caller-local snapshot, which
|
||||
// may itself name a batch that was never fetched.
|
||||
const loadedLimitRef = useRef(0);
|
||||
const mayHaveMoreSessionsRef = useRef(false);
|
||||
const usageLoadingRef = useRef<Set<string>>(new Set());
|
||||
const usageHydratedStatusRef = useRef<Map<string, SessionHistoryStatus>>(
|
||||
new Map(),
|
||||
@@ -498,7 +512,9 @@ export function useSessionHistory({
|
||||
const threadsRef = useRef<SessionThread[]>([]);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
const scheduledRefreshAtRef = useRef<number | null>(null);
|
||||
const refreshPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const refreshPromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const refreshLimitRef = useRef(0);
|
||||
const loadAllPromiseRef = useRef<Promise<boolean> | null>(null);
|
||||
const lastRefreshStartedAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -524,13 +540,22 @@ export function useSessionHistory({
|
||||
}, [activeSessionId]);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (refreshPromiseRef.current) {
|
||||
return refreshPromiseRef.current;
|
||||
// Reuse an in-flight refresh only when it already asked for at least as
|
||||
// many sessions as we need now. "Load more" raises the limit and then
|
||||
// awaits a refresh; sharing a request that captured the smaller limit
|
||||
// would resolve without the larger batch ever being fetched.
|
||||
while (refreshPromiseRef.current) {
|
||||
const pending = refreshPromiseRef.current;
|
||||
if (refreshLimitRef.current >= fetchLimitRef.current) {
|
||||
return pending;
|
||||
}
|
||||
await pending;
|
||||
}
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
const refreshPromise = (async (): Promise<boolean> => {
|
||||
lastRefreshStartedAtRef.current = Date.now();
|
||||
const limit = fetchLimitRef.current;
|
||||
refreshLimitRef.current = limit;
|
||||
// Only surface the loading state before anything has been fetched:
|
||||
// consumers only render it for an empty list, and toggling it on
|
||||
// every background poll re-rendered the whole app twice per refresh.
|
||||
@@ -540,7 +565,20 @@ export function useSessionHistory({
|
||||
try {
|
||||
const discovered = await desktopClient
|
||||
.invoke<CliDiscoveredSession[]>("list_discovered_sessions", { limit })
|
||||
.catch(() => []);
|
||||
.catch(() => null);
|
||||
// A rejected request is not an empty history. Treating it as one
|
||||
// would blank the list (the merge below is keyed off the response)
|
||||
// and mark the backend exhausted, hiding sessions that still exist
|
||||
// and disabling "load more" until some later poll happened to work.
|
||||
if (!Array.isArray(discovered)) {
|
||||
return false;
|
||||
}
|
||||
// Ask the raw response, not the filtered list: subagents and
|
||||
// sessions without a known model are dropped below, so a filtered
|
||||
// count under the limit does not mean the backend is exhausted.
|
||||
const hasMoreSessions = discovered.length >= limit;
|
||||
mayHaveMoreSessionsRef.current = hasMoreSessions;
|
||||
setMayHaveMoreSessions(hasMoreSessions);
|
||||
const topLevelSessions = discovered
|
||||
.map((session) => {
|
||||
const normalized: SessionHistoryItem = {
|
||||
@@ -610,21 +648,26 @@ export function useSessionHistory({
|
||||
});
|
||||
return areThreadsEquivalent(current, next) ? current : next;
|
||||
});
|
||||
loadedLimitRef.current = Math.max(loadedLimitRef.current, limit);
|
||||
return true;
|
||||
} catch {
|
||||
// Ignore in browser mode or when tauri command is unavailable.
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
})();
|
||||
|
||||
refreshPromiseRef.current = refreshPromise;
|
||||
try {
|
||||
await refreshPromise;
|
||||
} finally {
|
||||
// Release the slot from the promise itself rather than from this caller,
|
||||
// so a waiter in the loop above always observes a cleared ref when it
|
||||
// resumes instead of spinning on a settled promise.
|
||||
refreshPromise.finally(() => {
|
||||
if (refreshPromiseRef.current === refreshPromise) {
|
||||
refreshPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return await refreshPromise;
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
@@ -1126,6 +1169,56 @@ export function useSessionHistory({
|
||||
[getSessionByThreadId, onUpdateSessionMetadata, pendingAction],
|
||||
);
|
||||
|
||||
const setThreadPinned = useCallback(
|
||||
async (threadId: string, pinned: boolean) => {
|
||||
const applyPinned = (next: boolean) => {
|
||||
setThreads((current) =>
|
||||
updateThreadById(current, threadId, (thread) =>
|
||||
thread.pinned === next ? thread : { ...thread, pinned: next },
|
||||
),
|
||||
);
|
||||
setSessions((current) =>
|
||||
updateSessionById(current, threadId, (session) => ({
|
||||
...session,
|
||||
metadata: {
|
||||
...(session.metadata ?? {}),
|
||||
[PINNED_METADATA_KEY]: next || undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Favoriting is a single click, so apply it locally first and roll back
|
||||
// if the write fails rather than blocking the row on a round trip.
|
||||
applyPinned(pinned);
|
||||
try {
|
||||
await desktopClient.invoke("update_chat_session_metadata", {
|
||||
sessionId: threadId,
|
||||
metadata: { [PINNED_METADATA_KEY]: pinned ? true : null },
|
||||
});
|
||||
const sourceSession = getSessionByThreadId(threadId);
|
||||
onUpdateSessionMetadata?.(threadId, {
|
||||
...(sourceSession?.metadata ?? {}),
|
||||
[PINNED_METADATA_KEY]: pinned || undefined,
|
||||
});
|
||||
scheduleRefresh(HISTORY_FAST_REFRESH_DELAY_MS);
|
||||
return true;
|
||||
} catch (error) {
|
||||
applyPinned(!pinned);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: pinned ? "Favorite failed" : "Unfavorite failed",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The session could not be updated.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[getSessionByThreadId, onUpdateSessionMetadata, scheduleRefresh],
|
||||
);
|
||||
|
||||
const forkThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
const thread = threadsRef.current.find((item) => item.id === threadId);
|
||||
@@ -1243,13 +1336,30 @@ export function useSessionHistory({
|
||||
|
||||
const loadMoreSessions = useCallback(
|
||||
async (nextLimit: number) => {
|
||||
if (fetchLimitRef.current >= nextLimit) {
|
||||
return;
|
||||
if (loadedLimitRef.current >= nextLimit) {
|
||||
return true;
|
||||
}
|
||||
fetchLimitRef.current = nextLimit;
|
||||
const requestedLimit = Math.max(fetchLimitRef.current, nextLimit);
|
||||
fetchLimitRef.current = requestedLimit;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
await refreshSessions();
|
||||
const loaded = await refreshSessions();
|
||||
// Roll back to what was last fetched so a retry asks for this batch
|
||||
// again instead of skipping past it — but only when no overlapping
|
||||
// call has raised the limit further in the meantime, since lowering
|
||||
// it would make that call fetch a smaller batch than it asked for
|
||||
// and still report success.
|
||||
if (!loaded && fetchLimitRef.current === requestedLimit) {
|
||||
fetchLimitRef.current = loadedLimitRef.current;
|
||||
}
|
||||
if (!loaded) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not load more sessions",
|
||||
description: "Session history is unavailable right now.",
|
||||
});
|
||||
}
|
||||
return loaded;
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
@@ -1260,8 +1370,45 @@ export function useSessionHistory({
|
||||
() => loadMoreSessions(fetchLimitRef.current + INITIAL_HISTORY_FETCH_LIMIT),
|
||||
[loadMoreSessions],
|
||||
);
|
||||
const loadAllSessions = useCallback(() => {
|
||||
if (loadAllPromiseRef.current) {
|
||||
return loadAllPromiseRef.current;
|
||||
}
|
||||
const loadAllPromise = (async () => {
|
||||
// A global search, filter, or oldest-first sort can be selected while
|
||||
// the mount request is still in flight. Wait for that request before
|
||||
// deciding whether there is any older history to fetch.
|
||||
if (loadedLimitRef.current === 0 && !(await refreshSessions())) {
|
||||
return false;
|
||||
}
|
||||
// Grow exponentially so complete-history operations need only
|
||||
// logarithmically many requests while ordinary paging stays in
|
||||
// predictable 50-session increments.
|
||||
while (mayHaveMoreSessionsRef.current) {
|
||||
const currentLimit = Math.max(
|
||||
fetchLimitRef.current,
|
||||
loadedLimitRef.current,
|
||||
INITIAL_HISTORY_FETCH_LIMIT,
|
||||
);
|
||||
const nextLimit = Math.max(
|
||||
currentLimit + INITIAL_HISTORY_FETCH_LIMIT,
|
||||
currentLimit * 2,
|
||||
);
|
||||
if (!(await loadMoreSessions(nextLimit))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
loadAllPromiseRef.current = loadAllPromise;
|
||||
loadAllPromise.finally(() => {
|
||||
if (loadAllPromiseRef.current === loadAllPromise) {
|
||||
loadAllPromiseRef.current = null;
|
||||
}
|
||||
});
|
||||
return loadAllPromise;
|
||||
}, [loadMoreSessions, refreshSessions]);
|
||||
|
||||
const mayHaveMoreSessions = sessions.length >= fetchLimitRef.current;
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.sessionId, session])),
|
||||
[sessions],
|
||||
@@ -1271,6 +1418,7 @@ export function useSessionHistory({
|
||||
getSessionByThreadId,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
loadAllSessions,
|
||||
loadOlderSessions,
|
||||
loadMoreSessions,
|
||||
mayHaveMoreSessions,
|
||||
@@ -1278,6 +1426,7 @@ export function useSessionHistory({
|
||||
pendingAction,
|
||||
refreshSessions,
|
||||
renameThread,
|
||||
setThreadPinned,
|
||||
deleteThread,
|
||||
forkThread,
|
||||
sessionById,
|
||||
|
||||
@@ -7,6 +7,11 @@ export type SessionHistoryStatus =
|
||||
|
||||
export type SessionMetadata = {
|
||||
title?: string;
|
||||
/**
|
||||
* Favorited sessions. Stored in session metadata rather than desktop-local
|
||||
* state so every client reading the session sees the same flag.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
git?: {
|
||||
url?: string;
|
||||
branch?: string;
|
||||
@@ -14,6 +19,8 @@ export type SessionMetadata = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const PINNED_METADATA_KEY = "pinned";
|
||||
|
||||
export interface SessionHistoryItem {
|
||||
sessionId: string;
|
||||
source?: string;
|
||||
@@ -51,6 +58,10 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
|
||||
return typeof metadata.title === "string" ? metadata.title.trim() : "";
|
||||
}
|
||||
|
||||
export function getSessionMetadataPinned(metadata?: SessionMetadata): boolean {
|
||||
return metadata?.[PINNED_METADATA_KEY] === true;
|
||||
}
|
||||
|
||||
export function getSessionMetadataGitBranch(
|
||||
metadata?: SessionMetadata,
|
||||
): string {
|
||||
|
||||
@@ -228,11 +228,6 @@
|
||||
"command": "cline.openWalkthrough",
|
||||
"title": "Open Walkthrough",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
|
||||
@@ -75,6 +75,7 @@ enum ClineSay {
|
||||
USE_SUBAGENTS_SAY = 35;
|
||||
SUBAGENT_USAGE = 36;
|
||||
COMPACTION = 37;
|
||||
PLAN_COMPLETION_RESULT = 38;
|
||||
}
|
||||
|
||||
// Enum for ClineSayTool tool types
|
||||
|
||||
@@ -4,10 +4,68 @@ import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
/**
|
||||
* Merge a directory-scan result with the toggle state as it stands *after* the
|
||||
* scan. The scan in `synchronizeRuleToggles` is async, so state can change
|
||||
* while it runs:
|
||||
* - a toggle the user flips mid-scan must win over the stale snapshot value;
|
||||
* - an entry added mid-scan (e.g. a workflow file created via the modal) must
|
||||
* be kept even though the older scan didn't see the file;
|
||||
* - an entry removed from state mid-scan (e.g. the workflow was deleted via
|
||||
* the modal, which deletes the file and its entry) must stay removed even
|
||||
* though the older scan still saw the file;
|
||||
* - entries that existed before the scan but whose files the scan no longer
|
||||
* found are pruned (the file was deleted).
|
||||
*/
|
||||
function mergeToggleStateAfterScan(
|
||||
scanned: ClineRulesToggles,
|
||||
preScan: ClineRulesToggles,
|
||||
current: ClineRulesToggles,
|
||||
): ClineRulesToggles {
|
||||
const merged: ClineRulesToggles = {}
|
||||
for (const [key, value] of Object.entries(scanned)) {
|
||||
if (key in current) {
|
||||
merged[key] = current[key]
|
||||
} else if (!(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
// else: the entry was removed from state while the scan ran — keep it removed.
|
||||
}
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (!(key in scanned) && !(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes refresh runs. Overlapping refreshes (webview launch, the rules
|
||||
* modal opening, workflow file creation) would otherwise interleave their
|
||||
* scans and writes and could publish stale state; queueing them makes each
|
||||
* scan atomic relative to other refreshes, and any file create/delete that
|
||||
* happens mid-scan triggers its own refresh that queues behind the running
|
||||
* one and corrects the outcome. The merge in `mergeToggleStateAfterScan`
|
||||
* covers the remaining non-refresh writer: direct toggle flips.
|
||||
*/
|
||||
let refreshQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Refresh the workflow toggles
|
||||
*/
|
||||
export async function refreshWorkflowToggles(
|
||||
export function refreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
globalWorkflowToggles: ClineRulesToggles
|
||||
localWorkflowToggles: ClineRulesToggles
|
||||
}> {
|
||||
const run = refreshQueue.then(() => doRefreshWorkflowToggles(controller, workingDirectory))
|
||||
refreshQueue = run.catch(() => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
async function doRefreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
@@ -17,12 +75,24 @@ export async function refreshWorkflowToggles(
|
||||
// Global workflows
|
||||
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
|
||||
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
|
||||
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
const scannedGlobalToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
// Re-read state after the async scans: no `await` between here and the
|
||||
// writes below, so concurrent toggle updates cannot be lost.
|
||||
const updatedGlobalWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedGlobalToggles,
|
||||
globalWorkflowToggles,
|
||||
controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
|
||||
)
|
||||
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
|
||||
|
||||
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
|
||||
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const scannedWorkspaceToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const updatedWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedWorkspaceToggles,
|
||||
workflowRulesToggles,
|
||||
controller.stateManager.getWorkspaceStateKey("workflowToggles"),
|
||||
)
|
||||
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { GlobalStateAndSettings } from "@/shared/storage/state-keys"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import type { Controller } from "../index"
|
||||
import { refreshBasetenModels } from "../models/refreshBasetenModels"
|
||||
|
||||
@@ -20,6 +22,17 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
// Sync workflow toggles with the files on disk so the chat input's slash
|
||||
// command menu knows about workflows without requiring the user to open
|
||||
// the Workflows modal first (which is the only other place that refreshes
|
||||
// them). Fire-and-forget: the state post makes the toggles reach the webview.
|
||||
getCwd(getDesktopDir())
|
||||
.then(async (cwd) => {
|
||||
await refreshWorkflowToggles(controller, cwd)
|
||||
await controller.postStateToWebview()
|
||||
})
|
||||
.catch((error) => Logger.warn("Failed to refresh workflow toggles on webview launch:", error))
|
||||
|
||||
// Post last cached models as soon as possible for immediate availability in the UI
|
||||
const lastCachedModels = await controller.readOpenRouterModels()
|
||||
if (lastCachedModels) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type * as vscode from "vscode"
|
||||
import { migrateWelcomeViewCompleted } from "../state-migrations"
|
||||
|
||||
/** Minimal ExtensionContext exposing the stores the migration touches. */
|
||||
function makeContext(initial: { globalState?: Record<string, unknown>; secrets?: Record<string, string> } = {}) {
|
||||
const globalState = new Map<string, unknown>(Object.entries(initial.globalState ?? {}))
|
||||
const secrets = new Map<string, string>(Object.entries(initial.secrets ?? {}))
|
||||
const context = {
|
||||
globalState: {
|
||||
get: (key: string) => globalState.get(key),
|
||||
update: async (key: string, value: unknown) => {
|
||||
globalState.set(key, value)
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
get: async (key: string) => secrets.get(key),
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
return { context, globalState }
|
||||
}
|
||||
|
||||
let dataDir: string
|
||||
|
||||
/** Seed a file in the temp Cline data dir (e.g. globalState.json, secrets.json). */
|
||||
function writeDataFile(name: string, contents: unknown) {
|
||||
fs.writeFileSync(path.join(dataDir, name), JSON.stringify(contents), "utf-8")
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-state-migrations-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("migrateWelcomeViewCompleted", () => {
|
||||
it("leaves an already-set flag untouched", async () => {
|
||||
const { context, globalState } = makeContext({ globalState: { welcomeViewCompleted: true } })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(true)
|
||||
})
|
||||
|
||||
it("sets false when no configuration exists anywhere (fresh install)", async () => {
|
||||
const { context, globalState } = makeContext()
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(false)
|
||||
})
|
||||
|
||||
it("detects an API key in VS Code SecretStorage (pre-4.x upgrade path, unchanged)", async () => {
|
||||
const { context, globalState } = makeContext({ secrets: { apiKey: "sk-ant-123" } })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(true)
|
||||
})
|
||||
|
||||
it("detects an API key in the file-backed secrets.json (ENG-2346 regression)", async () => {
|
||||
const { context, globalState } = makeContext()
|
||||
writeDataFile("secrets.json", { openRouterApiKey: "sk-or-123" })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(true)
|
||||
})
|
||||
|
||||
it("detects keyless provider config in the file-backed globalState.json", async () => {
|
||||
const { context, globalState } = makeContext()
|
||||
writeDataFile("globalState.json", { awsRegion: "us-east-1" })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(true)
|
||||
})
|
||||
|
||||
it("honors welcomeViewCompleted=true already in the file-backed globalState.json", async () => {
|
||||
const { context, globalState } = makeContext()
|
||||
writeDataFile("globalState.json", { welcomeViewCompleted: true })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(true)
|
||||
})
|
||||
|
||||
it("ignores non-provider secrets (authNonce, mcpOAuthSecrets)", async () => {
|
||||
const { context, globalState } = makeContext()
|
||||
writeDataFile("secrets.json", { authNonce: "nonce", mcpOAuthSecrets: "{}" })
|
||||
await migrateWelcomeViewCompleted(context, dataDir)
|
||||
expect(globalState.get("welcomeViewCompleted")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { readGlobalState, readSecrets } from "@/sdk/legacy-state-reader"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ensureRulesDirectoryExists } from "./disk"
|
||||
|
||||
@@ -115,7 +116,7 @@ export async function migrateCustomInstructionsToGlobalRules(context: vscode.Ext
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateWelcomeViewCompleted(context: vscode.ExtensionContext) {
|
||||
export async function migrateWelcomeViewCompleted(context: vscode.ExtensionContext, dataDir?: string) {
|
||||
try {
|
||||
// Check if welcomeViewCompleted is already set
|
||||
const welcomeViewCompleted = context.globalState.get("welcomeViewCompleted")
|
||||
@@ -157,6 +158,28 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
|
||||
const planModeVsCodeLmModelSelector = context.globalState.get("planModeVsCodeLmModelSelector")
|
||||
const actModeVsCodeLmModelSelector = context.globalState.get("actModeVsCodeLmModelSelector")
|
||||
|
||||
// ENG-2346: The live 4.x extension persists provider config in the shared
|
||||
// file-backed stores (~/.cline/data/globalState.json + secrets.json), not in
|
||||
// VS Code storage — so for users upgrading from it, every value above is
|
||||
// undefined. Also consider the file-backed stores (same signals: the
|
||||
// completed flag itself, any provider secret, or the keyless provider
|
||||
// configs), otherwise fully configured users are sent back through onboarding.
|
||||
const fileGlobalState = readGlobalState(dataDir)
|
||||
const fileSecrets: Record<string, string | undefined> = readSecrets(dataDir)
|
||||
const hasFileBackedConfig =
|
||||
fileGlobalState.welcomeViewCompleted === true ||
|
||||
Object.entries(fileSecrets).some(([key, value]) => key !== "authNonce" && key !== "mcpOAuthSecrets" && !!value) ||
|
||||
[
|
||||
fileGlobalState.awsRegion,
|
||||
fileGlobalState.vertexProjectId,
|
||||
fileGlobalState.planModeOllamaModelId,
|
||||
fileGlobalState.planModeLmStudioModelId,
|
||||
fileGlobalState.actModeOllamaModelId,
|
||||
fileGlobalState.actModeLmStudioModelId,
|
||||
fileGlobalState.planModeVsCodeLmModelSelector,
|
||||
fileGlobalState.actModeVsCodeLmModelSelector,
|
||||
].some((value) => value !== undefined)
|
||||
|
||||
// This is the original logic used for checking if the welcome view should be shown
|
||||
// It was located in the ExtensionStateContextProvider
|
||||
const hasKey = [
|
||||
@@ -191,10 +214,12 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
|
||||
openAiCodexCredentials,
|
||||
].some((key) => key !== undefined)
|
||||
|
||||
// Set welcomeViewCompleted based on whether user has keys
|
||||
await context.globalState.update("welcomeViewCompleted", hasKey)
|
||||
const completed = hasKey || hasFileBackedConfig
|
||||
|
||||
Logger.log(`Migration: Set welcomeViewCompleted to ${hasKey} based on existing API keys`)
|
||||
// Set welcomeViewCompleted based on whether user has keys
|
||||
await context.globalState.update("welcomeViewCompleted", completed)
|
||||
|
||||
Logger.log(`Migration: Set welcomeViewCompleted to ${completed} based on existing API keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to migrate welcomeViewCompleted:", error)
|
||||
|
||||
@@ -26,7 +26,6 @@ const ClineCommands = {
|
||||
Walkthrough: prefix + ".openWalkthrough",
|
||||
GenerateCommit: prefix + ".generateGitCommitMessage",
|
||||
AbortCommit: prefix + ".abortGitCommitMessage",
|
||||
ReconstructTaskHistory: prefix + ".reconstructTaskHistory",
|
||||
// Jupyter Notebook commands
|
||||
JupyterGenerateCell: prefix + ".jupyterGenerateCell",
|
||||
JupyterExplainCell: prefix + ".jupyterExplainCell",
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
isSyntheticSdkUserMessage,
|
||||
type SdkUserMessage,
|
||||
} from "./sdk-user-message-mapping"
|
||||
import { buildDisabledWorkflowNames, expandSlashCommands } from "./slash-command-expansion"
|
||||
import { StatePostDebouncer } from "./state-post-debouncer"
|
||||
import { createTaskProxy, type TaskProxy } from "./task-proxy"
|
||||
import { syncTelemetrySettingFromSharedGlobalSettings } from "./telemetry-settings-sync"
|
||||
@@ -282,8 +283,13 @@ export class Controller {
|
||||
this.ocaAuthService = OcaAuthService.initialize(this)
|
||||
this.accountService = ClineAccountService.getInstance()
|
||||
|
||||
// Initialize message translator state
|
||||
this.messageTranslatorState = new MessageTranslatorState(undefined, () => this.getActiveProviderId())
|
||||
// Initialize message translator state. The mode getter styles the inferred turn-final
|
||||
// completion row (plan → yellow plan box, act → green completion box).
|
||||
this.messageTranslatorState = new MessageTranslatorState(
|
||||
undefined,
|
||||
() => this.getActiveProviderId(),
|
||||
() => (this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"),
|
||||
)
|
||||
// Authoritative UI-mode tracker, sharing the one id/seq/epoch authority.
|
||||
this.turnStateTracker = new TurnStateTracker(this.messageTranslatorState.getMinter())
|
||||
this.messages = new SdkMessageCoordinator({
|
||||
@@ -552,6 +558,7 @@ export class Controller {
|
||||
this.messageTranslatorState.clearApprovedToolMessageTs()
|
||||
this.messageTranslatorState.getMinter().bumpEpoch()
|
||||
},
|
||||
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.taskStart = new SdkTaskStartCoordinator({
|
||||
@@ -835,9 +842,13 @@ export class Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading `/workflow` or `/skill` slash command into its instruction
|
||||
* body. Mirrors the CLI's `buildUserInputMessage`. Returns the input unchanged
|
||||
* if it is not a known command or expansion fails.
|
||||
* Expand a `/workflow` or `/skill` slash command into its instruction body.
|
||||
* Serves the same purpose as the CLI's `buildUserInputMessage`, but is more
|
||||
* permissive than the SDK's leading-only resolver: it accepts the legacy
|
||||
* `/my-workflow.md` spelling the webview autocomplete inserts, matches
|
||||
* commands mid-message (anything the chat input highlights as a command),
|
||||
* and honors the user's workflow enable/disable toggles. Returns the input
|
||||
* unchanged if no known command matches or expansion fails.
|
||||
*/
|
||||
private async resolveSlashCommands(text: string): Promise<string> {
|
||||
if (this.isDisposed) {
|
||||
@@ -846,7 +857,18 @@ export class Controller {
|
||||
try {
|
||||
const workspaceRoot = await this.getWorkspaceRoot()
|
||||
const service = await this.ensureUserInstructionService(workspaceRoot)
|
||||
return service.resolveRuntimeSlashCommand(text)
|
||||
const remoteWorkflows = this.stateManager.getRemoteConfigSettings()?.remoteGlobalWorkflows ?? []
|
||||
const workflowRecords = service
|
||||
.listRecords("workflow")
|
||||
.map((record) => ({ name: record.item.name, filePath: record.filePath }))
|
||||
const disabledWorkflowNames = buildDisabledWorkflowNames({
|
||||
records: workflowRecords,
|
||||
globalToggles: this.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
|
||||
workspaceToggles: this.stateManager.getWorkspaceStateKey("workflowToggles"),
|
||||
remoteToggles: this.stateManager.getGlobalStateKey("remoteWorkflowToggles"),
|
||||
remoteAlwaysEnabledNames: remoteWorkflows.filter((workflow) => workflow.alwaysEnabled).map((w) => w.name),
|
||||
})
|
||||
return expandSlashCommands(text, service.listRuntimeCommands(), { disabledWorkflowNames, workflowRecords })
|
||||
} catch (error) {
|
||||
Logger.warn("[SdkController] Slash command resolution failed, using raw text:", error)
|
||||
return text
|
||||
@@ -1546,14 +1568,18 @@ export class Controller {
|
||||
* 1. Silently tear down the active session (unsubscribe + stop in background)
|
||||
* 2. Create the new task proxy with loaded messages BEFORE any state push
|
||||
* 3. Only then push state to the webview
|
||||
*
|
||||
* Delegates straight to the coordinator (including the history lookup) so
|
||||
* the "latest selection wins" generation is allocated synchronously at the
|
||||
* moment of the request — awaiting the lookup here first would let a
|
||||
* stalled older request grab a NEWER generation than a later selection and
|
||||
* replace it.
|
||||
*/
|
||||
async showTaskWithId(taskId: string): Promise<TaskResponse> {
|
||||
const historyItem = await this.taskHistory.findHistoryItem(taskId)
|
||||
const historyItem = await this.taskControl.showTaskWithId(taskId)
|
||||
if (!historyItem) {
|
||||
throw new Error(`Task not found in history: ${taskId}`)
|
||||
}
|
||||
|
||||
await this.taskControl.showTaskWithId(taskId, { skipHistoryLookup: true })
|
||||
return historyItemToTaskResponse(historyItem)
|
||||
}
|
||||
|
||||
@@ -1918,6 +1944,11 @@ export class Controller {
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
foregroundCommandRunning: this.foregroundCommands.isRunning,
|
||||
// Without this the webview always receives workspaceRoots: [] on the
|
||||
// SDK path (classic Controller exposes a public workspaceManager;
|
||||
// SdkController builds one lazily). The task-header working-directory
|
||||
// badge and anything else keyed on workspaceRoots depend on it.
|
||||
workspaceManager: await this.ensureWorkspaceManager(),
|
||||
})
|
||||
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
|
||||
.map(sessionHistoryRecordToHistoryItem)
|
||||
|
||||
@@ -422,6 +422,105 @@ describe("buildSessionConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("forwards the regional API line from legacy state so the gateway can route to the regional endpoint", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "zai",
|
||||
actModeApiModelId: "glm-5.2",
|
||||
zaiApiKey: "zai-key",
|
||||
zaiApiLine: "china",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerId).toBe("zai")
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "zai",
|
||||
apiLine: "china",
|
||||
})
|
||||
// No explicit base URL: the SDK gateway resolves the China endpoint
|
||||
// (open.bigmodel.cn) from apiLine; a pre-filled base URL would win
|
||||
// over that resolution.
|
||||
expect(config.baseUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it("falls back to the providers.json apiLine when legacy state has none", async () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
|
||||
if (providerId !== "moonshot") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "moonshot",
|
||||
apiKey: "moonshot-key",
|
||||
apiLine: "china",
|
||||
} as any
|
||||
})
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "moonshot",
|
||||
actModeApiModelId: "kimi-k3",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "moonshot",
|
||||
apiLine: "china",
|
||||
})
|
||||
})
|
||||
|
||||
it("inherits the base provider's legacy apiLine for coding variants", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "zai-coding-plan",
|
||||
actModeApiModelId: "glm-5.2",
|
||||
zaiApiKey: "zai-key",
|
||||
zaiApiLine: "china",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "zai-coding-plan",
|
||||
apiLine: "china",
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers the coding variant's own providers.json apiLine over the shared legacy field", async () => {
|
||||
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
|
||||
if (providerId !== "qwen-code") {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
provider: "qwen-code",
|
||||
apiKey: "qwen-code-key",
|
||||
apiLine: "international",
|
||||
} as any
|
||||
})
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "qwen-code",
|
||||
actModeApiModelId: "qwen3-coder-plus",
|
||||
qwenApiLine: "china",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).toMatchObject({
|
||||
providerId: "qwen-code",
|
||||
apiLine: "international",
|
||||
})
|
||||
})
|
||||
|
||||
it("omits apiLine for unrecognized values", async () => {
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "qwen",
|
||||
actModeApiModelId: "qwen-plus-latest",
|
||||
qwenApiKey: "qwen-key",
|
||||
qwenApiLine: "mars",
|
||||
} as any)
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.providerConfig).not.toHaveProperty("apiLine")
|
||||
})
|
||||
|
||||
it("exposes knownModels at the top level so manual compaction can budget against the model catalog", async () => {
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
|
||||
import type { ProviderApiLine, ModelInfo as SdkModelInfo } from "@cline/llms"
|
||||
import {
|
||||
getGeneratedModelsForProvider,
|
||||
getModelsForProvider,
|
||||
isProviderApiLine,
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@cline/llms"
|
||||
@@ -649,6 +650,61 @@ export function resolveBaseUrl(providerId: string, config: ApiConfiguration): st
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the regional API line ("china" | "international") for providers with
|
||||
* regional endpoints (Qwen, Moonshot, Z AI, MiniMax and their coding
|
||||
* variants). Resolution order:
|
||||
*
|
||||
* 1. The provider's own legacy StateManager field (mirroring resolveBaseUrl).
|
||||
* 2. The provider's own providers.json `apiLine` (SDK-store fallback).
|
||||
* 3. For coding variants without their own legacy field or stored line, the
|
||||
* base provider's legacy field (qwen-code shares Qwen's DashScope region,
|
||||
* zai-coding-plan shares Z AI's account region) — so a variant-specific
|
||||
* providers.json setting still wins over the shared field.
|
||||
*
|
||||
* The SDK gateway maps the line to the provider's regional base URL when no
|
||||
* explicit base URL is configured.
|
||||
*/
|
||||
export function resolveApiLine(providerId: string, config: ApiConfiguration): ProviderApiLine | undefined {
|
||||
const apiLineMap: Record<string, keyof ApiConfiguration> = {
|
||||
qwen: "qwenApiLine",
|
||||
moonshot: "moonshotApiLine",
|
||||
zai: "zaiApiLine",
|
||||
minimax: "minimaxApiLine",
|
||||
}
|
||||
const sharedApiLineMap: Record<string, keyof ApiConfiguration> = {
|
||||
"qwen-code": "qwenApiLine",
|
||||
"zai-coding-plan": "zaiApiLine",
|
||||
}
|
||||
|
||||
const field = apiLineMap[providerId]
|
||||
if (field) {
|
||||
const fromState = config[field]
|
||||
if (isProviderApiLine(fromState)) {
|
||||
return fromState
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsApiLine = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.apiLine
|
||||
if (isProviderApiLine(settingsApiLine)) {
|
||||
return settingsApiLine
|
||||
}
|
||||
} catch {
|
||||
Logger.warn(`[SessionFactory] Failed to read ${providerId} API line from providers.json`)
|
||||
}
|
||||
|
||||
const sharedField = sharedApiLineMap[providerId]
|
||||
if (sharedField) {
|
||||
const fromSharedState = config[sharedField]
|
||||
if (isProviderApiLine(fromSharedState)) {
|
||||
return fromSharedState
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session config builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -677,6 +733,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
let modelId: string | undefined
|
||||
let apiKey: string | undefined
|
||||
let baseUrl: string | undefined
|
||||
let apiLine: ProviderApiLine | undefined
|
||||
let apiConfig: ApiConfiguration | undefined
|
||||
// Cloud-provider structured options. The core runtime reads these from
|
||||
// CoreSessionConfig.providerConfig; without them the SDK gateway never receives
|
||||
@@ -707,6 +764,11 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
// Resolve base URL
|
||||
baseUrl = resolveBaseUrl(providerId, apiConfig)
|
||||
|
||||
// Resolve the regional API line (Qwen/Moonshot/Z AI/MiniMax). The
|
||||
// SDK gateway routes to the line's regional endpoint when no
|
||||
// explicit base URL is set.
|
||||
apiLine = resolveApiLine(providerId, apiConfig)
|
||||
|
||||
// Resolve Bedrock region + AWS authentication options from the legacy
|
||||
// ApiConfiguration (StateManager is the VSCode source of truth, not
|
||||
// providers.json).
|
||||
@@ -754,6 +816,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
modelId = lastUsed.model
|
||||
apiKey = lastUsed.apiKey
|
||||
baseUrl = lastUsed.baseUrl
|
||||
apiLine = isProviderApiLine(lastUsed.apiLine) ? lastUsed.apiLine : undefined
|
||||
Logger.log(`[SessionFactory] Using SDK provider fallback: ${providerId}/${modelId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -875,6 +938,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
modelId,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(baseUrl !== undefined ? { baseUrl } : {}),
|
||||
...(apiLine !== undefined ? { apiLine } : {}),
|
||||
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
|
||||
fetch,
|
||||
}
|
||||
|
||||
@@ -1073,6 +1073,133 @@ describe("translateSessionEvent — agent_event done", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translateSessionEvent — inferred turn-final completion retag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("translateSessionEvent — inferred turn-final completion", () => {
|
||||
const agentEvent = (event: Partial<AgentEvent> & { type: string }): CoreSessionEvent =>
|
||||
({
|
||||
type: "agent_event",
|
||||
payload: { sessionId: "session-1", event: event as AgentEvent },
|
||||
}) as CoreSessionEvent
|
||||
|
||||
const endText = (state: MessageTranslatorState, text: string) =>
|
||||
translateSessionEvent(agentEvent({ type: "content_end", contentType: "text", text }), state)
|
||||
|
||||
const done = (state: MessageTranslatorState, reason: "completed" | "aborted" | "error" = "completed") =>
|
||||
translateSessionEvent(agentEvent({ type: "done", reason, text: "", iterations: 1 }), state)
|
||||
|
||||
it("retags the turn-final text to plan_completion_result in plan mode", () => {
|
||||
const state = new MessageTranslatorState(undefined, undefined, () => "plan")
|
||||
const textResult = endText(state, "Here is the plan.")
|
||||
|
||||
const doneResult = done(state)
|
||||
|
||||
expect(doneResult.messages).toHaveLength(1)
|
||||
expect(doneResult.messages[0]).toMatchObject({
|
||||
ts: textResult.messages[0].ts,
|
||||
type: "say",
|
||||
say: "plan_completion_result",
|
||||
text: "Here is the plan.",
|
||||
partial: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not retag when the turn ends on a tool call after the text", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
endText(state, "Switching over now.")
|
||||
|
||||
// e.g. switch_to_act_mode (lifecycle.completesRun) ends the turn after the tool
|
||||
translateSessionEvent(
|
||||
agentEvent({ type: "content_start", contentType: "tool", toolName: "switch_to_act_mode", input: {} }),
|
||||
state,
|
||||
)
|
||||
translateSessionEvent(
|
||||
agentEvent({ type: "content_end", contentType: "tool", toolName: "switch_to_act_mode", output: "ok" }),
|
||||
state,
|
||||
)
|
||||
|
||||
expect(done(state).messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("does not retag an aborted or errored turn", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
endText(state, "Halfway through...")
|
||||
expect(done(state, "aborted").messages).toHaveLength(0)
|
||||
|
||||
// The abort cleared the candidate — a later stray done must not resurrect it.
|
||||
expect(done(state).messages).toHaveLength(0)
|
||||
|
||||
endText(state, "Almost there...")
|
||||
const errorResult = translateSessionEvent(
|
||||
agentEvent({ type: "error", error: new Error("boom"), recoverable: false }),
|
||||
state,
|
||||
)
|
||||
expect(errorResult.messages.some((m) => m.say === "completion_result")).toBe(false)
|
||||
expect(done(state).messages.filter((m) => m.say === "completion_result")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("retags only the last finalized text of the turn (text → tool → text)", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
endText(state, "Let me check the file first.")
|
||||
translateSessionEvent(
|
||||
agentEvent({
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "call-1",
|
||||
input: { path: "/a.ts" },
|
||||
}),
|
||||
state,
|
||||
)
|
||||
translateSessionEvent(
|
||||
agentEvent({
|
||||
type: "content_end",
|
||||
contentType: "tool",
|
||||
toolName: "read_files",
|
||||
toolCallId: "call-1",
|
||||
output: "contents",
|
||||
}),
|
||||
state,
|
||||
)
|
||||
const finalText = endText(state, "All done — the file looks good.")
|
||||
|
||||
const doneResult = done(state)
|
||||
expect(doneResult.messages).toHaveLength(1)
|
||||
expect(doneResult.messages[0]).toMatchObject({
|
||||
ts: finalText.messages[0].ts,
|
||||
say: "completion_result",
|
||||
text: "All done — the file looks good.",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not retag when the completion tool already rendered the green box", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
endText(state, "Wrapping up.")
|
||||
translateSessionEvent(
|
||||
agentEvent({
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "attempt_completion",
|
||||
input: { result: "Done!" },
|
||||
}),
|
||||
state,
|
||||
)
|
||||
translateSessionEvent(agentEvent({ type: "content_end", contentType: "tool", toolName: "attempt_completion" }), state)
|
||||
|
||||
expect(done(state).messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("clearTurnOutcome drops a stale candidate from the previous turn", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
endText(state, "Previous turn's answer.")
|
||||
state.clearTurnOutcome() // new user turn begins
|
||||
|
||||
expect(done(state).messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translateSessionEvent — agent_event (error)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1106,6 +1233,45 @@ describe("translateSessionEvent — agent_event error", () => {
|
||||
expect(result.turnComplete).toBe(true)
|
||||
})
|
||||
|
||||
it("records the error outcome when the turn terminates with done(reason:'error')", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "error",
|
||||
text: "stream failed before assistant output",
|
||||
iterations: 1,
|
||||
} as AgentEvent,
|
||||
},
|
||||
}
|
||||
|
||||
const result = translateSessionEvent(event, state)
|
||||
expect(result.turnComplete).toBe(true)
|
||||
expect(state.wasErrorSeen()).toBe(true)
|
||||
})
|
||||
|
||||
it("does not record an error outcome for a successful done event", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "",
|
||||
iterations: 1,
|
||||
} as AgentEvent,
|
||||
},
|
||||
}
|
||||
|
||||
translateSessionEvent(event, state)
|
||||
expect(state.wasErrorSeen()).toBe(false)
|
||||
})
|
||||
|
||||
it("reshapes insufficient_credits error into ClineError-compatible format", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const errorJson = JSON.stringify({
|
||||
@@ -1503,8 +1669,9 @@ describe("translateSessionEvent — full streaming flow", () => {
|
||||
expect(endResult.messages[0].partial).toBe(false)
|
||||
expect(endResult.messages[0].text).toBe("Hello world!")
|
||||
|
||||
// 3. Done — without attempt_completion, emits ask:"completion_result"
|
||||
// with empty text (no green rectangle, just enables follow-up input)
|
||||
// 3. Done — the turn ended cleanly on a text response, so the final text row is
|
||||
// retagged in place (same ts) to say:"completion_result" for the green
|
||||
// "Task Completed" box (act mode is the default when no mode source is provided).
|
||||
const doneResult = translateSessionEvent(
|
||||
{
|
||||
type: "agent_event",
|
||||
@@ -1520,7 +1687,14 @@ describe("translateSessionEvent — full streaming flow", () => {
|
||||
},
|
||||
state,
|
||||
)
|
||||
expect(doneResult.messages).toHaveLength(0)
|
||||
expect(doneResult.messages).toHaveLength(1)
|
||||
expect(doneResult.messages[0]).toMatchObject({
|
||||
ts: endResult.messages[0].ts,
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: "Hello world!",
|
||||
partial: false,
|
||||
})
|
||||
expect(doneResult.turnComplete).toBe(true)
|
||||
})
|
||||
|
||||
@@ -2963,6 +3137,30 @@ describe("sdkToolToClineSayTool — editor diff rendering (S6-48)", () => {
|
||||
expect(tool.content).toBe("export const x = 1")
|
||||
})
|
||||
|
||||
it("editor with insert_line is an edit of an existing file, not a new-file creation", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
event: {
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "editor",
|
||||
toolCallId: "call-insert",
|
||||
// A prepend/insert: new_text present, no old_text, but insert_line set.
|
||||
// The SDK editor executor requires the file to already exist for insert,
|
||||
// so this must map to editedExistingFile (not newFileCreated).
|
||||
input: { path: "/src/existing.ts", new_text: "// prepended", insert_line: 1 },
|
||||
} as AgentEvent,
|
||||
},
|
||||
}
|
||||
const result = translateSessionEvent(event, state)
|
||||
const tool = JSON.parse(result.messages[0].text!)
|
||||
expect(tool.tool).toBe("editedExistingFile")
|
||||
expect(tool.path).toBe("/src/existing.ts")
|
||||
})
|
||||
|
||||
it("S6-48: editor with old_str/new_str also builds search/replace diff", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const event: CoreSessionEvent = {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
// - SDK "agent_event" content_end → ClineMessage with partial=false
|
||||
// - SDK "agent_event" content_start (tool: attempt_completion) → ClineMessage say="completion_result"
|
||||
// - SDK "agent_event" content_end (tool: attempt_completion) → ClineMessage say="completion_result" (final)
|
||||
// - SDK "agent_event" done → ClineMessage ask="completion_result" (always; must be last message)
|
||||
// - SDK "agent_event" done (reason "completed", turn ended on text) → retags that final
|
||||
// say="text" row in place to say="completion_result" (act) / say="plan_completion_result" (plan)
|
||||
// - SDK "agent_event" error → ClineMessage say="error"
|
||||
// - SDK "agent_event" usage → ClineMessage say="api_req_started" with ClineApiReqInfo JSON
|
||||
// - SDK "ended" event → finalizes the session
|
||||
@@ -144,6 +145,7 @@ export class MessageTranslatorState {
|
||||
constructor(
|
||||
minter: MessageIdMinter = new MessageIdMinter(),
|
||||
private readonly getActiveProviderId?: () => string | undefined,
|
||||
private readonly getUiMode?: () => "plan" | "act" | "yolo" | undefined,
|
||||
) {
|
||||
this.minter = minter
|
||||
}
|
||||
@@ -153,6 +155,15 @@ export class MessageTranslatorState {
|
||||
return this.getActiveProviderId?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan/act mode governing the current turn, used to style the inferred turn-final
|
||||
* response (plan → yellow plan box, act/yolo → green completion box).
|
||||
* Defaults to act when the host doesn't supply a mode source.
|
||||
*/
|
||||
currentUiMode(): "plan" | "act" {
|
||||
return this.getUiMode?.() === "plan" ? "plan" : "act"
|
||||
}
|
||||
|
||||
/** The shared minter, exposed so coordinators and history rendering mint from the same source. */
|
||||
getMinter(): MessageIdMinter {
|
||||
return this.minter
|
||||
@@ -310,6 +321,54 @@ export class MessageTranslatorState {
|
||||
return this.attemptCompletionSeen
|
||||
}
|
||||
|
||||
/** Whether a provider/agent error surfaced in this turn (ask:"api_req_failed" emitted) */
|
||||
private errorSeen = false
|
||||
|
||||
/** Mark that this turn surfaced an error */
|
||||
setErrorSeen(): void {
|
||||
this.errorSeen = true
|
||||
}
|
||||
|
||||
/** Check if this turn surfaced an error — drives the "error" turn phase (Retry / New Task) */
|
||||
wasErrorSeen(): boolean {
|
||||
return this.errorSeen
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Turn-final text tracking — the SDK agent usually ends a turn with a plain
|
||||
// text response instead of a completion tool. When a turn ends cleanly with
|
||||
// text as its last content, that text row is retagged in place (same ts) to
|
||||
// say:"completion_result" (act) or say:"plan_completion_result" (plan) so
|
||||
// the webview shows the legacy-style completion feedback box.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** ts of the last finalized (non-partial, non-empty) text message of the current turn */
|
||||
private turnFinalTextTs: number | undefined
|
||||
/** Text of the message tracked by turnFinalTextTs */
|
||||
private turnFinalText = ""
|
||||
|
||||
/** Remember the most recent finalized text as the candidate turn-final response. */
|
||||
recordTurnFinalText(ts: number, text: string): void {
|
||||
this.turnFinalTextTs = ts
|
||||
this.turnFinalText = text
|
||||
}
|
||||
|
||||
/** Forget the candidate turn-final text (tool activity means the turn didn't end on it). */
|
||||
clearTurnFinalText(): void {
|
||||
this.turnFinalTextTs = undefined
|
||||
this.turnFinalText = ""
|
||||
}
|
||||
|
||||
/** Take (and clear) the candidate turn-final text, if any. */
|
||||
takeTurnFinalText(): { ts: number; text: string } | undefined {
|
||||
if (this.turnFinalTextTs === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const result = { ts: this.turnFinalTextTs, text: this.turnFinalText }
|
||||
this.clearTurnFinalText()
|
||||
return result
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// spawn_agent tracking — aggregates parallel spawn_agent tool calls into
|
||||
// the rich SubagentStatusRow UI (use_subagents + subagent messages).
|
||||
@@ -431,12 +490,15 @@ export class MessageTranslatorState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear turn-outcome signals (`attemptCompletionSeen`). Called at a new user turn / task
|
||||
* boundary so each turn's phase is computed fresh; it is intentionally separate from the
|
||||
* per-iteration `reset()` so the completion signal persists across the iterations of one turn.
|
||||
* Clear turn-outcome signals (`attemptCompletionSeen`, the turn-final text candidate).
|
||||
* Called at a new user turn / task boundary so each turn's phase is computed fresh; it is
|
||||
* intentionally separate from the per-iteration `reset()` so the completion signal persists
|
||||
* across the iterations of one turn.
|
||||
*/
|
||||
clearTurnOutcome(): void {
|
||||
this.attemptCompletionSeen = false
|
||||
this.errorSeen = false
|
||||
this.clearTurnFinalText()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +570,11 @@ function sdkToolToClineSayTool(toolName: string, input?: unknown): ClineSayTool
|
||||
getStringField(parsedInput, "content")
|
||||
const patch = getStringField(parsedInput, "patch") ?? getStringField(parsedInput, "diff")
|
||||
const oldText = getStringField(parsedInput, "old_text") ?? getStringField(parsedInput, "old_str")
|
||||
const isEdit = toolName === "replace_in_file" || !!oldText
|
||||
// `insert_line` inserts into an existing file (the SDK editor executor requires
|
||||
// the file to already exist), so it is an edit — not a new-file creation. Without
|
||||
// this the card mislabels a prepend/insert as "Cline wants to create a new file".
|
||||
const insertLine = getNumberField(parsedInput, "insert_line")
|
||||
const isEdit = toolName === "replace_in_file" || !!oldText || insertLine != null
|
||||
|
||||
// When the SDK provides both old and new text, build a search/replace
|
||||
// diff in the format DiffEditRow expects. ChatRow passes `content` to
|
||||
@@ -671,8 +737,9 @@ function parseToolInput(input: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
/**
|
||||
* Whether a tool name is the agent's completion tool — the one that declares the task done and
|
||||
* drives the green "Task Completed" box plus the `completed` turn phase. Two names are accepted:
|
||||
* the VSCode extra tool `attempt_completion` and the SDK's built-in `submit_and_exit`
|
||||
* drives the green completion box plus the `completed` turn phase. Two names are accepted:
|
||||
* the legacy VSCode extra tool `attempt_completion` (no longer registered for new sessions,
|
||||
* but still present in persisted transcripts) and the SDK's built-in `submit_and_exit`
|
||||
* (DefaultToolNames.SUBMIT_AND_EXIT, lifecycle.completesRun=true).
|
||||
*/
|
||||
function isCompletionTool(toolName: string): boolean {
|
||||
@@ -1065,6 +1132,10 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
const toolName = event.toolName ?? "unknown"
|
||||
const input = event.input
|
||||
|
||||
// Tool activity after a text block means that text wasn't the
|
||||
// turn-final response — drop the retag candidate.
|
||||
state.clearTurnFinalText()
|
||||
|
||||
if (state.isToolApprovalDenied(event.toolCallId)) {
|
||||
break
|
||||
}
|
||||
@@ -1086,7 +1157,7 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
}
|
||||
|
||||
// The completion tool (attempt_completion / submit_and_exit) is handled specially:
|
||||
// it drives the green "Task Completed" rectangle. We emit say:"completion_result"
|
||||
// it drives the green completion box. We emit say:"completion_result"
|
||||
// here (partial) and finalize it at content_end. Recording attemptCompletionSeen
|
||||
// makes the turn end in the "completed" phase ("Start New Task") rather than
|
||||
// "awaiting_followup".
|
||||
@@ -1228,13 +1299,19 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
switch (event.contentType) {
|
||||
case "text": {
|
||||
const ts = state.clearStreamingText()
|
||||
const finalText = event.text ?? ""
|
||||
messages.push({
|
||||
ts,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: event.text ?? "",
|
||||
text: finalText,
|
||||
partial: false,
|
||||
})
|
||||
// Candidate for the turn-final response: if the turn ends cleanly with
|
||||
// this text as its last content, `done` retags it as a completion row.
|
||||
if (finalText.trim()) {
|
||||
state.recordTurnFinalText(ts, finalText)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "reasoning": {
|
||||
@@ -1253,6 +1330,10 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
case "tool": {
|
||||
const toolName = event.toolName ?? "unknown"
|
||||
|
||||
// A completed tool call after a text block means that text wasn't the
|
||||
// turn-final response — drop the retag candidate.
|
||||
state.clearTurnFinalText()
|
||||
|
||||
if (state.checkDeniedToolApproval(event.toolCallId) || isKnownToolApprovalDenial(event.error)) {
|
||||
state.clearStreamingTool()
|
||||
break
|
||||
@@ -1334,14 +1415,14 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
}
|
||||
|
||||
// Completion tool (attempt_completion / submit_and_exit) → finalize the green
|
||||
// "Task Completed" rectangle. The partial say:"completion_result" was emitted at
|
||||
// completion box. The partial say:"completion_result" was emitted at
|
||||
// content_start; here we emit the non-partial version.
|
||||
if (isCompletionTool(toolName)) {
|
||||
const storedInput = state.getStreamingToolInput()
|
||||
const ts = state.clearStreamingTool()
|
||||
const resultText = getCompletionResultText(storedInput)
|
||||
// Finalize the say:"completion_result" (non-partial)
|
||||
// This renders the green "Task Completed" rectangle.
|
||||
// This renders the green completion box.
|
||||
messages.push({
|
||||
ts,
|
||||
type: "say",
|
||||
@@ -1555,22 +1636,55 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
|
||||
}
|
||||
|
||||
case "done": {
|
||||
// Agent turn is complete. This emits no transcript message — it only signals
|
||||
// turnComplete to the caller. UI mode comes from the authoritative TurnState the
|
||||
// Agent turn is complete. Footer/buttons come from the authoritative TurnState the
|
||||
// session-event coordinator sets on turn end (completed when the completion tool was
|
||||
// used this turn, otherwise awaiting_followup), and the green "Task Completed" box
|
||||
// comes from the say:"completion_result" emitted at the completion tool's content_end.
|
||||
// used this turn, otherwise awaiting_followup) — never from the message tail.
|
||||
// A compaction divider still open here means the turn was aborted mid-compaction.
|
||||
finalizeDanglingCompaction(state, messages, "cancelled")
|
||||
|
||||
// A turn can terminate with done(reason:"error") without a separate
|
||||
// "error" event — record the error outcome here too so turn end still
|
||||
// resolves to the "error" phase (Retry / Start New Task).
|
||||
if (event.reason === "error") {
|
||||
state.setErrorSeen()
|
||||
}
|
||||
|
||||
// Inferred completion feedback: the SDK agent normally ends a turn with a plain
|
||||
// text response rather than a completion tool. When the turn ended cleanly and its
|
||||
// last content was text, retag that text row in place (same ts → upserted by the
|
||||
// message store / webview reducer) so the user gets the legacy-style "done" visual:
|
||||
// green box in act mode, yellow plan box in plan mode. Turns
|
||||
// that ended via the completion tool already rendered their green box at the tool's
|
||||
// content_end; aborted/errored turns keep their plain text.
|
||||
if (event.reason === "completed" && !state.wasAttemptCompletionSeen()) {
|
||||
const finalText = state.takeTurnFinalText()
|
||||
if (finalText) {
|
||||
messages.push({
|
||||
ts: finalText.ts,
|
||||
type: "say",
|
||||
say: state.currentUiMode() === "plan" ? "plan_completion_result" : "completion_result",
|
||||
text: finalText.text,
|
||||
partial: false,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
state.clearTurnFinalText()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "error": {
|
||||
finalizeDanglingCompaction(state, messages, "failed")
|
||||
// An errored turn didn't end on its text response — no completion retag.
|
||||
state.clearTurnFinalText()
|
||||
if (state.isSuppressedToolApprovalDenial(event.error)) {
|
||||
break
|
||||
}
|
||||
|
||||
// Record the error outcome so turn end resolves to the "error" phase
|
||||
// (footer shows Retry / Start New Task) instead of awaiting_followup.
|
||||
state.setErrorSeen()
|
||||
|
||||
// Serialize the error message for the webview's ErrorRow to parse.
|
||||
// The webview uses ClineError.parse() on the `api_req_failed` text to
|
||||
// detect special error types (insufficient credits, spend limit, auth,
|
||||
@@ -1804,6 +1918,12 @@ type SdkMessageWithMetrics = SdkMessage & {
|
||||
cacheWriteTokens?: number
|
||||
cost?: number
|
||||
}
|
||||
/**
|
||||
* Plan/act mode recovered from the persisted <user_input mode="..."> wrapper before display
|
||||
* sanitization strips it (see sanitizeSdkUserMessagesForDisplay in sdk-task-history.ts).
|
||||
* Only meaningful on user messages; governs the turn that follows.
|
||||
*/
|
||||
uiMode?: "plan" | "act" | "yolo"
|
||||
}
|
||||
|
||||
function textContentBlocksToText(content: SdkMessage["content"]): string {
|
||||
@@ -1910,16 +2030,35 @@ function finalizePersistedToolUse(
|
||||
)
|
||||
}
|
||||
|
||||
export interface SdkMessagesToClineMessagesOptions {
|
||||
/**
|
||||
* Whether the transcript's LAST agent turn ended cleanly (per the session record's status).
|
||||
* Only that final turn is ever retagged into the inferred completion row — persisted
|
||||
* transcripts carry no per-turn outcome, so earlier turns always render as plain text —
|
||||
* and the terminal text of a session that failed, was cancelled, or died mid-run must not
|
||||
* be retagged either, or a reopened broken task would render its dangling response as a
|
||||
* green/plan "done" box. Defaults to true.
|
||||
*/
|
||||
finalTurnCompleted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK-persisted LLM messages back into the ClineMessage format used by
|
||||
* the webview. Keep this in the live message translator so history rendering
|
||||
* and streaming rendering share the same SDK tool → Cline UI mapping.
|
||||
*/
|
||||
export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], minter?: MessageIdMinter): ClineMessage[] {
|
||||
export function sdkMessagesToClineMessages(
|
||||
messages: SdkMessageWithMetrics[],
|
||||
minter?: MessageIdMinter,
|
||||
options?: SdkMessagesToClineMessagesOptions,
|
||||
): ClineMessage[] {
|
||||
const clineMessages: ClineMessage[] = []
|
||||
// Plan/act mode of the turn currently being replayed, recovered from each user message's
|
||||
// persisted <user_input mode="..."> wrapper (stamped as `uiMode` before sanitization).
|
||||
let currentMode: "plan" | "act" | "yolo" | undefined
|
||||
// Use the process-wide minter when provided so regenerated history ids are globally unique
|
||||
// and never overlap live-session ids. Falls back to a private minter for standalone tests.
|
||||
const state = new MessageTranslatorState(minter)
|
||||
const state = new MessageTranslatorState(minter, undefined, () => currentMode)
|
||||
const pendingToolUses = new Map<string, SdkToolUseBlock>()
|
||||
|
||||
const flushUnmatchedToolUses = () => {
|
||||
@@ -1929,6 +2068,34 @@ export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], mi
|
||||
pendingToolUses.clear()
|
||||
}
|
||||
|
||||
// Add or update by ts — the synthesized turn-end `done` below retags an already-emitted
|
||||
// text row in place (same ts), mirroring the live path's upsert-by-ts message store.
|
||||
const upsertClineMessages = (updates: ClineMessage[]) => {
|
||||
for (const update of updates) {
|
||||
const existingIndex = clineMessages.findIndex((m) => m.ts === update.ts)
|
||||
if (existingIndex !== -1) {
|
||||
clineMessages[existingIndex] = update
|
||||
} else {
|
||||
clineMessages.push(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close out the transcript's FINAL agent turn by replaying the same `done` translation as
|
||||
// the live path, so a final turn that ended on a text response gets the inferred completion
|
||||
// retag (green box in act mode, yellow plan box in plan mode) when rehydrated from SDK
|
||||
// history. Only the final turn is eligible: persisted transcripts carry no per-turn
|
||||
// outcome, so an earlier turn that the user cancelled mid-response and then followed up on
|
||||
// is indistinguishable from one that ended cleanly — retagging it would present an
|
||||
// interrupted response as a deliberate turn end. The final turn's outcome IS known (the
|
||||
// caller gates it on the session record's status via `finalTurnCompleted`).
|
||||
const endFinalTurn = () => {
|
||||
upsertClineMessages(
|
||||
agentEventToMessages({ type: "done", reason: "completed", text: "", iterations: 0 } as AgentEvent, state),
|
||||
)
|
||||
state.clearTurnOutcome()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant") {
|
||||
flushUnmatchedToolUses()
|
||||
@@ -1975,6 +2142,10 @@ export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], mi
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
// Tool activity after a text block means that text wasn't the
|
||||
// turn-final response (also covers dangling tool_use blocks whose
|
||||
// results never arrived — an aborted turn must not retag).
|
||||
state.clearTurnFinalText()
|
||||
pendingToolUses.set(block.id, block)
|
||||
break
|
||||
}
|
||||
@@ -1986,6 +2157,11 @@ export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], mi
|
||||
if (typeof message.content === "string") {
|
||||
const text = message.content.trim()
|
||||
if (text) {
|
||||
// Visible user text marks a turn boundary: drop the preceding turn's outcome
|
||||
// signals (its text is NOT retagged — see endFinalTurn) and pick up the mode
|
||||
// of the NEW turn from this message's wrapper.
|
||||
state.clearTurnOutcome()
|
||||
currentMode = message.uiMode ?? currentMode
|
||||
clineMessages.push({
|
||||
ts: state.nextTs(),
|
||||
type: "say",
|
||||
@@ -1999,6 +2175,8 @@ export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], mi
|
||||
|
||||
const userText = textContentBlocksToText(message.content)
|
||||
if (userText) {
|
||||
state.clearTurnOutcome()
|
||||
currentMode = message.uiMode ?? currentMode
|
||||
clineMessages.push({
|
||||
ts: state.nextTs(),
|
||||
type: "say",
|
||||
@@ -2023,6 +2201,14 @@ export function sdkMessagesToClineMessages(messages: SdkMessageWithMetrics[], mi
|
||||
}
|
||||
}
|
||||
|
||||
// Close out the transcript's final agent turn so its terminal text (if the turn ended on
|
||||
// text) gets the inferred completion retag. Skipped when the session record says the last
|
||||
// run failed, was cancelled, or died mid-turn: its terminal text is a dangling partial
|
||||
// response, not a completion, and must stay a plain text row.
|
||||
if (options?.finalTurnCompleted !== false) {
|
||||
endFinalTurn()
|
||||
}
|
||||
|
||||
// Always emit ask:"completion_result"
|
||||
// as the LAST message so it comes after the usage event's
|
||||
// say:"api_req_started". This is critical: the webview uses
|
||||
|
||||
@@ -110,6 +110,22 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("resolves the turn phase to 'error' when the turn surfaced a provider error", async () => {
|
||||
const { coordinator, options, event } = makeCoordinator({
|
||||
translation: {
|
||||
messages: [],
|
||||
sessionEnded: false,
|
||||
turnComplete: true,
|
||||
},
|
||||
})
|
||||
options.messageTranslatorState.setErrorSeen()
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("error")
|
||||
expect(options.setTurnPhase).not.toHaveBeenCalledWith("awaiting_followup")
|
||||
})
|
||||
|
||||
it("marks a submitted queued prompt as a new streaming turn", async () => {
|
||||
const message: ClineMessage = { ts: 1, type: "say", say: "user_feedback", text: "queued prompt" }
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
|
||||
@@ -108,6 +108,10 @@ export class SdkSessionEventCoordinator {
|
||||
// (showing the scroll-arrow default instead), so the cancel-set phase is preserved.
|
||||
if (!activeSession.isRunning) {
|
||||
Logger.debug("[SdkController] turn-complete straggler after cancel; preserving resumable phase")
|
||||
} else if (this.options.messageTranslatorState.wasErrorSeen()) {
|
||||
// The turn surfaced a provider error (ask:"api_req_failed" was emitted) —
|
||||
// offer error recovery (Retry / Start New Task), not the followup state.
|
||||
this.options.setTurnPhase?.("error")
|
||||
} else if (this.options.messageTranslatorState.wasAttemptCompletionSeen()) {
|
||||
this.options.setTurnPhase?.("completed")
|
||||
} else {
|
||||
|
||||
@@ -45,6 +45,23 @@ describe("SdkSessionHistoryLoader", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("prefers the live in-memory conversation when the host exposes it", async () => {
|
||||
const liveMessages = [
|
||||
{ role: "user", content: "list the files in this folder" },
|
||||
{ role: "assistant", content: "I will list them now." },
|
||||
]
|
||||
const reader = {
|
||||
readLiveMessages: vi.fn().mockResolvedValue(liveMessages),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as SdkSessionHost
|
||||
|
||||
const result = await new SdkSessionHistoryLoader().loadInitialMessages(reader, "task-1")
|
||||
|
||||
expect(reader.readLiveMessages).toHaveBeenCalledWith("task-1")
|
||||
expect(reader.readMessages).not.toHaveBeenCalled()
|
||||
expect(result).toEqual(liveMessages)
|
||||
})
|
||||
|
||||
it("falls back to classic API history when SDK messages are empty", async () => {
|
||||
const reader = {
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { SdkInitialMessages, SdkSessionHost } from "./session-host"
|
||||
export class SdkSessionHistoryLoader {
|
||||
async loadInitialMessages(sessionHost: SdkSessionHost, taskId: string): Promise<SdkInitialMessages | undefined> {
|
||||
try {
|
||||
const sdkMessages = await sessionHost.readMessages(taskId)
|
||||
// Prefer the live in-memory conversation: the persisted transcript
|
||||
// only catches up at turn boundaries, so a rebuild right after
|
||||
// aborting a turn (e.g. a plan/act mode switch mid-approval) would
|
||||
// otherwise read an empty file and drop the task context.
|
||||
const sdkMessages = await (sessionHost.readLiveMessages?.(taskId) ?? sessionHost.readMessages(taskId))
|
||||
if (sdkMessages.length > 0) {
|
||||
const sanitizedMessages = sanitizeInitialMessagesForSessionStart(sdkMessages)
|
||||
if (sanitizedMessages !== sdkMessages) {
|
||||
|
||||
@@ -76,12 +76,13 @@ describe("SdkTaskControlCoordinator", () => {
|
||||
task: existingTask,
|
||||
hasHistoryItem: true,
|
||||
clineMessages: sdkClineMessages,
|
||||
sessionStatus: "completed",
|
||||
})
|
||||
|
||||
await coordinator.showTaskWithId("task-1")
|
||||
|
||||
expect(options.taskHistory.findHistoryItem).toHaveBeenCalledWith("task-1")
|
||||
expect(options.sessions.endActiveSession).toHaveBeenCalledWith("showTaskWithId")
|
||||
expect(options.sessions.endActiveSession).toHaveBeenCalledWith("showTaskWithId", { awaitStop: false })
|
||||
expect(existingTask.messageStateHandler.clear).toHaveBeenCalledOnce()
|
||||
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
|
||||
expect(state.task?.taskId).toBe("task-1")
|
||||
@@ -123,6 +124,170 @@ describe("SdkTaskControlCoordinator", () => {
|
||||
|
||||
expect(options.setTask).not.toHaveBeenCalled()
|
||||
expect(options.taskHistory.getClineMessages).not.toHaveBeenCalled()
|
||||
expect(options.setTurnPhase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("appends a resume ask and sets the resumable phase when showing an interrupted (cancelled) task", async () => {
|
||||
// History rendering appends a synthetic trailing ask:"completion_result"
|
||||
// to every reopened conversation, so the persisted session status — not
|
||||
// the message tail — must decide the resume affordance.
|
||||
const sdkClineMessages: ClineMessage[] = [
|
||||
{ ts: 1, type: "say", say: "task", text: "hello" },
|
||||
{ ts: 2, type: "ask", ask: "completion_result", text: "" },
|
||||
]
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: sdkClineMessages,
|
||||
sessionStatus: "cancelled",
|
||||
})
|
||||
|
||||
await coordinator.showTaskWithId("task-1")
|
||||
|
||||
expect(state.task?.messageStateHandler.getClineMessages().at(-1)).toEqual(
|
||||
expect.objectContaining({ type: "ask", ask: "resume_task" }),
|
||||
)
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("resumable", expect.any(Number))
|
||||
})
|
||||
|
||||
it("sets the turn phase to resumable when showing a failed task", async () => {
|
||||
const sdkClineMessages: ClineMessage[] = [
|
||||
{ ts: 1, type: "say", say: "task", text: "hello" },
|
||||
{ ts: 2, type: "say", say: "text", text: "partial answer" },
|
||||
]
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: sdkClineMessages,
|
||||
sessionStatus: "failed",
|
||||
})
|
||||
|
||||
await coordinator.showTaskWithId("task-1")
|
||||
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("resumable", expect.any(Number))
|
||||
})
|
||||
|
||||
it("sets the turn phase to completed when showing a completed task", async () => {
|
||||
const sdkClineMessages: ClineMessage[] = [
|
||||
{ ts: 1, type: "say", say: "task", text: "hello" },
|
||||
{ ts: 2, type: "ask", ask: "completion_result", text: "" },
|
||||
]
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: sdkClineMessages,
|
||||
sessionStatus: "completed",
|
||||
})
|
||||
|
||||
await coordinator.showTaskWithId("task-1")
|
||||
|
||||
expect(state.task?.messageStateHandler.getClineMessages().at(-1)).toEqual(
|
||||
expect.objectContaining({ type: "ask", ask: "resume_completed_task" }),
|
||||
)
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("completed", expect.any(Number))
|
||||
})
|
||||
|
||||
it("sets the turn phase to idle when showing a task with no messages", async () => {
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: [],
|
||||
})
|
||||
|
||||
await coordinator.showTaskWithId("task-1")
|
||||
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("idle")
|
||||
})
|
||||
|
||||
it("keeps the newest selection when an older open's history lookup resolves last", async () => {
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: [{ ts: 1, type: "say", say: "task", text: "hello" }],
|
||||
sessionStatus: "cancelled",
|
||||
})
|
||||
|
||||
// Task A's preflight history lookup stalls. The view generation must be
|
||||
// allocated BEFORE this await: when the lookup used to live in
|
||||
// SdkController ahead of the coordinator, a stalled lookup re-entered
|
||||
// with a NEWER generation than a later selection and replaced it.
|
||||
let resolveLookup: ((item: unknown) => void) | undefined
|
||||
options.taskHistory.findHistoryItem.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveLookup = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const staleOpen = coordinator.showTaskWithId("task-old")
|
||||
|
||||
// Task B is selected afterwards and loads successfully.
|
||||
await coordinator.showTaskWithId("task-new")
|
||||
expect(state.task?.taskId).toBe("task-new")
|
||||
const endActiveSessionCalls = options.sessions.endActiveSession.mock.calls.length
|
||||
|
||||
// Task A's lookup finally resolves. It must neither stop the session the
|
||||
// newer selection installed nor replace the selection.
|
||||
resolveLookup?.({ id: "task-old", ts: 1, task: "old", tokensIn: 0, tokensOut: 0, totalCost: 0 })
|
||||
const staleResult = await staleOpen
|
||||
|
||||
expect(staleResult).toBeDefined()
|
||||
expect(state.task?.taskId).toBe("task-new")
|
||||
expect(options.sessions.endActiveSession.mock.calls.length).toBe(endActiveSessionCalls)
|
||||
})
|
||||
|
||||
it("abandons a superseded showTaskWithId so the newest selection wins", async () => {
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: [{ ts: 1, type: "say", say: "task", text: "hello" }],
|
||||
sessionStatus: "cancelled",
|
||||
})
|
||||
|
||||
// Park the FIRST open on its message read so a second open can start
|
||||
// and finish while the first is still in flight.
|
||||
let resolveFirstRead: ((messages: ClineMessage[]) => void) | undefined
|
||||
options.taskHistory.getClineMessages.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ClineMessage[]>((resolve) => {
|
||||
resolveFirstRead = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const firstOpen = coordinator.showTaskWithId("task-old")
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(resolveFirstRead).toBeDefined()
|
||||
|
||||
await coordinator.showTaskWithId("task-new")
|
||||
expect(state.task?.taskId).toBe("task-new")
|
||||
const phaseCallsAfterSecondOpen = options.setTurnPhase.mock.calls.length
|
||||
|
||||
resolveFirstRead?.([{ ts: 1, type: "say", say: "task", text: "stale" }])
|
||||
await firstOpen
|
||||
|
||||
// The stale open must not replace the newer selection or its turn phase.
|
||||
expect(state.task?.taskId).toBe("task-new")
|
||||
expect(state.task?.messageStateHandler.getClineMessages().length).toBeGreaterThan(0)
|
||||
expect(options.setTurnPhase.mock.calls.length).toBe(phaseCallsAfterSecondOpen)
|
||||
})
|
||||
|
||||
it("abandons a superseded showTaskWithId when the user clears the task", async () => {
|
||||
const { coordinator, options, state } = makeCoordinator({
|
||||
hasHistoryItem: true,
|
||||
clineMessages: [{ ts: 1, type: "say", say: "task", text: "hello" }],
|
||||
sessionStatus: "cancelled",
|
||||
})
|
||||
|
||||
let resolveRead: ((messages: ClineMessage[]) => void) | undefined
|
||||
options.taskHistory.getClineMessages.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ClineMessage[]>((resolve) => {
|
||||
resolveRead = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const open = coordinator.showTaskWithId("task-old")
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
await coordinator.clearTask()
|
||||
resolveRead?.([{ ts: 1, type: "say", say: "task", text: "stale" }])
|
||||
await open
|
||||
|
||||
expect(state.task).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not install the new task proxy until its messages are loaded", async () => {
|
||||
@@ -197,6 +362,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
},
|
||||
taskHistory: {
|
||||
getClineMessages: vi.fn().mockResolvedValue(input.clineMessages ?? []),
|
||||
getSessionStatus: vi.fn().mockResolvedValue(input.sessionStatus),
|
||||
isLegacyTask: vi.fn().mockResolvedValue(input.isLegacyTask ?? false),
|
||||
findHistoryItem: vi.fn(() =>
|
||||
input.hasHistoryItem === false
|
||||
@@ -218,6 +384,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
onAskResponse: vi.fn().mockResolvedValue(undefined),
|
||||
resetMessageTranslator: vi.fn(),
|
||||
raiseCancelFence: vi.fn(),
|
||||
setTurnPhase: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SdkTaskControlCoordinatorOptions & {
|
||||
sessions: SdkTaskControlCoordinatorOptions["sessions"] & {
|
||||
@@ -240,6 +407,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
getTask: ReturnType<typeof vi.fn>
|
||||
setTask: ReturnType<typeof vi.fn>
|
||||
resetMessageTranslator: ReturnType<typeof vi.fn>
|
||||
setTurnPhase: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -256,6 +424,7 @@ interface MakeCoordinatorInput {
|
||||
hasHistoryItem: boolean
|
||||
clineMessages: ClineMessage[]
|
||||
isLegacyTask: boolean
|
||||
sessionStatus: string
|
||||
}
|
||||
|
||||
function makeActiveSession() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
@@ -16,6 +17,13 @@ export interface SdkTaskControlCoordinatorOptions {
|
||||
onAskResponse: (text?: string, images?: string[], files?: string[]) => Promise<void>
|
||||
resetMessageTranslator: () => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
/**
|
||||
* Sets the authoritative turn phase. showTaskWithId must derive the phase
|
||||
* from the reopened conversation (resumable/completed) — leaving the
|
||||
* previous task's phase in place hides the Resume button for interrupted
|
||||
* sessions opened from History (and can leak stale buttons in general).
|
||||
*/
|
||||
setTurnPhase: (phase: TurnPhase, anchorTs?: number) => void
|
||||
/**
|
||||
* Raise the cancel fence SYNCHRONOUSLY before aborting the SDK session: bump the epoch so any
|
||||
* straggler events the SDK emits after the abort request carry the old epoch (and are dropped
|
||||
@@ -26,6 +34,14 @@ export interface SdkTaskControlCoordinatorOptions {
|
||||
}
|
||||
|
||||
export class SdkTaskControlCoordinator {
|
||||
/**
|
||||
* Generation counter for task-view mutations (showTaskWithId / clearTask).
|
||||
* showTaskWithId awaits several reads before installing the task proxy; a
|
||||
* request that loses the race to a newer mutation abandons installation at
|
||||
* the next fence check so the user's latest selection always wins.
|
||||
*/
|
||||
private taskViewGeneration = 0
|
||||
|
||||
constructor(private readonly options: SdkTaskControlCoordinatorOptions) {}
|
||||
|
||||
async cancelTask(): Promise<void> {
|
||||
@@ -72,6 +88,9 @@ export class SdkTaskControlCoordinator {
|
||||
}
|
||||
|
||||
async clearTask(): Promise<void> {
|
||||
// Supersede any in-flight showTaskWithId so it cannot re-install a task
|
||||
// after the user cleared the view (e.g. clicked New Task).
|
||||
this.taskViewGeneration++
|
||||
this.options.interactions.clearPending("Task cleared")
|
||||
|
||||
await this.options.sessions.endActiveSession("clearTask")
|
||||
@@ -88,17 +107,64 @@ export class SdkTaskControlCoordinator {
|
||||
this.options.resetMessageTranslator()
|
||||
}
|
||||
|
||||
async showTaskWithId(taskId: string, options: { skipHistoryLookup?: boolean } = {}): Promise<void> {
|
||||
try {
|
||||
if (!options.skipHistoryLookup) {
|
||||
const historyItem = await this.options.taskHistory.findHistoryItem(taskId)
|
||||
if (!historyItem) {
|
||||
Logger.error(`[SdkController] Task not found in history: ${taskId}`)
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Opens a task from History. The view generation is allocated synchronously
|
||||
* on entry — BEFORE any asynchronous work, including the history lookup —
|
||||
* so the newest user selection always holds the newest generation and every
|
||||
* older in-flight request self-abandons at its next fence check. (The
|
||||
* lookup used to live in SdkController before the generation was taken; a
|
||||
* stalled preflight could then re-enter with a NEWER generation than a
|
||||
* later selection and replace it.)
|
||||
*
|
||||
* Returns the task's HistoryItem, or undefined when the task is unknown.
|
||||
* A superseded call still returns the item (the lookup succeeded); it just
|
||||
* skips mutating the task view.
|
||||
*/
|
||||
async showTaskWithId(taskId: string): Promise<HistoryItem | undefined> {
|
||||
const generation = ++this.taskViewGeneration
|
||||
const isSuperseded = (): boolean => {
|
||||
if (generation === this.taskViewGeneration) {
|
||||
return false
|
||||
}
|
||||
Logger.debug(`[SdkController] showTaskWithId superseded by a newer selection; skipping: ${taskId}`)
|
||||
return true
|
||||
}
|
||||
|
||||
await this.options.sessions.endActiveSession("showTaskWithId")
|
||||
let historyItem: HistoryItem | undefined
|
||||
try {
|
||||
historyItem = await this.options.taskHistory.findHistoryItem(taskId)
|
||||
} catch (error) {
|
||||
Logger.error(`[SdkController] Failed to look up task in history: ${taskId}`, error)
|
||||
return undefined
|
||||
}
|
||||
if (!historyItem) {
|
||||
Logger.error(`[SdkController] Task not found in history: ${taskId}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// FENCE: before stopping the active session. A superseded request must
|
||||
// not stop a session that a newer selection just started or resumed.
|
||||
if (isSuperseded()) {
|
||||
return historyItem
|
||||
}
|
||||
|
||||
try {
|
||||
// When reopening the task that is currently active, wait for its stop to
|
||||
// land so the persisted session status read below reflects how the last
|
||||
// turn actually ended (completed vs cancelled) instead of a transient
|
||||
// non-terminal status.
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
await this.options.sessions.endActiveSession("showTaskWithId", {
|
||||
awaitStop: activeSession?.sessionId === taskId,
|
||||
})
|
||||
|
||||
// FENCE: everything below mutates the shared task view (clearing the
|
||||
// current task, installing the new proxy, setting the turn phase). If a
|
||||
// newer showTaskWithId/clearTask started while this call awaited I/O,
|
||||
// bail out so the stale request cannot clobber the newer selection.
|
||||
if (isSuperseded()) {
|
||||
return historyItem
|
||||
}
|
||||
|
||||
const currentTask = this.options.getTask()
|
||||
if (currentTask) {
|
||||
@@ -110,12 +176,16 @@ export class SdkTaskControlCoordinator {
|
||||
// Load messages before installing the new task proxy so any concurrent
|
||||
// postStateToWebview() caller never sees the new id with empty messages.
|
||||
const isLegacyTask = await this.options.taskHistory.isLegacyTask(taskId)
|
||||
const sessionStatus = isLegacyTask ? undefined : await this.options.taskHistory.getSessionStatus(taskId)
|
||||
const rawMessages = await this.options.taskHistory.getClineMessages(taskId)
|
||||
if (isSuperseded()) {
|
||||
return historyItem
|
||||
}
|
||||
const messages = this.options.messages.finalizeMessagesForSave(rawMessages)
|
||||
const cleanedMessages = isLegacyTask
|
||||
? this.appendLegacyTaskWarningAndResumeMessage(messages)
|
||||
: messages.length > 0
|
||||
? this.appendFreshResumeMessage(messages)
|
||||
? this.appendFreshResumeMessage(messages, sessionStatus)
|
||||
: []
|
||||
|
||||
const task = createTaskProxy(
|
||||
@@ -128,6 +198,19 @@ export class SdkTaskControlCoordinator {
|
||||
}
|
||||
this.options.setTask(task)
|
||||
|
||||
// Derive the turn phase from the appended resume ask. The webview
|
||||
// renders footer buttons from the authoritative TurnState, so without
|
||||
// this the phase left over from the previous context (often "idle")
|
||||
// hides the Resume button for interrupted/failed sessions.
|
||||
const lastMessage = cleanedMessages.at(-1)
|
||||
if (lastMessage?.type === "ask" && lastMessage.ask === "resume_completed_task") {
|
||||
this.options.setTurnPhase("completed", lastMessage.ts)
|
||||
} else if (lastMessage?.type === "ask" && lastMessage.ask === "resume_task") {
|
||||
this.options.setTurnPhase("resumable", lastMessage.ts)
|
||||
} else {
|
||||
this.options.setTurnPhase("idle")
|
||||
}
|
||||
|
||||
if (cleanedMessages.length > 0) {
|
||||
Logger.log(`[SdkController] Loaded ${cleanedMessages.length} messages for task: ${taskId}`)
|
||||
} else {
|
||||
@@ -142,13 +225,20 @@ export class SdkTaskControlCoordinator {
|
||||
} catch (error) {
|
||||
Logger.error("[SdkController] Failed to show task:", error)
|
||||
}
|
||||
return historyItem
|
||||
}
|
||||
|
||||
private appendFreshResumeMessage(messages: ClineMessage[]): ClineMessage[] {
|
||||
const lastRelevantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.ask !== "resume_task" && m.ask !== "resume_completed_task")
|
||||
const resumeAsk = lastRelevantMessage?.ask === "completion_result" ? "resume_completed_task" : "resume_task"
|
||||
private appendFreshResumeMessage(messages: ClineMessage[], sessionStatus?: string): ClineMessage[] {
|
||||
// The persisted session status is the only reliable completion signal:
|
||||
// SDK conversations do not record a completion tool call in the
|
||||
// transcript (a completed turn and a turn interrupted mid-stream both
|
||||
// end with plain assistant text), and history rendering appends a
|
||||
// synthetic trailing ask:"completion_result" either way, so the message
|
||||
// tail cannot be used. When the status is unknown (e.g. a transient
|
||||
// read failure), default to the Resume affordance: resuming a completed
|
||||
// task is harmless, while hiding Resume on an interrupted one is the
|
||||
// data-loss illusion this exists to prevent.
|
||||
const resumeAsk = sessionStatus === "completed" ? "resume_completed_task" : "resume_task"
|
||||
const cleanedMessages = messages.filter((m) => m.ask !== "resume_task" && m.ask !== "resume_completed_task")
|
||||
cleanedMessages.push({
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -130,6 +130,8 @@ describe("SdkTaskHistory", () => {
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ type: "say", say: "task", text: "Build the feature", partial: false },
|
||||
// Mid-transcript turns are never retagged into the inferred completion row:
|
||||
// history carries no per-turn outcome, so an earlier turn's text stays plain.
|
||||
{ type: "say", say: "text", text: "Done", partial: false },
|
||||
{ type: "say", say: "user_feedback", text: "Follow up", partial: false },
|
||||
// A trailing ask:"completion_result" is appended so a reopened task
|
||||
@@ -206,7 +208,8 @@ describe("SdkTaskHistory", () => {
|
||||
expect(result).toMatchObject([
|
||||
{ type: "say", say: "task", text: "add a joke", partial: false },
|
||||
{ type: "say", say: "tool", partial: false },
|
||||
{ type: "say", say: "text", text: "Done!", partial: false },
|
||||
// The turn's final text response is retagged to the inferred completion row.
|
||||
{ type: "say", say: "completion_result", text: "Done!", partial: false },
|
||||
{ type: "ask", ask: "completion_result", partial: false },
|
||||
])
|
||||
expect(result.map((message) => message.text).join("\n")).not.toContain(rawToolResult)
|
||||
@@ -216,6 +219,111 @@ describe("SdkTaskHistory", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("retags only the final turn's text, styled by the mode recovered from user_input wrappers", async () => {
|
||||
const { history, readMessages } = makeHistory([makeSessionRecord("task-1")])
|
||||
readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: '<user_input mode="plan">plan the feature</user_input>' },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Here is the plan." }] },
|
||||
{ role: "user", content: '<user_input mode="act">looks good, do it</user_input>' },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Implemented." }] },
|
||||
] as never)
|
||||
|
||||
const result = await history.getClineMessages("task-1")
|
||||
|
||||
expect(result).toMatchObject([
|
||||
// The <user_input mode="..."> wrapper is stripped for display but its mode
|
||||
// styles the final turn's inferred completion row. Mid-transcript turns stay
|
||||
// plain — history has no per-turn outcome to trust.
|
||||
{ type: "say", say: "task", text: "plan the feature" },
|
||||
{ type: "say", say: "text", text: "Here is the plan." },
|
||||
{ type: "say", say: "user_feedback", text: "looks good, do it" },
|
||||
{ type: "say", say: "completion_result", text: "Implemented." },
|
||||
{ type: "ask", ask: "completion_result" },
|
||||
])
|
||||
})
|
||||
|
||||
it("styles the final turn's inferred completion with the plan box when the last turn ran in plan mode", async () => {
|
||||
const { history, readMessages } = makeHistory([makeSessionRecord("task-1")])
|
||||
readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: '<user_input mode="act">build it</user_input>' },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Built." }] },
|
||||
{ role: "user", content: '<user_input mode="plan">now plan the next phase</user_input>' },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Phase two plan." }] },
|
||||
] as never)
|
||||
|
||||
const result = await history.getClineMessages("task-1")
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ type: "say", say: "plan_completion_result", text: "Phase two plan." }),
|
||||
)
|
||||
expect(result).toContainEqual(expect.objectContaining({ type: "say", say: "text", text: "Built." }))
|
||||
})
|
||||
|
||||
it("retags the terminal text of a session whose record says it completed", async () => {
|
||||
// "completed" is written by the runtime host when the session is released after a
|
||||
// clean final turn (task switch / clear / extension dispose).
|
||||
const { history, readMessages } = makeHistory([makeSessionRecord("task-1", { status: "completed" })])
|
||||
readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: "first request" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Final answer." }] },
|
||||
] as never)
|
||||
|
||||
const result = await history.getClineMessages("task-1")
|
||||
|
||||
expect(result).toContainEqual(expect.objectContaining({ type: "say", say: "completion_result", text: "Final answer." }))
|
||||
})
|
||||
|
||||
it("does not retag the terminal text of a session that did not end cleanly", async () => {
|
||||
// "failed"/"cancelled" runs ended on a dangling response. Non-terminal statuses at
|
||||
// rest mean the process died without recording an outcome — "idle" is also the state
|
||||
// after an aborted turn (markTurnIdle runs for every finish reason), so it cannot be
|
||||
// trusted as a clean ending.
|
||||
for (const status of ["failed", "cancelled", "running", "pending", "idle"] as const) {
|
||||
const { history, readMessages } = makeHistory([makeSessionRecord("task-1", { status })])
|
||||
readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: "first request" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "First answer." }] },
|
||||
{ role: "user", content: "second request" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Dangling partial answer" }] },
|
||||
] as never)
|
||||
|
||||
const result = await history.getClineMessages("task-1")
|
||||
|
||||
expect(result).toContainEqual(expect.objectContaining({ type: "say", say: "text", text: "Dangling partial answer" }))
|
||||
expect(result.filter((m) => m.say === "completion_result" || m.say === "plan_completion_result")).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
|
||||
it("does not retag the terminal text when no session record exists (unknown outcome)", async () => {
|
||||
const { history, readMessages } = makeHistory([])
|
||||
readMessages.mockResolvedValueOnce([
|
||||
{ role: "user", content: "do the thing" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Answer of unknown outcome" }] },
|
||||
] as never)
|
||||
|
||||
const result = await history.getClineMessages("task-without-record")
|
||||
|
||||
expect(result).toContainEqual(expect.objectContaining({ type: "say", say: "text", text: "Answer of unknown outcome" }))
|
||||
expect(result.filter((m) => m.say === "completion_result" || m.say === "plan_completion_result")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("does not retag a transcript that ends on a dangling tool call", () => {
|
||||
const result = sdkMessagesToClineMessages([
|
||||
{ role: "user", content: "do the thing" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Reading the file first." },
|
||||
{ type: "tool_use", id: "toolu_dangling", name: "read_files", input: { path: "/a.ts" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// The aborted turn's text stays a plain text row — no inferred completion box.
|
||||
expect(result.filter((m) => m.say === "completion_result" || m.say === "plan_completion_result")).toHaveLength(0)
|
||||
expect(result).toContainEqual(expect.objectContaining({ type: "say", say: "text", text: "Reading the file first." }))
|
||||
})
|
||||
|
||||
it("hides subagent sessions from task history", async () => {
|
||||
const rootTask = makeSessionRecord("root")
|
||||
const subagent = makeSessionRecord("root__agent", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path"
|
||||
import type { ClineCoreListHistoryOptions, SessionHistoryRecord } from "@cline/core"
|
||||
import type { Message as SdkMessage } from "@cline/llms"
|
||||
import { formatDisplayUserInput } from "@cline/shared"
|
||||
import { formatDisplayUserInput, parseUserInputMode } from "@cline/shared"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import getFolderSize from "get-folder-size"
|
||||
@@ -129,13 +129,34 @@ function historyItemToSessionHistoryRecord(item: HistoryItem): SessionHistoryRec
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSdkUserMessagesForDisplay(messages: SdkMessage[]): SdkMessage[] {
|
||||
return messages.map((message) => {
|
||||
/** SdkMessage plus the plan/act mode recovered from its <user_input mode="..."> wrapper. */
|
||||
type SdkDisplayMessage = SdkMessage & { uiMode?: "plan" | "act" | "yolo" }
|
||||
|
||||
function parseUserMessageMode(content: SdkMessage["content"]): "plan" | "act" | "yolo" | undefined {
|
||||
if (typeof content === "string") {
|
||||
return parseUserInputMode(content)
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
const mode = parseUserInputMode(block.text)
|
||||
if (mode) {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function sanitizeSdkUserMessagesForDisplay(messages: SdkMessage[]): SdkDisplayMessage[] {
|
||||
return messages.map((message): SdkDisplayMessage => {
|
||||
if (message.role !== "user") {
|
||||
return message
|
||||
}
|
||||
// Recover the mode BEFORE display sanitization strips the <user_input mode="..."> wrapper;
|
||||
// history rendering uses it to style each turn's inferred completion row.
|
||||
const uiMode = parseUserMessageMode(message.content)
|
||||
if (typeof message.content === "string") {
|
||||
return { ...message, content: formatDisplayUserInput(message.content) }
|
||||
return { ...message, content: formatDisplayUserInput(message.content), uiMode }
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return {
|
||||
@@ -145,6 +166,7 @@ function sanitizeSdkUserMessagesForDisplay(messages: SdkMessage[]): SdkMessage[]
|
||||
? { ...block, text: formatDisplayUserInput(block.text) }
|
||||
: block,
|
||||
),
|
||||
uiMode,
|
||||
}
|
||||
}
|
||||
return message
|
||||
@@ -431,6 +453,19 @@ export class SdkTaskHistory {
|
||||
const clineMessages = sdkMessagesToClineMessages(
|
||||
sanitizeSdkUserMessagesForDisplay(sdkMessages),
|
||||
this.options.getMinter?.(),
|
||||
{
|
||||
// Only retag the transcript's terminal text as an inferred completion when the
|
||||
// session record says its last turn ended cleanly — status "completed", written
|
||||
// by the SDK runtime host's resolveInteractiveStopStatus when the session is
|
||||
// released (task switch, clear, extension dispose). Everything else stays a
|
||||
// plain text row: "failed"/"cancelled" runs ended on a dangling response, and
|
||||
// non-terminal statuses at rest ("idle"/"running"/"pending") mean the process
|
||||
// died without recording an outcome — "idle" in particular is also the state
|
||||
// after an aborted turn (markTurnIdle runs for every finish reason), so it
|
||||
// cannot be trusted as a clean ending. A missing record is likewise an unknown
|
||||
// outcome, so it gets no completion styling either.
|
||||
finalTurnCompleted: sdkRecord?.status === "completed",
|
||||
},
|
||||
)
|
||||
if (sdkRecord && legacyTask) {
|
||||
return mergeLegacyUiMessagesWithResumedSdkMessages(readUiMessages(taskId, legacyTask.dataDir), clineMessages)
|
||||
@@ -438,6 +473,18 @@ export class SdkTaskHistory {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted session status ("completed" | "cancelled" | "failed" | ...).
|
||||
* Persisted messages cannot distinguish a completed conversation from one
|
||||
* interrupted mid-stream (both just end with assistant text), so reopening a
|
||||
* task from History uses this status to decide between the Resume Task and
|
||||
* Start New Task affordances.
|
||||
*/
|
||||
async getSessionStatus(taskId: string): Promise<SessionHistoryRecord["status"] | undefined> {
|
||||
const sdkRecord = await this.getSdkRecord(taskId).catch(() => undefined)
|
||||
return sdkRecord?.status
|
||||
}
|
||||
|
||||
async isLegacyTask(taskId: string): Promise<boolean> {
|
||||
const sdkRecord = await this.getSdkRecord(taskId)
|
||||
if (sdkRecord) {
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface SdkSessionHost {
|
||||
listHistory(options?: ClineCoreListHistoryOptions): Promise<SessionHistoryRecord[]>
|
||||
delete(sessionId: string): Promise<boolean>
|
||||
readMessages(sessionId: string): Promise<SdkInitialMessages>
|
||||
/**
|
||||
* Like readMessages, but prefers the live in-memory conversation when the
|
||||
* session is still resident, so an in-flight (or just-aborted) turn is not
|
||||
* lost to the persisted transcript lagging behind.
|
||||
*/
|
||||
readLiveMessages?(sessionId: string): Promise<SdkInitialMessages>
|
||||
updateSessionCompactionState?(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }>
|
||||
restore(input: RestoreInput): Promise<RestoreResult>
|
||||
update(
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { AvailableRuntimeCommand } from "@cline/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildDisabledWorkflowNames, expandSlashCommands } from "./slash-command-expansion"
|
||||
|
||||
function workflow(name: string, instructions: string): AvailableRuntimeCommand {
|
||||
return { id: name, name, instructions, kind: "workflow" }
|
||||
}
|
||||
|
||||
function skill(name: string, instructions: string): AvailableRuntimeCommand {
|
||||
return { id: name, name, instructions, kind: "skill" }
|
||||
}
|
||||
|
||||
describe("expandSlashCommands", () => {
|
||||
const commands = [workflow("release", "Run the release workflow."), skill("debug", "Use the debugging skill.")]
|
||||
|
||||
it("expands a leading workflow command", () => {
|
||||
expect(expandSlashCommands("/release", commands)).toBe("Run the release workflow.")
|
||||
expect(expandSlashCommands("/release now", commands)).toBe("Run the release workflow. now")
|
||||
})
|
||||
|
||||
it("expands the legacy filename spelling with the .md extension", () => {
|
||||
expect(expandSlashCommands("/release.md now", commands)).toBe("Run the release workflow. now")
|
||||
})
|
||||
|
||||
it("expands the other workflow file extensions the SDK discovers", () => {
|
||||
expect(expandSlashCommands("/release.markdown", commands)).toBe("Run the release workflow.")
|
||||
expect(expandSlashCommands("/release.txt", commands)).toBe("Run the release workflow.")
|
||||
})
|
||||
|
||||
it("matches case-insensitively as a fallback, like webview validation", () => {
|
||||
expect(expandSlashCommands("/Release.MD", commands)).toBe("Run the release workflow.")
|
||||
})
|
||||
|
||||
it("resolves a typed filename to a frontmatter-renamed workflow via records", () => {
|
||||
const renamed = [workflow("ship-it", "Ship it carefully.")]
|
||||
const records = [{ name: "ship-it", filePath: "/repo/.clinerules/workflows/release.md" }]
|
||||
expect(expandSlashCommands("/release.md", renamed, { workflowRecords: records })).toBe("Ship it carefully.")
|
||||
// The renamed command stays governed by its file's toggle.
|
||||
expect(
|
||||
expandSlashCommands("/release.md", renamed, {
|
||||
workflowRecords: records,
|
||||
disabledWorkflowNames: new Set(["ship-it"]),
|
||||
}),
|
||||
).toBe("/release.md")
|
||||
})
|
||||
|
||||
it("expands a command that appears mid-message after whitespace", () => {
|
||||
expect(expandSlashCommands("please run /release.md for v2", commands)).toBe("please run Run the release workflow. for v2")
|
||||
})
|
||||
|
||||
it("only expands the first matching command", () => {
|
||||
expect(expandSlashCommands("/release then /debug", commands)).toBe("Run the release workflow. then /debug")
|
||||
})
|
||||
|
||||
it("skips unknown commands but still expands a later known one", () => {
|
||||
expect(expandSlashCommands("/newtask use /release", commands)).toBe("/newtask use Run the release workflow.")
|
||||
})
|
||||
|
||||
it("expands skills by name", () => {
|
||||
expect(expandSlashCommands("/debug this failure", commands)).toBe("Use the debugging skill. this failure")
|
||||
})
|
||||
|
||||
it("does not treat path segments as commands", () => {
|
||||
expect(expandSlashCommands("look at /release/notes.txt", commands)).toBe("look at /release/notes.txt")
|
||||
})
|
||||
|
||||
it("returns unknown commands unchanged", () => {
|
||||
expect(expandSlashCommands("/missing", commands)).toBe("/missing")
|
||||
expect(expandSlashCommands("no commands here", commands)).toBe("no commands here")
|
||||
})
|
||||
|
||||
it("skips workflows the user disabled via toggles", () => {
|
||||
const disabled = new Set(["release"])
|
||||
expect(expandSlashCommands("/release", commands, { disabledWorkflowNames: disabled })).toBe("/release")
|
||||
expect(expandSlashCommands("/release.md", commands, { disabledWorkflowNames: disabled })).toBe("/release.md")
|
||||
// Skills are governed by frontmatter, not workflow toggles.
|
||||
expect(expandSlashCommands("/debug", commands, { disabledWorkflowNames: new Set(["debug"]) })).toBe(
|
||||
"Use the debugging skill.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildDisabledWorkflowNames", () => {
|
||||
it("disables records whose file toggle is off, by exact command name", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "Release", filePath: "/home/user/Documents/Cline/Workflows/Release.md" },
|
||||
{ name: "notes", filePath: "/home/user/Documents/Cline/Workflows/notes.txt" },
|
||||
{ name: "keep", filePath: "/home/user/Documents/Cline/Workflows/keep.md" },
|
||||
],
|
||||
globalToggles: {
|
||||
"/home/user/Documents/Cline/Workflows/Release.md": false,
|
||||
"/home/user/Documents/Cline/Workflows/notes.txt": false,
|
||||
"/home/user/Documents/Cline/Workflows/keep.md": true,
|
||||
},
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["Release", "notes"]))
|
||||
})
|
||||
|
||||
it("matches the toggle by file basename even when frontmatter renames the command", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "ship-it", filePath: "/repo/.clinerules/workflows/release.md" }],
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["ship-it"]))
|
||||
})
|
||||
|
||||
it("keeps a name enabled when any scope has it enabled", () => {
|
||||
// Legacy expansion searched enabled workflows across scopes, so a
|
||||
// disabled workspace file must not shadow an enabled global one.
|
||||
const records = [{ name: "release", filePath: "/repo/.clinerules/workflows/release.md" }]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
globalToggles: { "/global/dir/release.md": true },
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": false },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
globalToggles: { "/global/dir/release.md": false },
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": true },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("governs each command by its own record when similar names span scopes", () => {
|
||||
// Distinct commands whose names only differ by case/extension must not
|
||||
// influence each other: the disabled remote command stays disabled even
|
||||
// though the similarly-named local one is enabled, and vice versa.
|
||||
const records = [
|
||||
{ name: "Release", filePath: "/repo/.clinerules/workflows/Release.md" },
|
||||
{ name: "release", filePath: "/repo/.cline/remote-config/workflows/release.md" },
|
||||
]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/Release.md": true },
|
||||
remoteToggles: { release: false },
|
||||
}),
|
||||
).toEqual(new Set(["release"]))
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/Release.md": false },
|
||||
remoteAlwaysEnabledNames: ["release"],
|
||||
}),
|
||||
).toEqual(new Set(["Release"]))
|
||||
})
|
||||
|
||||
it("treats records without any toggle entry as enabled", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "fresh", filePath: "/home/user/.cline/workflows/fresh.md" }],
|
||||
globalToggles: { "/global/dir/other.md": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("governs remote-config records by name-keyed remote toggles", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" },
|
||||
{ name: "org-review", filePath: "/repo/.cline/remote-config/workflows/org-review.md" },
|
||||
{ name: "org-default", filePath: "C:\\repo\\.cline\\remote-config\\workflows\\org-default.md" },
|
||||
],
|
||||
remoteToggles: { "org-standards": false, "org-review": true },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("keys remote toggles off the materialized filename even when frontmatter aliases the command", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "friendly-alias", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["friendly-alias"]))
|
||||
})
|
||||
|
||||
it("matches remote toggles whose config names get sanitized during materialization", () => {
|
||||
// "Org Standards" materializes as org-standards.md, and the record is
|
||||
// named after the sanitized basename.
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("merges colliding sanitized remote names as enabled-if-any-enabled", () => {
|
||||
// "Org Standards" and "org standards" both sanitize to org-standards.
|
||||
const records = [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
remoteToggles: { "Org Standards": false, "org standards": true },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
remoteToggles: { "Org Standards": false, "org standards": false },
|
||||
}),
|
||||
).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("matches remote toggles for names longer than the materializer's 80-char cap", () => {
|
||||
const longConfigName = "a".repeat(100)
|
||||
const materializedName = "a".repeat(80)
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: materializedName, filePath: `/repo/.cline/remote-config/workflows/${materializedName}.md` }],
|
||||
remoteToggles: { [longConfigName]: false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set([materializedName]))
|
||||
})
|
||||
|
||||
it("treats locked (alwaysEnabled) remote workflows as enabled despite stale toggles", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
remoteAlwaysEnabledNames: ["Org Standards"],
|
||||
})
|
||||
expect(disabled).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("collects disabled names across local and remote scopes", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "deploy", filePath: "/global/dir/deploy.md" },
|
||||
{ name: "keep", filePath: "/global/dir/keep.md" },
|
||||
{ name: "hotfix", filePath: "C:\\repo\\.clinerules\\workflows\\hotfix.md" },
|
||||
{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" },
|
||||
],
|
||||
globalToggles: { "/global/dir/deploy.md": false, "/global/dir/keep.md": true },
|
||||
workspaceToggles: { "C:\\repo\\.clinerules\\workflows\\hotfix.md": false },
|
||||
remoteToggles: { "org-standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["deploy", "hotfix", "org-standards"]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { AvailableRuntimeCommand } from "@cline/core"
|
||||
|
||||
/**
|
||||
* Matches a slash-command token that is either at the start of the message or
|
||||
* preceded by whitespace, and followed by whitespace or end-of-string. Kept in
|
||||
* sync with the webview's `slashCommandRegex` (webview-ui/src/utils/slash-commands.ts)
|
||||
* so anything the chat input highlights/autocompletes as a command can be expanded.
|
||||
*/
|
||||
const SLASH_COMMAND_TOKEN_REGEX = /(^|\s)(\/[a-zA-Z0-9_.:@-]+)(?=\s|$)/g
|
||||
|
||||
/**
|
||||
* File extensions the SDK's workflow discovery accepts (`MARKDOWN_EXTENSIONS`
|
||||
* in @cline/core's user-instruction-config-loader). The SDK strips the
|
||||
* extension when naming the command; the webview autocomplete and legacy
|
||||
* toggle state keep it.
|
||||
*/
|
||||
const WORKFLOW_FILE_EXTENSION_REGEX = /\.(md|markdown|txt)$/i
|
||||
|
||||
/**
|
||||
* Canonical form used to compare workflow names across the places they appear:
|
||||
* typed slash commands and toggle paths keep the file extension, while SDK
|
||||
* command names and remote workflow names do not.
|
||||
*/
|
||||
function canonicalWorkflowName(value: string): string {
|
||||
const stripped = value.replace(WORKFLOW_FILE_EXTENSION_REGEX, "").toLowerCase()
|
||||
return stripped || value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim port of @cline/shared's private `sanitizeSegment`
|
||||
* (src/remote-config/materializer.ts), which names the files that remote
|
||||
* workflows materialize to — lower-cased, disallowed character runs collapsed
|
||||
* to `-`, capped at 80 characters. Keep in sync with the original.
|
||||
*/
|
||||
function sanitizeRemoteSegment(value: string): string {
|
||||
let result = ""
|
||||
let pendingSeparator = false
|
||||
for (const char of value.trim().toLowerCase()) {
|
||||
const code = char.charCodeAt(0)
|
||||
const isAllowed =
|
||||
(code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === "." || char === "_" || char === "-"
|
||||
if (isAllowed) {
|
||||
if (pendingSeparator && result && result[result.length - 1] !== "-") {
|
||||
result += "-"
|
||||
}
|
||||
pendingSeparator = false
|
||||
result += char
|
||||
} else {
|
||||
pendingSeparator = true
|
||||
}
|
||||
if (result.length >= 80) {
|
||||
break
|
||||
}
|
||||
}
|
||||
while (result.endsWith("-")) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
while (result.startsWith("-")) {
|
||||
result = result.slice(1)
|
||||
}
|
||||
return result || "item"
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison key for remote workflow names. The discovered record is named
|
||||
* after the sanitized file basename, while remote toggles are keyed by the
|
||||
* original config name (e.g. "Org Standards"), so apply the materializer's
|
||||
* exact transformation to both sides before comparing (it is idempotent on
|
||||
* already-sanitized names).
|
||||
*/
|
||||
function remoteWorkflowNameKey(value: string): string {
|
||||
return sanitizeRemoteSegment(value.replace(WORKFLOW_FILE_EXTENSION_REGEX, ""))
|
||||
}
|
||||
|
||||
function fileBasename(filePath: string): string {
|
||||
return filePath.replace(/^.*[/\\]/, "")
|
||||
}
|
||||
|
||||
/** Matches files materialized from remote config (`.cline/remote-config/…`). */
|
||||
const REMOTE_CONFIG_PATH_REGEX = /[/\\]\.cline[/\\]remote-config[/\\]/
|
||||
|
||||
/** The discovered workflow files toggle filtering and matching operate on. */
|
||||
export interface WorkflowRecordRef {
|
||||
/** Command name (frontmatter `name`, or file basename without extension). */
|
||||
name: string
|
||||
/** Absolute path of the workflow file. */
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface ExpandSlashCommandsOptions {
|
||||
/**
|
||||
* Exact command names of workflows the user disabled via the Workflows
|
||||
* toggles, from {@link buildDisabledWorkflowNames}. Disabled workflows are
|
||||
* left unexpanded, matching legacy semantics.
|
||||
*/
|
||||
disabledWorkflowNames?: ReadonlySet<string>
|
||||
/**
|
||||
* Discovered workflow records, used to also match a typed file name (e.g.
|
||||
* `/my-workflow.md`, what the autocomplete inserts) against a workflow
|
||||
* whose frontmatter `name` differs from its filename.
|
||||
*/
|
||||
workflowRecords?: ReadonlyArray<WorkflowRecordRef>
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the runtime command matching a typed slash-command name.
|
||||
*
|
||||
* The SDK names workflows by frontmatter `name` or file basename *without* the
|
||||
* extension, but the webview autocomplete (and legacy Cline versions) surface
|
||||
* workflow files as `/my-workflow.md`. Accept both spellings — and resolve a
|
||||
* typed file name to its frontmatter-renamed command — so workflows created
|
||||
* under the legacy extension keep working after an upgrade.
|
||||
*/
|
||||
function findRuntimeCommand(
|
||||
commands: readonly AvailableRuntimeCommand[],
|
||||
typedName: string,
|
||||
workflowRecords: ReadonlyArray<WorkflowRecordRef>,
|
||||
): AvailableRuntimeCommand | undefined {
|
||||
const withoutExtension = typedName.replace(WORKFLOW_FILE_EXTENSION_REGEX, "")
|
||||
const candidates = withoutExtension && withoutExtension !== typedName ? [typedName, withoutExtension] : [typedName]
|
||||
for (const candidate of candidates) {
|
||||
const exact = commands.find((command) => command.name === candidate)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
// The webview highlights/validates slash commands case-insensitively, so
|
||||
// fall back to a case-insensitive match rather than silently not expanding.
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase()
|
||||
const insensitive = commands.find((command) => command.name.toLowerCase() === lowered)
|
||||
if (insensitive) {
|
||||
return insensitive
|
||||
}
|
||||
}
|
||||
// Typed file name (autocomplete inserts `/my-workflow.md`) whose workflow
|
||||
// was renamed via frontmatter: resolve through the record's file basename.
|
||||
const typedCanonical = canonicalWorkflowName(typedName)
|
||||
const record = workflowRecords.find((r) => canonicalWorkflowName(fileBasename(r.filePath)) === typedCanonical)
|
||||
if (record) {
|
||||
const recordCanonical = canonicalWorkflowName(record.name)
|
||||
return commands.find((command) => canonicalWorkflowName(command.name) === recordCanonical)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the first slash command in `text` that resolves to a known
|
||||
* workflow/skill into its instruction body.
|
||||
*
|
||||
* Unlike the SDK's `resolveRuntimeSlashCommand` (leading `/command` only), this
|
||||
* matches commands anywhere in the message — the webview lets users insert a
|
||||
* slash command after whitespace mid-message, and the legacy extension expanded
|
||||
* those too. Only the first matching command is expanded, mirroring legacy
|
||||
* behavior and the webview menu (which only offers suggestions for the first
|
||||
* command in a message).
|
||||
*/
|
||||
export function expandSlashCommands(
|
||||
text: string,
|
||||
commands: readonly AvailableRuntimeCommand[],
|
||||
options: ExpandSlashCommandsOptions = {},
|
||||
): string {
|
||||
if (!text.includes("/") || commands.length === 0) {
|
||||
return text
|
||||
}
|
||||
const disabledWorkflowNames = options.disabledWorkflowNames ?? new Set()
|
||||
const workflowRecords = options.workflowRecords ?? []
|
||||
for (const match of text.matchAll(SLASH_COMMAND_TOKEN_REGEX)) {
|
||||
const token = match[2]
|
||||
const typedName = token.slice(1)
|
||||
const command = findRuntimeCommand(commands, typedName, workflowRecords)
|
||||
if (!command) {
|
||||
continue
|
||||
}
|
||||
if (command.kind === "workflow" && disabledWorkflowNames.has(command.name)) {
|
||||
continue
|
||||
}
|
||||
const start = (match.index ?? 0) + match[1].length
|
||||
const end = start + token.length
|
||||
return text.slice(0, start) + command.instructions + text.slice(end)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export interface BuildDisabledWorkflowNamesOptions {
|
||||
/** Discovered workflow records from `listRecords("workflow")`. */
|
||||
records: ReadonlyArray<WorkflowRecordRef>
|
||||
/** `globalWorkflowToggles` (global settings) — keyed by absolute file path. */
|
||||
globalToggles?: Record<string, boolean>
|
||||
/** Workspace `workflowToggles` — keyed by absolute file path. */
|
||||
workspaceToggles?: Record<string, boolean>
|
||||
/** `remoteWorkflowToggles` (global state) — keyed by remote workflow name. */
|
||||
remoteToggles?: Record<string, boolean>
|
||||
/** Names of remote workflows the organization locks on (`alwaysEnabled`). */
|
||||
remoteAlwaysEnabledNames?: Iterable<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the set of exact command names whose workflows the user disabled via
|
||||
* the Workflows toggles (local, global, and enterprise/remote scopes).
|
||||
*
|
||||
* Each command is governed by the toggle state of its own record — the file
|
||||
* whose body would actually expand — so a disabled workflow in one scope can
|
||||
* neither suppress nor unlock a *different* command that happens to share a
|
||||
* similar name in another scope. (The SDK keeps one record per command name,
|
||||
* so per-record evaluation is per-command evaluation.)
|
||||
*
|
||||
* Toggle state is matched to a record by its file basename, so a frontmatter
|
||||
* `name` that differs from the filename is still governed by the file's
|
||||
* toggle. A basename toggled in several scopes counts as enabled when *any*
|
||||
* scope has it enabled: those files collapse into a single record, and legacy
|
||||
* expansion only searched enabled workflows across scopes, so a disabled
|
||||
* workspace file must not shadow a same-named enabled global one (or vice
|
||||
* versa). Files materialized from remote config are governed by the
|
||||
* name-keyed remote toggles instead, and locked (`alwaysEnabled`) remote
|
||||
* workflows always count as enabled.
|
||||
*/
|
||||
export function buildDisabledWorkflowNames(options: BuildDisabledWorkflowNamesOptions): Set<string> {
|
||||
const enabledByBasename = new Map<string, boolean>()
|
||||
for (const toggles of [options.globalToggles ?? {}, options.workspaceToggles ?? {}]) {
|
||||
for (const [filePath, enabled] of Object.entries(toggles)) {
|
||||
const key = canonicalWorkflowName(fileBasename(filePath))
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
enabledByBasename.set(key, (enabledByBasename.get(key) ?? false) || enabled)
|
||||
}
|
||||
}
|
||||
// Distinct config names can sanitize to the same materialized name (case,
|
||||
// punctuation, or the 80-char cap); merge collisions as enabled-if-any-
|
||||
// enabled rather than letting the last entry win arbitrarily.
|
||||
const remoteToggles = new Map<string, boolean>()
|
||||
for (const [name, enabled] of Object.entries(options.remoteToggles ?? {})) {
|
||||
const key = remoteWorkflowNameKey(name)
|
||||
remoteToggles.set(key, (remoteToggles.get(key) ?? false) || enabled)
|
||||
}
|
||||
const remoteAlwaysEnabled = new Set([...(options.remoteAlwaysEnabledNames ?? [])].map(remoteWorkflowNameKey))
|
||||
|
||||
const disabled = new Set<string>()
|
||||
for (const record of options.records) {
|
||||
if (!record.name) {
|
||||
continue
|
||||
}
|
||||
let enabled: boolean
|
||||
if (REMOTE_CONFIG_PATH_REGEX.test(record.filePath)) {
|
||||
// Key off the materialized file basename — the materializer derives it
|
||||
// from the remote config name, so it stays correct even when the file's
|
||||
// frontmatter aliases the command name to something else.
|
||||
const remoteKey = remoteWorkflowNameKey(fileBasename(record.filePath))
|
||||
enabled = remoteAlwaysEnabled.has(remoteKey) || remoteToggles.get(remoteKey) !== false
|
||||
} else {
|
||||
enabled = enabledByBasename.get(canonicalWorkflowName(fileBasename(record.filePath))) ?? true
|
||||
}
|
||||
if (!enabled) {
|
||||
disabled.add(record.name)
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createDefaultShellExecutor, createMcpTools } from "@cline/core"
|
||||
import { type AgentTool, type AgentToolContext, createTool } from "@cline/shared"
|
||||
import { createMcpTools } from "@cline/core"
|
||||
import type { AgentTool, AgentToolContext } from "@cline/shared"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -44,77 +44,6 @@ class McpHubToolProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily-created shell executor for attempt_completion commands.
|
||||
* Re-uses the SDK's built-in shell executor which
|
||||
* already handles cross-platform shells, timeout, abort signals, and output truncation.
|
||||
*/
|
||||
const getCompletionCommandExecutor = (() => {
|
||||
let executor: ReturnType<typeof createDefaultShellExecutor> | undefined
|
||||
return () => {
|
||||
if (!executor) {
|
||||
executor = createDefaultShellExecutor({
|
||||
timeoutMs: 15_000, // showcase commands, not long-running builds
|
||||
maxOutputBytes: 256_000,
|
||||
})
|
||||
}
|
||||
return executor!
|
||||
}
|
||||
})()
|
||||
|
||||
function createAttemptCompletionTool(options: { cwd?: string } = {}): AgentTool {
|
||||
return createTool({
|
||||
name: "attempt_completion",
|
||||
description:
|
||||
"Once you've completed the user's task, use this tool to present the result to the user. " +
|
||||
"The user may provide feedback if they are not satisfied, which you can use to make improvements and try again.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: {
|
||||
type: "string",
|
||||
description: "A clear, brief summary of the final result of the task.",
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"An optional terminal command to showcase the result (e.g. open a dev server). " +
|
||||
"Do not use commands like echo or cat that merely print text.",
|
||||
},
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
execute: async (input: unknown, context: AgentToolContext) => {
|
||||
const parsedInput = input && typeof input === "object" ? (input as Record<string, unknown>) : {}
|
||||
const resultText = typeof parsedInput.result === "string" ? parsedInput.result : "Task completed."
|
||||
const command = typeof parsedInput.command === "string" ? parsedInput.command.trim() : undefined
|
||||
|
||||
if (!command) {
|
||||
return resultText
|
||||
}
|
||||
|
||||
// Execute the command and include its output in the result
|
||||
const cwd = options.cwd || process.cwd()
|
||||
Logger.log(`[attempt_completion] Executing command: ${command} (cwd: ${cwd})`)
|
||||
|
||||
try {
|
||||
const shellExecutor = getCompletionCommandExecutor()
|
||||
const commandOutput = await shellExecutor(command, cwd, context)
|
||||
const trimmedOutput = commandOutput.trim()
|
||||
|
||||
if (trimmedOutput) {
|
||||
return `${resultText}\n\n[Command: ${command}]\n${trimmedOutput}`
|
||||
}
|
||||
return `${resultText}\n\n[Command executed: ${command}]`
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
Logger.warn(`[attempt_completion] Command failed: ${errorMsg}`)
|
||||
return `${resultText}\n\n[Command failed: ${command}]\n${errorMsg}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface VscodeExtraToolsOptions {
|
||||
cwd?: string
|
||||
/**
|
||||
@@ -149,7 +78,10 @@ export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExt
|
||||
}),
|
||||
)
|
||||
|
||||
const tools: AgentTool[] = [createAttemptCompletionTool({ cwd: options?.cwd }), ...mcpTools.flat()]
|
||||
// No completion tool is exposed: the agent simply ends its turn with a text
|
||||
// response, and the turn-end inference in message-translator.ts styles that
|
||||
// final text as the completion feedback row.
|
||||
const tools: AgentTool[] = [...mcpTools.flat()]
|
||||
|
||||
// Add the custom run_commands tool when a terminal manager is available.
|
||||
// This replaces the SDK's built-in run_commands, which is suppressed via
|
||||
|
||||
@@ -234,6 +234,10 @@ export class VscodeSessionHost implements SdkSessionHost {
|
||||
return this.inner.readMessages(sessionId)
|
||||
}
|
||||
|
||||
async readLiveMessages(sessionId: string) {
|
||||
return this.inner.readLiveMessages(sessionId)
|
||||
}
|
||||
|
||||
async updateSessionCompactionState(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }> {
|
||||
return this.inner.updateSessionCompactionState(sessionId, state)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ export type ClineSay =
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "completion_result"
|
||||
| "plan_completion_result" // turn-final plan-mode response inferred at turn end (SDK path)
|
||||
| "user_feedback"
|
||||
| "user_feedback_diff"
|
||||
| "command"
|
||||
|
||||
@@ -40,7 +40,7 @@ export type LanguageDisplay =
|
||||
|
||||
export const DEFAULT_LANGUAGE_SETTINGS: LanguageKey = "en"
|
||||
|
||||
const languageOptions: { key: LanguageKey; display: LanguageDisplay }[] = [
|
||||
export const languageOptions: { key: LanguageKey; display: LanguageDisplay }[] = [
|
||||
{ key: "en", display: "English" },
|
||||
{ key: "ar", display: "Arabic - العربية" },
|
||||
{ key: "pt-BR", display: "Portuguese - Português (Brasil)" },
|
||||
|
||||
@@ -78,6 +78,7 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
|
||||
text: ClineSay.TEXT,
|
||||
reasoning: ClineSay.REASONING,
|
||||
completion_result: ClineSay.COMPLETION_RESULT_SAY,
|
||||
plan_completion_result: ClineSay.PLAN_COMPLETION_RESULT,
|
||||
user_feedback: ClineSay.USER_FEEDBACK,
|
||||
user_feedback_diff: ClineSay.USER_FEEDBACK_DIFF,
|
||||
command: ClineSay.COMMAND_SAY,
|
||||
@@ -128,6 +129,7 @@ function convertProtoEnumToClineSay(say: ClineSay): AppClineSay | undefined {
|
||||
[ClineSay.TEXT]: "text",
|
||||
[ClineSay.REASONING]: "reasoning",
|
||||
[ClineSay.COMPLETION_RESULT_SAY]: "completion_result",
|
||||
[ClineSay.PLAN_COMPLETION_RESULT]: "plan_completion_result",
|
||||
[ClineSay.USER_FEEDBACK]: "user_feedback",
|
||||
[ClineSay.USER_FEEDBACK_DIFF]: "user_feedback_diff",
|
||||
[ClineSay.COMMAND_SAY]: "command",
|
||||
|
||||
@@ -319,11 +319,6 @@ export const ChatRowContent = memo(
|
||||
<code className="break-all">{mcpServerUse.serverName}</code> MCP server:
|
||||
</span>,
|
||||
]
|
||||
case "completion_result":
|
||||
return [
|
||||
<span className="codicon codicon-check text-success mb-[-1.5px]" />,
|
||||
<span className="text-success font-bold">Task Completed</span>,
|
||||
]
|
||||
case "api_req_started":
|
||||
// API request rows no longer render the request payload/cost accordion.
|
||||
// Thinking/reasoning is handled directly in the api_req_started renderer below.
|
||||
@@ -935,11 +930,13 @@ export const ChatRowContent = memo(
|
||||
return (
|
||||
<CompletionOutputRow
|
||||
handleQuoteClick={handleQuoteClick}
|
||||
headClassNames={HEADER_CLASSNAMES}
|
||||
quoteButtonState={quoteButtonState}
|
||||
text={text || ""}
|
||||
/>
|
||||
)
|
||||
case "plan_completion_result":
|
||||
// Turn-final plan-mode response inferred at turn end (SDK path)
|
||||
return <PlanCompletionOutputRow text={message.text || ""} />
|
||||
case "shell_integration_warning":
|
||||
return (
|
||||
<div className="flex flex-col bg-warning/20 p-2 rounded-xs border border-error">
|
||||
@@ -1032,7 +1029,6 @@ export const ChatRowContent = memo(
|
||||
return (
|
||||
<CompletionOutputRow
|
||||
handleQuoteClick={handleQuoteClick}
|
||||
headClassNames={HEADER_CLASSNAMES}
|
||||
quoteButtonState={quoteButtonState}
|
||||
text={text || ""}
|
||||
/>
|
||||
@@ -1137,10 +1133,7 @@ export const ChatRowContent = memo(
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<PlanCompletionOutputRow
|
||||
headClassNames={HEADER_CLASSNAMES}
|
||||
text={response || message.text || ""}
|
||||
/>
|
||||
<PlanCompletionOutputRow text={response || message.text || ""} />
|
||||
<OptionsButtons
|
||||
inputValue={inputValue}
|
||||
isActive={
|
||||
|
||||
@@ -142,7 +142,11 @@ const ClineFreeModelLimitError = ({ message }: ClineFreeModelLimitErrorProps) =>
|
||||
className="w-full mt-3"
|
||||
disabled={isSwitching || didSwitch}
|
||||
onClick={handleSwitchToPaidModel}>
|
||||
{isSwitching ? "Switching..." : didSwitch ? "Switched to the paid model" : "Switch to the paid model"}
|
||||
{isSwitching
|
||||
? "Switching..."
|
||||
: didSwitch
|
||||
? "Switched to Usage-Based billing"
|
||||
: "Switch to Usage-Based billing"}
|
||||
</VSCodeButton>
|
||||
{didSwitch && (
|
||||
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CopyButton } from "../common/CopyButton"
|
||||
import { QuoteButtonState } from "./ChatRow"
|
||||
import { MarkdownRow } from "./MarkdownRow"
|
||||
import QuoteButton from "./QuoteButton"
|
||||
@@ -10,35 +7,25 @@ interface CompletionOutputRowProps {
|
||||
text: string
|
||||
quoteButtonState: QuoteButtonState
|
||||
handleQuoteClick: () => void
|
||||
headClassNames?: string
|
||||
}
|
||||
|
||||
export const CompletionOutputRow = memo(
|
||||
({ headClassNames, text, quoteButtonState, handleQuoteClick }: CompletionOutputRowProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-sm border border-success/20 overflow-visible bg-success/10 p-2 pt-3">
|
||||
{/* Title */}
|
||||
<div className={cn(headClassNames, "justify-between px-1")}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CheckIcon className="size-3 text-success" />
|
||||
<span className="text-success font-bold">Task Completed</span>
|
||||
</div>
|
||||
<CopyButton className="text-success" textToCopy={text} />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="w-full relative border-t-1 border-description/20 rounded-b-sm">
|
||||
<div className="completion-output-content p-2 pt-3 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 rounded-sm">
|
||||
<MarkdownRow markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton left={quoteButtonState.left} onClick={handleQuoteClick} top={quoteButtonState.top} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
/**
|
||||
* Quiet visual cue that the agent's turn ended on this response (act mode):
|
||||
* a green-tinted container with no header or label. The response might be a
|
||||
* question or an interim summary rather than a definitive task completion,
|
||||
* so the box deliberately makes no "Task Completed" claim.
|
||||
*/
|
||||
export const CompletionOutputRow = memo(({ text, quoteButtonState, handleQuoteClick }: CompletionOutputRowProps) => {
|
||||
return (
|
||||
<div className="rounded-sm border border-success/20 overflow-visible bg-success/10">
|
||||
<div className="completion-output-content relative p-2 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0 rounded-sm">
|
||||
<MarkdownRow markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton left={quoteButtonState.left} onClick={handleQuoteClick} top={quoteButtonState.top} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CompletionOutputRow.displayName = "CompletionOutputRow"
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
import { NotepadTextIcon } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
import { CopyButton } from "@/components/common/CopyButton"
|
||||
import MarkdownBlock from "@/components/common/MarkdownBlock"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface PlanCompletionOutputProps {
|
||||
text: string
|
||||
onCopy?: () => void
|
||||
headClassNames?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Styled completion output for Plan Mode responses
|
||||
* Uses grayscale colors to distinguish from Act Mode's green success theme
|
||||
* Quiet visual cue that the agent's plan-mode turn ended on this response:
|
||||
* a container tinted with the yellow plan accent (matching the plan/act
|
||||
* toggle and the CLI's plan-mode color) with no header or label. The
|
||||
* response might be a question rather than a finished plan, so the box
|
||||
* deliberately makes no "Plan Created" claim.
|
||||
*/
|
||||
const PlanCompletionOutputRow = memo(({ text, headClassNames }: PlanCompletionOutputProps) => {
|
||||
const PlanCompletionOutputRow = memo(({ text }: PlanCompletionOutputProps) => {
|
||||
return (
|
||||
<div className="rounded-sm border border-description/50 overflow-visible bg-code p-2 pt-3">
|
||||
{/* Header */}
|
||||
<div className={cn(headClassNames, "justify-between px-1")}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<NotepadTextIcon className="size-2" />
|
||||
<span className="text-foreground font-bold">Plan Created</span>
|
||||
</div>
|
||||
<CopyButton textToCopy={text || ""} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full relative border-t-1 border-description/20 rounded-b-sm">
|
||||
<div className="plan-completion-content p-2 pt-3 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0">
|
||||
<div className="wrap-anywhere [&_hr]:opacity-20">
|
||||
<MarkdownBlock markdown={text} />
|
||||
</div>
|
||||
<div className="rounded-sm border border-warning/20 overflow-visible bg-warning/10">
|
||||
<div className="plan-completion-content p-2 w-full [&_hr]:opacity-20 [&_p:last-child]:mb-0">
|
||||
<div className="wrap-anywhere [&_hr]:opacity-20">
|
||||
<MarkdownBlock markdown={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,11 @@ export const RequestStartRow: React.FC<RequestStartRowProps> = ({
|
||||
const hasCost = cost != null
|
||||
const hasReasoning = !!reasoningContent
|
||||
const hasCompletionResult = clineMessages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result" || msg.ask === "plan_mode_respond",
|
||||
(msg) =>
|
||||
msg.ask === "completion_result" ||
|
||||
msg.say === "completion_result" ||
|
||||
msg.say === "plan_completion_result" ||
|
||||
msg.ask === "plan_mode_respond",
|
||||
)
|
||||
|
||||
const apiReqState: ApiReqState = hasError ? "error" : hasCost ? "final" : hasReasoning ? "thinking" : "pre"
|
||||
|
||||
+11
-104
@@ -5,8 +5,8 @@ import { Virtuoso } from "react-virtuoso"
|
||||
import { StickyUserMessage } from "@/components/chat/task-header/StickyUserMessage"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThinkingLoaderRow } from "../../hooks/useThinkingLoaderRow"
|
||||
import type { ChatState, MessageHandlers, ScrollBehavior } from "../../types/chatTypes"
|
||||
import { isToolGroup } from "../../utils/messageUtils"
|
||||
import { createMessageRenderer } from "../messages/MessageRenderer"
|
||||
|
||||
// Sentinel ts for the synthetic "Thinking..." placeholder row. Not a real message; ignored when
|
||||
@@ -77,109 +77,16 @@ export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
return Array.isArray(lastRow) ? lastRow.at(-1) : lastRow
|
||||
}, [lastVisibleRow])
|
||||
|
||||
// Show "Thinking..." until real content starts streaming.
|
||||
// This is the sole early loading indicator - RequestStartRow does NOT duplicate it.
|
||||
// Covers: pre-api_req_started (backend processing) AND post-api_req_started (waiting for model).
|
||||
// Hides once reasoning, tools, text, or any other content message appears.
|
||||
const isWaitingForResponse = useMemo(() => {
|
||||
const lastMsg = modifiedMessages[modifiedMessages.length - 1]
|
||||
|
||||
// AUTHORITATIVE PATH: when the backend provides a TurnState, the agent is only "thinking"
|
||||
// while phase === "streaming". Any other phase (awaiting_approval/followup, completed,
|
||||
// error, resumable, idle) is never a thinking state — this is what makes the footer
|
||||
// immune to trailing bookkeeping messages and prevents the stuck-"Thinking" bug (RC1).
|
||||
// During streaming we still suppress the footer loader once a partial content row is
|
||||
// actually rendering, to avoid a duplicate spinner (handled by the legacy sub-logic
|
||||
// below, which only runs in the streaming case).
|
||||
if (turnState) {
|
||||
if (turnState.phase !== "streaming") {
|
||||
return false
|
||||
}
|
||||
// phase === streaming: show Thinking until a visible content row is streaming.
|
||||
if (groupedMessages.length === 0 || !lastVisibleMessage) {
|
||||
return true
|
||||
}
|
||||
if (lastVisibleRow && isToolGroup(lastVisibleRow)) {
|
||||
return true
|
||||
}
|
||||
return lastVisibleMessage.partial !== true
|
||||
}
|
||||
|
||||
// LEGACY PATH (no TurnState — classic/older state): infer from the message tail.
|
||||
// Never show thinking while waiting on user input (any ask state).
|
||||
// This includes completion_result, tool approvals, followups, and resume asks.
|
||||
if (lastRawMessage?.type === "ask") {
|
||||
return false
|
||||
}
|
||||
// attempt_completion emits a final say("completion_result") before ask("completion_result").
|
||||
// Treat that final completion message as non-waiting to avoid a brief footer flicker.
|
||||
if (lastRawMessage?.type === "say" && lastRawMessage.say === "completion_result") {
|
||||
return false
|
||||
}
|
||||
if (lastRawMessage?.type === "say" && lastRawMessage.say === "api_req_started") {
|
||||
try {
|
||||
const info = JSON.parse(lastRawMessage.text || "{}")
|
||||
if (info.cancelReason === "user_cancelled") {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Always show while task has started but no visible rows are rendered yet.
|
||||
if (groupedMessages.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Defensive guard for transient states where a grouped row exists
|
||||
// but we still cannot resolve a concrete visible message.
|
||||
if (!lastVisibleMessage) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Always show when the last rendered row is a toolgroup.
|
||||
if (lastVisibleRow && isToolGroup(lastVisibleRow)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// User-requested behavior:
|
||||
// if the last visible row is not actively partial, always show Thinking in the footer.
|
||||
// (some rows like checkpoint_created don't set `partial`, and should be treated as non-partial)
|
||||
if (lastVisibleMessage.partial !== true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!lastMsg) {
|
||||
// No messages after the initial task message - new task just started
|
||||
return true
|
||||
}
|
||||
if (lastMsg.say === "user_feedback" || lastMsg.say === "user_feedback_diff") return true
|
||||
if (lastMsg.say === "api_req_started") {
|
||||
try {
|
||||
const info = JSON.parse(lastMsg.text || "{}")
|
||||
// Still in progress (no cost) and nothing has streamed after it yet
|
||||
return info.cost == null
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [turnState, lastRawMessage, groupedMessages.length, lastVisibleMessage, lastVisibleRow, modifiedMessages])
|
||||
|
||||
// Keep loader in the message flow (not footer). During handoff from waiting -> reasoning stream,
|
||||
// keep the loader mounted until a real reasoning row is visible.
|
||||
const showThinkingLoaderRow = useMemo(() => {
|
||||
const handoffToReasoningPending =
|
||||
lastRawMessage?.type === "say" &&
|
||||
lastRawMessage.say === "reasoning" &&
|
||||
lastRawMessage.partial === true &&
|
||||
lastVisibleMessage?.say !== "reasoning"
|
||||
|
||||
// Mirror the old footer behavior exactly: show whenever waiting logic says so.
|
||||
// Plus a brief handoff guard while grouped rows catch up to raw reasoning stream.
|
||||
return isWaitingForResponse || handoffToReasoningPending
|
||||
}, [isWaitingForResponse, lastRawMessage, lastVisibleMessage?.say])
|
||||
// Keep loader in the message flow (not footer). Show/hide logic (waiting heuristic,
|
||||
// waiting -> reasoning handoff guard, and anti-flash debounce on turn end) lives in the hook.
|
||||
const showThinkingLoaderRow = useThinkingLoaderRow({
|
||||
turnState,
|
||||
lastRawMessage,
|
||||
groupedMessages,
|
||||
lastVisibleRow,
|
||||
lastVisibleMessage,
|
||||
modifiedMessages,
|
||||
})
|
||||
|
||||
const displayedGroupedMessages = useMemo<(ClineMessage | ClineMessage[])[]>(() => {
|
||||
if (!showThinkingLoaderRow) {
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
export { useChatState } from "./useChatState"
|
||||
export { useMessageHandlers } from "./useMessageHandlers"
|
||||
export { useScrollBehavior } from "./useScrollBehavior"
|
||||
export { useThinkingLoaderRow } from "./useThinkingLoaderRow"
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import type { ClineMessage, TurnState } from "@shared/ExtensionMessage"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
computeIsWaitingForResponse,
|
||||
THINKING_LOADER_GRACE_MS,
|
||||
type ThinkingLoaderInputs,
|
||||
useThinkingLoaderRow,
|
||||
} from "./useThinkingLoaderRow"
|
||||
|
||||
function say(ts: number, sayType: ClineMessage["say"], partial?: boolean, text = ""): ClineMessage {
|
||||
return { ts, type: "say", say: sayType, text, partial }
|
||||
}
|
||||
|
||||
function streaming(seq = 1): TurnState {
|
||||
return { phase: "streaming", seq }
|
||||
}
|
||||
|
||||
function inputsFor(messages: ClineMessage[], turnState: TurnState | undefined): ThinkingLoaderInputs {
|
||||
return {
|
||||
turnState,
|
||||
lastRawMessage: messages.at(-1),
|
||||
groupedMessages: messages,
|
||||
lastVisibleRow: messages.at(-1),
|
||||
lastVisibleMessage: messages.at(-1),
|
||||
modifiedMessages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
describe("computeIsWaitingForResponse (turnState path)", () => {
|
||||
it("waits while streaming with no visible rows yet", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([], streaming()))).toBe(true)
|
||||
})
|
||||
|
||||
it("does not wait while a content row is actively streaming", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "text", true)], streaming()))).toBe(false)
|
||||
})
|
||||
|
||||
it("waits when the last visible row is no longer partial while streaming", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "text", false)], streaming()))).toBe(true)
|
||||
})
|
||||
|
||||
it("never waits outside the streaming phase", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "text", false)], { phase: "awaiting_followup", seq: 2 }))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "text", false)], { phase: "completed", seq: 2 }))).toBe(false)
|
||||
})
|
||||
|
||||
it("does not wait on a final completion_result even while phase is still streaming", () => {
|
||||
// attempt_completion's say("completion_result") lands before the done event flips the
|
||||
// phase to "completed"; the loader must not flash during that gap.
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "completion_result", false)], streaming()))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeIsWaitingForResponse (legacy path)", () => {
|
||||
it("does not wait when the last raw message is an ask", () => {
|
||||
const ask: ClineMessage = { ts: 1, type: "ask", ask: "followup", text: "?", partial: false }
|
||||
expect(computeIsWaitingForResponse(inputsFor([ask], undefined))).toBe(false)
|
||||
})
|
||||
|
||||
it("does not wait on a final completion_result", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "completion_result", false)], undefined))).toBe(false)
|
||||
})
|
||||
|
||||
it("waits when the last visible row is not actively partial", () => {
|
||||
expect(computeIsWaitingForResponse(inputsFor([say(1, "text", false)], undefined))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("useThinkingLoaderRow anti-flash debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderLoader(initial: ThinkingLoaderInputs) {
|
||||
return renderHook((inputs: ThinkingLoaderInputs) => useThinkingLoaderRow(inputs), { initialProps: initial })
|
||||
}
|
||||
|
||||
it("does not flash when the turn completes right after the tail message finalizes", () => {
|
||||
// Streaming text row: loader hidden.
|
||||
const { result, rerender } = renderLoader(inputsFor([say(1, "text", true)], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
// Tail finalizes (partial -> false) while turnState still says "streaming":
|
||||
// the loader must NOT appear immediately.
|
||||
rerender(inputsFor([say(1, "text", false)], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
// The done event flips the phase before the grace period elapses: no flash.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(THINKING_LOADER_GRACE_MS - 100)
|
||||
})
|
||||
rerender(inputsFor([say(1, "text", false)], { phase: "awaiting_followup", seq: 2 }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(THINKING_LOADER_GRACE_MS)
|
||||
})
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it("shows the loader after the grace period when the wait is real (mid-turn)", () => {
|
||||
const { result, rerender } = renderLoader(inputsFor([say(1, "text", true)], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
rerender(inputsFor([say(1, "text", false)], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(THINKING_LOADER_GRACE_MS)
|
||||
})
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it("shows the loader immediately at turn start (no finalizing tail involved)", () => {
|
||||
const userMessage = say(1, "user_feedback", false, "do the thing")
|
||||
const { result, rerender } = renderLoader(inputsFor([userMessage], { phase: "awaiting_followup", seq: 1 }))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
rerender(inputsFor([userMessage], streaming(2)))
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it("hides the loader as soon as new content starts streaming during the wait", () => {
|
||||
const { result, rerender } = renderLoader(inputsFor([say(1, "text", false)], streaming()))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(THINKING_LOADER_GRACE_MS)
|
||||
})
|
||||
expect(result.current).toBe(true)
|
||||
|
||||
rerender(inputsFor([say(1, "text", false), say(2, "reasoning", true, "hmm")], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it("does not flash on attempt_completion turns even without the debounce timing", () => {
|
||||
const { result, rerender } = renderLoader(inputsFor([say(1, "completion_result", true)], streaming()))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
rerender(inputsFor([say(1, "completion_result", false)], streaming()))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(THINKING_LOADER_GRACE_MS)
|
||||
})
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ClineMessage, TurnState } from "@shared/ExtensionMessage"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { isToolGroup } from "../utils/messageUtils"
|
||||
|
||||
/**
|
||||
* Grace period before showing the "Thinking..." loader row when its trigger is the tail
|
||||
* message finishing streaming (partial -> false). That signal is ambiguous: mid-turn it means
|
||||
* "waiting on the model's next content block / API request" (loader wanted), but at the end of
|
||||
* a turn it arrives via the fast partial-message stream moments before the `done` event flips
|
||||
* turnState out of "streaming" via a full state post. Showing instantly in that window makes
|
||||
* the loader flash in and out on every turn completion, so hold off briefly — a real wait
|
||||
* outlives the grace period, while the turn-end phase change cancels it.
|
||||
*/
|
||||
export const THINKING_LOADER_GRACE_MS = 500
|
||||
|
||||
export interface ThinkingLoaderInputs {
|
||||
turnState: TurnState | undefined
|
||||
/** Tail of the raw clineMessages array. */
|
||||
lastRawMessage: ClineMessage | undefined
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[]
|
||||
/** Tail of groupedMessages. */
|
||||
lastVisibleRow: ClineMessage | ClineMessage[] | undefined
|
||||
/** Tail message of lastVisibleRow (last element when it is a group). */
|
||||
lastVisibleMessage: ClineMessage | undefined
|
||||
modifiedMessages: ClineMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent is presumed to be working with nothing visibly streaming yet, i.e. the
|
||||
* "Thinking..." loader row should be requested. This is the sole early loading indicator -
|
||||
* RequestStartRow does NOT duplicate it.
|
||||
* Covers: pre-api_req_started (backend processing) AND post-api_req_started (waiting for model).
|
||||
* Hides once reasoning, tools, text, or any other content message appears.
|
||||
*/
|
||||
export function computeIsWaitingForResponse({
|
||||
turnState,
|
||||
lastRawMessage,
|
||||
groupedMessages,
|
||||
lastVisibleRow,
|
||||
lastVisibleMessage,
|
||||
modifiedMessages,
|
||||
}: ThinkingLoaderInputs): boolean {
|
||||
const lastMsg = modifiedMessages[modifiedMessages.length - 1]
|
||||
|
||||
// AUTHORITATIVE PATH: when the backend provides a TurnState, the agent is only "thinking"
|
||||
// while phase === "streaming". Any other phase (awaiting_approval/followup, completed,
|
||||
// error, resumable, idle) is never a thinking state — this is what makes the footer
|
||||
// immune to trailing bookkeeping messages and prevents the stuck-"Thinking" bug (RC1).
|
||||
// During streaming we still suppress the footer loader once a partial content row is
|
||||
// actually rendering, to avoid a duplicate spinner (handled by the legacy sub-logic
|
||||
// below, which only runs in the streaming case).
|
||||
if (turnState) {
|
||||
if (turnState.phase !== "streaming") {
|
||||
return false
|
||||
}
|
||||
// attempt_completion emits a final say("completion_result") a beat before the `done`
|
||||
// event flips the phase to "completed", and the turn-end inferred completion rows
|
||||
// (say completion_result / plan_completion_result) likewise land just before the phase
|
||||
// change. Treat them as non-waiting so the loader doesn't flash during that gap (same
|
||||
// anti-flicker guard as the legacy path below).
|
||||
if (
|
||||
lastRawMessage?.type === "say" &&
|
||||
(lastRawMessage.say === "completion_result" || lastRawMessage.say === "plan_completion_result")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// phase === streaming: show Thinking until a visible content row is streaming.
|
||||
if (groupedMessages.length === 0 || !lastVisibleMessage) {
|
||||
return true
|
||||
}
|
||||
if (lastVisibleRow && isToolGroup(lastVisibleRow)) {
|
||||
return true
|
||||
}
|
||||
return lastVisibleMessage.partial !== true
|
||||
}
|
||||
|
||||
// LEGACY PATH (no TurnState — classic/older state): infer from the message tail.
|
||||
// Never show thinking while waiting on user input (any ask state).
|
||||
// This includes completion_result, tool approvals, followups, and resume asks.
|
||||
if (lastRawMessage?.type === "ask") {
|
||||
return false
|
||||
}
|
||||
// attempt_completion emits a final say("completion_result") before ask("completion_result").
|
||||
// Treat that final completion message as non-waiting to avoid a brief footer flicker.
|
||||
// The turn-end inferred completion rows (say completion_result / plan_completion_result)
|
||||
// are likewise terminal.
|
||||
if (
|
||||
lastRawMessage?.type === "say" &&
|
||||
(lastRawMessage.say === "completion_result" || lastRawMessage.say === "plan_completion_result")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (lastRawMessage?.type === "say" && lastRawMessage.say === "api_req_started") {
|
||||
try {
|
||||
const info = JSON.parse(lastRawMessage.text || "{}")
|
||||
if (info.cancelReason === "user_cancelled") {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Always show while task has started but no visible rows are rendered yet.
|
||||
if (groupedMessages.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Defensive guard for transient states where a grouped row exists
|
||||
// but we still cannot resolve a concrete visible message.
|
||||
if (!lastVisibleMessage) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Always show when the last rendered row is a toolgroup.
|
||||
if (lastVisibleRow && isToolGroup(lastVisibleRow)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// User-requested behavior:
|
||||
// if the last visible row is not actively partial, always show Thinking in the footer.
|
||||
// (some rows like checkpoint_created don't set `partial`, and should be treated as non-partial)
|
||||
if (lastVisibleMessage.partial !== true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!lastMsg) {
|
||||
// No messages after the initial task message - new task just started
|
||||
return true
|
||||
}
|
||||
if (lastMsg.say === "user_feedback" || lastMsg.say === "user_feedback_diff") return true
|
||||
if (lastMsg.say === "api_req_started") {
|
||||
try {
|
||||
const info = JSON.parse(lastMsg.text || "{}")
|
||||
// Still in progress (no cost) and nothing has streamed after it yet
|
||||
return info.cost == null
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced visibility for the in-list "Thinking..." loader row.
|
||||
*
|
||||
* Shows immediately for unambiguous triggers (turn start, new message appended, tool group
|
||||
* tail). When the trigger is the current tail message transitioning partial -> non-partial,
|
||||
* showing is delayed by THINKING_LOADER_GRACE_MS: at turn end that transition happens just
|
||||
* before the phase flips out of "streaming", and an instant loader would flash in and out.
|
||||
*/
|
||||
export function useDebouncedLoaderVisibility(shouldShow: boolean, tailTs: number | undefined, tailIsPartial: boolean): boolean {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const prevTailRef = useRef<{ ts: number | undefined; partial: boolean }>({ ts: undefined, partial: false })
|
||||
|
||||
useEffect(() => {
|
||||
const prevTail = prevTailRef.current
|
||||
prevTailRef.current = { ts: tailTs, partial: tailIsPartial }
|
||||
|
||||
if (!shouldShow) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tailJustFinishedStreaming = tailTs !== undefined && prevTail.ts === tailTs && prevTail.partial && !tailIsPartial
|
||||
if (!tailJustFinishedStreaming) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), THINKING_LOADER_GRACE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [shouldShow, tailTs, tailIsPartial])
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the in-list "Thinking..." loader row should currently be rendered.
|
||||
* Combines the waiting heuristic, the waiting -> reasoning handoff guard, and the
|
||||
* anti-flash debounce for tail-finalization triggers.
|
||||
*/
|
||||
export function useThinkingLoaderRow(inputs: ThinkingLoaderInputs): boolean {
|
||||
const { turnState, lastRawMessage, groupedMessages, lastVisibleRow, lastVisibleMessage, modifiedMessages } = inputs
|
||||
|
||||
const isWaitingForResponse = useMemo(
|
||||
() =>
|
||||
computeIsWaitingForResponse({
|
||||
turnState,
|
||||
lastRawMessage,
|
||||
groupedMessages,
|
||||
lastVisibleRow,
|
||||
lastVisibleMessage,
|
||||
modifiedMessages,
|
||||
}),
|
||||
[turnState, lastRawMessage, groupedMessages, lastVisibleRow, lastVisibleMessage, modifiedMessages],
|
||||
)
|
||||
|
||||
// During handoff from waiting -> reasoning stream, keep the loader mounted until a real
|
||||
// reasoning row is visible in the grouped list.
|
||||
const handoffToReasoningPending =
|
||||
lastRawMessage?.type === "say" &&
|
||||
lastRawMessage.say === "reasoning" &&
|
||||
lastRawMessage.partial === true &&
|
||||
lastVisibleMessage?.say !== "reasoning"
|
||||
|
||||
return useDebouncedLoaderVisibility(
|
||||
isWaitingForResponse || handoffToReasoningPending,
|
||||
lastVisibleMessage?.ts,
|
||||
lastVisibleMessage?.partial === true,
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import NewTaskButton from "./buttons/NewTaskButton"
|
||||
import OpenDiskConversationHistoryButton from "./buttons/OpenDiskConversationHistoryButton"
|
||||
import ContextWindow from "./ContextWindow"
|
||||
import { highlightText } from "./Highlights"
|
||||
import TaskWorkingDirectoryBadge from "./TaskWorkingDirectoryBadge"
|
||||
|
||||
const IS_DEV = process.env.IS_DEV === "true"
|
||||
interface TaskHeaderProps {
|
||||
@@ -49,6 +50,8 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
expandTaskHeader: isTaskExpanded,
|
||||
setExpandTaskHeader: setIsTaskExpanded,
|
||||
environment,
|
||||
workspaceRoots,
|
||||
platform,
|
||||
} = useExtensionState()
|
||||
|
||||
const [isHighlightedTextExpanded, setIsHighlightedTextExpanded] = useState(false)
|
||||
@@ -161,6 +164,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-end select-none shrink-0">
|
||||
<TaskWorkingDirectoryBadge
|
||||
platform={platform}
|
||||
taskCwd={currentTaskItem?.cwdOnTaskInitialization}
|
||||
workspaceRoots={workspaceRoots}
|
||||
/>
|
||||
{isCostAvailable && (
|
||||
<div
|
||||
className="mx-1 px-1 py-0.25 rounded-full inline-flex shrink-0 text-badge-background bg-badge-foreground/80 items-center"
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import type { WorkspaceRoot } from "@shared/multi-root/types"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import TaskWorkingDirectoryBadge, { isTaskCwdOutsideWorkspace } from "./TaskWorkingDirectoryBadge"
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
TooltipContent: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const roots = (...paths: string[]): WorkspaceRoot[] => paths.map((path) => ({ path, vcs: "git" }) as WorkspaceRoot)
|
||||
|
||||
describe("isTaskCwdOutsideWorkspace", () => {
|
||||
it("is false when the cwd equals a workspace root", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/home/user/project", roots("/home/user/project"), "linux")).toBe(false)
|
||||
})
|
||||
|
||||
it("is false when the cwd is inside a workspace root", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/home/user/project/packages/app", roots("/home/user/project"), "linux")).toBe(false)
|
||||
})
|
||||
|
||||
it("is true when the cwd is outside every workspace root", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/cline-hello", roots("/home/user/project"), "linux")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not treat a sibling path sharing a prefix as inside", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/home/user/project-other", roots("/home/user/project"), "linux")).toBe(true)
|
||||
})
|
||||
|
||||
it("is false when the cwd is unknown or roots are missing", () => {
|
||||
expect(isTaskCwdOutsideWorkspace(undefined, roots("/home/user/project"), "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("", roots("/home/user/project"), "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/cline-hello", [], "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/cline-hello", undefined, "linux")).toBe(false)
|
||||
})
|
||||
|
||||
it("ignores trailing separators", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/home/user/project/", roots("/home/user/project"), "linux")).toBe(false)
|
||||
})
|
||||
|
||||
it("checks all roots in a multi-root workspace", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/repos/backend/src", roots("/repos/frontend", "/repos/backend"), "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("/repos/infra", roots("/repos/frontend", "/repos/backend"), "linux")).toBe(true)
|
||||
})
|
||||
|
||||
describe("case sensitivity is platform-aware", () => {
|
||||
it("treats case-only differences as the same directory on win32", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("C:\\Users\\Dev\\Project", roots("c:/users/dev/project"), "win32")).toBe(false)
|
||||
})
|
||||
|
||||
it("treats case-only differences as distinct directories on linux and darwin", () => {
|
||||
// darwin is strict to match arePathsEqual and to cover
|
||||
// case-sensitive macOS volumes (see normalizeForComparison).
|
||||
expect(isTaskCwdOutsideWorkspace("/repo/App", roots("/repo/app"), "linux")).toBe(true)
|
||||
expect(isTaskCwdOutsideWorkspace("/Users/dev/Project", roots("/users/dev/project"), "darwin")).toBe(true)
|
||||
})
|
||||
|
||||
it("stays strict when the platform is unknown", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/repo/App", roots("/repo/app"), "unknown")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("separators are platform-aware", () => {
|
||||
it("treats backslashes as separators only on win32", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("C:\\repo\\app\\src", roots("C:/repo/app"), "win32")).toBe(false)
|
||||
// On POSIX a backslash is an ordinary filename character.
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/weird\\dir", roots("/tmp/weird\\dir"), "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/weird\\dir", roots("/tmp/weird/dir"), "linux")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filesystem roots as workspace roots", () => {
|
||||
it("treats descendants of a '/' workspace root as inside", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("/tmp/task", roots("/"), "linux")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("/", roots("/"), "linux")).toBe(false)
|
||||
})
|
||||
|
||||
it("treats descendants of a Windows drive root as inside", () => {
|
||||
expect(isTaskCwdOutsideWorkspace("C:\\Users\\dev", roots("C:\\"), "win32")).toBe(false)
|
||||
expect(isTaskCwdOutsideWorkspace("D:\\work", roots("C:\\"), "win32")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskWorkingDirectoryBadge", () => {
|
||||
it("renders the cwd basename and full-path tooltip when outside the workspace", () => {
|
||||
render(
|
||||
<TaskWorkingDirectoryBadge
|
||||
platform="linux"
|
||||
taskCwd="/tmp/cline-hello"
|
||||
workspaceRoots={roots("/home/user/project")}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("cline-hello")).toBeDefined()
|
||||
expect(screen.getByText(/working directory is \/tmp\/cline-hello/)).toBeDefined()
|
||||
})
|
||||
|
||||
it("renders nothing when the cwd is inside the workspace", () => {
|
||||
const { container } = render(
|
||||
<TaskWorkingDirectoryBadge
|
||||
platform="linux"
|
||||
taskCwd="/home/user/project/src"
|
||||
workspaceRoots={roots("/home/user/project")}
|
||||
/>,
|
||||
)
|
||||
expect(container.innerHTML).toBe("")
|
||||
})
|
||||
|
||||
it("renders nothing when workspace roots are not yet known", () => {
|
||||
const { container } = render(
|
||||
<TaskWorkingDirectoryBadge platform="linux" taskCwd="/tmp/cline-hello" workspaceRoots={[]} />,
|
||||
)
|
||||
expect(container.innerHTML).toBe("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Platform } from "@shared/ExtensionMessage"
|
||||
import type { WorkspaceRoot } from "@shared/multi-root/types"
|
||||
import { FolderIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
/**
|
||||
* Normalizes a path for comparison only, with platform-aware semantics that
|
||||
* match the extension host's `arePathsEqual` in src/utils/path.ts:
|
||||
* - win32: backslashes are separators; comparison is case-insensitive.
|
||||
* - everything else (including darwin and "unknown"): strict —
|
||||
* case-sensitive and backslash is an ordinary filename character.
|
||||
*
|
||||
* darwin is deliberately strict even though the default APFS volume is
|
||||
* case-insensitive: macOS volumes can be case-sensitive, and for a warning
|
||||
* badge a rare spurious warning is better than silently hiding a real
|
||||
* mismatch. This also keeps the policy identical to `arePathsEqual`.
|
||||
*/
|
||||
function normalizeForComparison(p: string, platform: Platform): string {
|
||||
let normalized = p.trim()
|
||||
if (platform === "win32") {
|
||||
normalized = normalized.replace(/\\/g, "/").toLowerCase()
|
||||
}
|
||||
while (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the task's working directory is neither one of the open
|
||||
* workspace roots nor inside one of them. Returns false when either side is
|
||||
* unknown (no cwd recorded, or roots not yet initialized) so the badge never
|
||||
* shows a false alarm.
|
||||
*/
|
||||
export function isTaskCwdOutsideWorkspace(
|
||||
taskCwd: string | undefined,
|
||||
workspaceRoots: WorkspaceRoot[] | undefined,
|
||||
platform: Platform,
|
||||
): boolean {
|
||||
const cwd = taskCwd?.trim()
|
||||
if (!cwd || !workspaceRoots || workspaceRoots.length === 0) {
|
||||
return false
|
||||
}
|
||||
const normalizedCwd = normalizeForComparison(cwd, platform)
|
||||
return !workspaceRoots.some((root) => {
|
||||
const rootPath = normalizeForComparison(root.path ?? "", platform)
|
||||
if (rootPath.length === 0) {
|
||||
return false
|
||||
}
|
||||
// A root may already end with a separator (e.g. "/" or "C:/").
|
||||
const containmentPrefix = rootPath.endsWith("/") ? rootPath : `${rootPath}/`
|
||||
return normalizedCwd === rootPath || normalizedCwd.startsWith(containmentPrefix)
|
||||
})
|
||||
}
|
||||
|
||||
function basename(p: string, platform: Platform): string {
|
||||
let cleaned = p
|
||||
if (platform === "win32") {
|
||||
cleaned = cleaned.replace(/\\/g, "/")
|
||||
}
|
||||
cleaned = cleaned.replace(/\/+$/, "")
|
||||
return cleaned.split("/").pop() || p
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent task-header chip shown when a task's working directory lies
|
||||
* outside the folder(s) open in this window (e.g. a task resumed from the
|
||||
* CLI or from another workspace). Cline reads, edits, and runs commands in
|
||||
* the task's own cwd, so the mismatch must stay visible for the whole task.
|
||||
*/
|
||||
const TaskWorkingDirectoryBadge: React.FC<{
|
||||
taskCwd?: string
|
||||
workspaceRoots?: WorkspaceRoot[]
|
||||
platform: Platform
|
||||
}> = ({ taskCwd, workspaceRoots, platform }) => {
|
||||
if (!isTaskCwdOutsideWorkspace(taskCwd, workspaceRoots ?? [], platform)) {
|
||||
return null
|
||||
}
|
||||
const cwd = (taskCwd ?? "").trim()
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipContent className="max-w-xs" side="bottom">
|
||||
This task's working directory is {cwd}, which is outside the current workspace. Cline reads and edits files and
|
||||
runs commands there.
|
||||
</TooltipContent>
|
||||
<TooltipTrigger className="flex items-center min-w-0">
|
||||
<div
|
||||
aria-label={`Task working directory ${cwd} is outside the current workspace`}
|
||||
className="mx-1 px-1.5 py-0.25 rounded-full inline-flex items-center gap-1 min-w-0 max-w-32 border border-(--vscode-editorWarning-foreground)/60 text-(--vscode-editorWarning-foreground)"
|
||||
id="task-cwd-badge">
|
||||
<FolderIcon className="shrink-0" size={11} />
|
||||
<span className="text-xs whitespace-nowrap overflow-hidden text-ellipsis min-w-0">
|
||||
{basename(cwd, platform)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskWorkingDirectoryBadge
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ToggleCursorRuleRequest,
|
||||
ToggleSkillRequest,
|
||||
ToggleWindsurfRuleRequest,
|
||||
ToggleWorkflowRequest,
|
||||
} from "@shared/proto/cline/file"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
@@ -32,6 +33,8 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
localCursorRulesToggles = {},
|
||||
localWindsurfRulesToggles = {},
|
||||
localAgentsRulesToggles = {},
|
||||
localWorkflowToggles = {},
|
||||
globalWorkflowToggles = {},
|
||||
hooksEnabled,
|
||||
setGlobalClineRulesToggles,
|
||||
setLocalClineRulesToggles,
|
||||
@@ -58,7 +61,7 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
|
||||
const [arrowPosition, setArrowPosition] = useState(0)
|
||||
const [menuPosition, setMenuPosition] = useState(0)
|
||||
const [currentView, setCurrentView] = useState<"rules" | "hooks" | "skills">("rules")
|
||||
const [currentView, setCurrentView] = useState<"rules" | "workflows" | "hooks" | "skills">("rules")
|
||||
|
||||
// Auto-switch to rules tab if hooks become disabled while viewing hooks tab
|
||||
useEffect(() => {
|
||||
@@ -205,10 +208,20 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const localWorkflows = Object.entries(localWorkflowToggles || {})
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const globalWorkflows = Object.entries(globalWorkflowToggles || {})
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const remoteConfigSettings = useRemoteConfigSettings(isVisible)
|
||||
const remoteRules = remoteConfigSettings.filter((s) => s.type === "rule")
|
||||
const remoteWorkflows = remoteConfigSettings.filter((s) => s.type === "workflow")
|
||||
const remoteSkills = remoteConfigSettings.filter((s) => s.type === "skill")
|
||||
const hasRemoteRules = remoteRules.length > 0
|
||||
const hasRemoteWorkflows = remoteWorkflows.length > 0
|
||||
const hasRemoteSkills = remoteSkills.length > 0
|
||||
|
||||
// Handle toggle rule using gRPC
|
||||
@@ -289,6 +302,29 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Handle toggle workflow using gRPC
|
||||
const toggleWorkflow = (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
|
||||
FileServiceClient.toggleWorkflow(
|
||||
ToggleWorkflowRequest.create({
|
||||
workflowPath,
|
||||
enabled,
|
||||
scope: isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL,
|
||||
}),
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.toggles) {
|
||||
if (isGlobal) {
|
||||
setGlobalWorkflowToggles(response.toggles)
|
||||
} else {
|
||||
setLocalWorkflowToggles(response.toggles)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error("Failed to toggle workflow:", err)
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle hook handler
|
||||
const toggleHook = (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
|
||||
FileServiceClient.toggleHook({
|
||||
@@ -401,17 +437,24 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
<TabButton isActive={currentView === "skills"} onClick={() => setCurrentView("skills")}>
|
||||
Skills
|
||||
</TabButton>
|
||||
<TabButton isActive={currentView === "workflows"} onClick={() => setCurrentView("workflows")}>
|
||||
Workflows
|
||||
</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote config banner */}
|
||||
{(currentView === "rules" && hasRemoteRules) || (currentView === "skills" && hasRemoteSkills) ? (
|
||||
{(currentView === "rules" && hasRemoteRules) ||
|
||||
(currentView === "workflows" && hasRemoteWorkflows) ||
|
||||
(currentView === "skills" && hasRemoteSkills) ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-textBlockQuote-background border-l-[3px] border-vscode-textLink-foreground">
|
||||
<i className="codicon codicon-lock text-sm" />
|
||||
<span className="text-base">
|
||||
{currentView === "rules"
|
||||
? "Your organization manages some rules"
|
||||
: "Your organization manages some skills"}
|
||||
: currentView === "workflows"
|
||||
? "Your organization manages some workflows"
|
||||
: "Your organization manages some skills"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -429,6 +472,17 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
Docs
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
) : currentView === "workflows" ? (
|
||||
<p>
|
||||
Workflows allow you to define a series of steps to guide Cline through a repetitive set of
|
||||
tasks, such as deploying a service or submitting a PR. To invoke a workflow, type{" "}
|
||||
<span className="text-foreground font-bold">/workflow-name</span> in the chat.{" "}
|
||||
<VSCodeLink
|
||||
className="text-xs inline"
|
||||
href="https://docs.cline.bot/features/slash-commands/workflows">
|
||||
Docs
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
) : currentView === "skills" ? (
|
||||
<p>
|
||||
Skills are reusable instruction sets that Cline can activate on-demand. When a task matches a
|
||||
@@ -530,6 +584,75 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : currentView === "workflows" ? (
|
||||
<>
|
||||
{/* Deprecation warning banner */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-inputValidation-warningBackground border-l-[3px] border-vscode-inputValidation-warningBorder">
|
||||
<i className="codicon codicon-warning text-sm" />
|
||||
<span className="text-base">
|
||||
Workflows are being deprecated. Use skills instead.{" "}
|
||||
<VSCodeLink
|
||||
href="https://docs.cline.bot/customization/skills"
|
||||
style={{ display: "inline", fontSize: "inherit" }}>
|
||||
Learn more
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Workflows Section (remote) */}
|
||||
{hasRemoteWorkflows && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-normal mb-2">Enterprise Workflows</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
{remoteWorkflows
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workflow) => {
|
||||
const enabled = workflow.locked || workflow.enabled
|
||||
return (
|
||||
<RuleRow
|
||||
alwaysEnabled={workflow.locked}
|
||||
enabled={enabled}
|
||||
isGlobal={true}
|
||||
isRemote={true}
|
||||
key={workflow.name}
|
||||
rulePath={workflow.name}
|
||||
ruleType="workflow"
|
||||
toggleRule={workflow.toggle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Workflows Section */}
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-normal mb-2">Global Workflows</div>
|
||||
<RulesToggleList
|
||||
isGlobal={true}
|
||||
listGap="small"
|
||||
rules={globalWorkflows}
|
||||
ruleType={"workflow"}
|
||||
showNewRule={true}
|
||||
showNoRules={false}
|
||||
toggleRule={(rulePath, enabled) => toggleWorkflow(true, rulePath, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Local Workflows Section */}
|
||||
<div className="-mb-2.5">
|
||||
<div className="text-sm font-normal mb-2">Workspace Workflows</div>
|
||||
<RulesToggleList
|
||||
isGlobal={false}
|
||||
listGap="small"
|
||||
rules={localWorkflows}
|
||||
ruleType={"workflow"}
|
||||
showNewRule={true}
|
||||
showNoRules={false}
|
||||
toggleRule={(rulePath, enabled) => toggleWorkflow(false, rulePath, enabled)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : currentView === "hooks" ? (
|
||||
<>
|
||||
<div className="text-xs text-description mb-4">
|
||||
|
||||
@@ -274,7 +274,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
threshold: 0.6,
|
||||
shouldSort: true,
|
||||
isCaseSensitive: false,
|
||||
ignoreLocation: false,
|
||||
// Match anywhere in the task text. With location-based scoring, a
|
||||
// match more than ~60 characters into the title scores above the
|
||||
// threshold and the task silently vanishes from search results
|
||||
// (e.g. searching "aqueducts" in "Write a detailed 800-word essay
|
||||
// about the history of the Roman aqueducts...").
|
||||
ignoreLocation: true,
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 1,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import Fuse from "fuse.js"
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useInterval } from "react-use"
|
||||
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
@@ -12,7 +10,6 @@ import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
|
||||
import { useProviderListings } from "@/hooks/useProviderListings"
|
||||
import { ModelsServiceClient } from "@/services/grpc-client"
|
||||
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import { AIhubmixProvider } from "./providers/AihubmixProvider"
|
||||
import { AnthropicProvider } from "./providers/AnthropicProvider"
|
||||
@@ -120,33 +117,6 @@ const ApiOptions = ({
|
||||
|
||||
const { handleModeFieldChange } = useApiConfigurationHandlers()
|
||||
|
||||
const [_ollamaModels, setOllamaModels] = useState<string[]>([])
|
||||
|
||||
// Poll ollama/vscode-lm models
|
||||
const requestLocalModels = useCallback(async () => {
|
||||
if (selectedProvider === "ollama") {
|
||||
try {
|
||||
const response = await ModelsServiceClient.getOllamaModels(
|
||||
StringRequest.create({
|
||||
value: apiConfiguration?.ollamaBaseUrl || "",
|
||||
}),
|
||||
)
|
||||
if (response && response.values) {
|
||||
setOllamaModels(response.values)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Ollama models:", error)
|
||||
setOllamaModels([])
|
||||
}
|
||||
}
|
||||
}, [selectedProvider, apiConfiguration?.ollamaBaseUrl])
|
||||
useEffect(() => {
|
||||
if (selectedProvider === "ollama") {
|
||||
requestLocalModels()
|
||||
}
|
||||
}, [selectedProvider, requestLocalModels])
|
||||
useInterval(requestLocalModels, selectedProvider === "ollama" ? 2000 : null)
|
||||
|
||||
// Provider search state
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
|
||||
@@ -10,6 +10,8 @@ interface OllamaModelPickerProps {
|
||||
ollamaModels: string[]
|
||||
selectedModelId: string
|
||||
onModelChange: (modelId: string) => void
|
||||
/** Called when the search field gains focus, e.g. to refresh the model list on demand. */
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
@@ -17,6 +19,7 @@ const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
|
||||
ollamaModels,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
onFocus,
|
||||
placeholder = "Search and select a model...",
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState(selectedModelId || "")
|
||||
@@ -131,7 +134,10 @@ const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
|
||||
<DropdownWrapper ref={dropdownRef}>
|
||||
<VSCodeTextField
|
||||
id="ollama-model-search"
|
||||
onFocus={() => setIsDropdownVisible(true)}
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
onFocus?.()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement)?.value || ""
|
||||
handleModelChange(value)
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { languageOptions } from "@shared/Languages"
|
||||
import React from "react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { updateSetting } from "./utils/settingsHandlers"
|
||||
|
||||
const PreferredLanguageSetting: React.FC = () => {
|
||||
const { preferredLanguage } = useExtensionState()
|
||||
|
||||
const handleLanguageChange = (newLanguage: string) => {
|
||||
updateSetting("preferredLanguage", newLanguage)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{}}>
|
||||
<div>
|
||||
<label className="block mb-1 text-base font-medium" htmlFor="preferred-language-dropdown">
|
||||
Preferred Language
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
currentValue={preferredLanguage || "English"}
|
||||
id="preferred-language-dropdown"
|
||||
onChange={(e: any) => {
|
||||
handleLanguageChange(e.target.value)
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
<VSCodeOption value="English">English</VSCodeOption>
|
||||
<VSCodeOption value="Arabic - العربية">Arabic - العربية</VSCodeOption>
|
||||
<VSCodeOption value="Portuguese - Português (Brasil)">Portuguese - Português (Brasil)</VSCodeOption>
|
||||
<VSCodeOption value="Czech - Čeština">Czech - Čeština</VSCodeOption>
|
||||
<VSCodeOption value="French - Français">French - Français</VSCodeOption>
|
||||
<VSCodeOption value="German - Deutsch">German - Deutsch</VSCodeOption>
|
||||
<VSCodeOption value="Hindi - हिन्दी">Hindi - हिन्दी</VSCodeOption>
|
||||
<VSCodeOption value="Hungarian - Magyar">Hungarian - Magyar</VSCodeOption>
|
||||
<VSCodeOption value="Italian - Italiano">Italian - Italiano</VSCodeOption>
|
||||
<VSCodeOption value="Japanese - 日本語">Japanese - 日本語</VSCodeOption>
|
||||
<VSCodeOption value="Korean - 한국어">Korean - 한국어</VSCodeOption>
|
||||
<VSCodeOption value="Polish - Polski">Polish - Polski</VSCodeOption>
|
||||
<VSCodeOption value="Portuguese - Português (Portugal)">Portuguese - Português (Portugal)</VSCodeOption>
|
||||
<VSCodeOption value="Russian - Русский">Russian - Русский</VSCodeOption>
|
||||
<VSCodeOption value="Simplified Chinese - 简体中文">Simplified Chinese - 简体中文</VSCodeOption>
|
||||
<VSCodeOption value="Spanish - Español">Spanish - Español</VSCodeOption>
|
||||
<VSCodeOption value="Traditional Chinese - 繁體中文">Traditional Chinese - 繁體中文</VSCodeOption>
|
||||
<VSCodeOption value="Turkish - Türkçe">Turkish - Türkçe</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<Select
|
||||
onValueChange={(newLanguage) => updateSetting("preferredLanguage", newLanguage)}
|
||||
value={preferredLanguage || "English"}>
|
||||
<SelectTrigger className="w-full" id="preferred-language-dropdown">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map(({ key, display }) => (
|
||||
<SelectItem key={key} value={display}>
|
||||
{display}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-description mt-1">The language that Cline should use for communication.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useInterval } from "react-use"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
import { useProviderModelSelection } from "@/hooks/useProviderModelSelection"
|
||||
@@ -103,7 +102,10 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
[commitModelSelection, lmStudioModels, toLmStudioModelInfo],
|
||||
)
|
||||
|
||||
// Poll LM Studio models
|
||||
// Fetch LM Studio models on mount, whenever the endpoint changes, and when
|
||||
// the model control gains focus (no interval polling — the endpoint is
|
||||
// user-configurable, see ENG-2344), so a server started after mount is
|
||||
// still discovered.
|
||||
const requestLmStudioModels = useCallback(async () => {
|
||||
await ModelsServiceClient.getLmStudioModels({
|
||||
value: endpoint,
|
||||
@@ -146,8 +148,6 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
handleFieldChange,
|
||||
])
|
||||
|
||||
useInterval(requestLmStudioModels, 6000)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseUrlField
|
||||
@@ -159,7 +159,7 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
|
||||
<div className="font-semibold">Model</div>
|
||||
{lmStudioModels.length > 0 ? (
|
||||
<DropdownContainer className="dropdown-container" zIndex={10}>
|
||||
<DropdownContainer className="dropdown-container" onFocusCapture={() => void requestLmStudioModels()} zIndex={10}>
|
||||
<VSCodeDropdown
|
||||
className="w-full mb-3"
|
||||
onChange={(e: any) => {
|
||||
@@ -177,12 +177,14 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => {
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
) : (
|
||||
<DebouncedTextField
|
||||
initialValue={displayedSelectedModelId || ""}
|
||||
onChange={handleModelChange}
|
||||
placeholder={"e.g. meta-llama-3.1-8b-instruct"}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<div onFocusCapture={() => void requestLmStudioModels()}>
|
||||
<DebouncedTextField
|
||||
initialValue={displayedSelectedModelId || ""}
|
||||
onChange={handleModelChange}
|
||||
placeholder={"e.g. meta-llama-3.1-8b-instruct"}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-semibold">Context Window</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useInterval } from "react-use"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
import { useProviderModelSelection } from "@/hooks/useProviderModelSelection"
|
||||
@@ -68,7 +67,10 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
|
||||
[write],
|
||||
)
|
||||
|
||||
// Poll ollama models
|
||||
// Fetch ollama models on mount and whenever the base URL changes. The
|
||||
// picker also refetches on focus — do NOT poll on an interval: the base
|
||||
// URL is user-configurable, so an unbounded poll can hammer a remote or
|
||||
// metered endpoint for as long as the settings pane is open (ENG-2344).
|
||||
const requestOllamaModels = useCallback(async () => {
|
||||
try {
|
||||
const response = await ModelsServiceClient.getOllamaModels(
|
||||
@@ -89,8 +91,6 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
|
||||
requestOllamaModels()
|
||||
}, [requestOllamaModels])
|
||||
|
||||
useInterval(requestOllamaModels, 2000)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseUrlField
|
||||
@@ -116,6 +116,7 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam
|
||||
</label>
|
||||
<OllamaModelPicker
|
||||
ollamaModels={ollamaModels}
|
||||
onFocus={requestOllamaModels}
|
||||
onModelChange={(modelId) => {
|
||||
const trimmedModelId = modelId.trim()
|
||||
if (!trimmedModelId) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { parseVsCodeLmModelSelector, stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
|
||||
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeDropdown, VSCodeLink, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useInterval } from "react-use"
|
||||
import type * as vscodemodels from "vscode"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { useProviderConfig } from "@/hooks/useProviderConfig"
|
||||
@@ -28,7 +27,9 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
? stringifyVsCodeLmModelSelector(vsCodeLmModelSelector)
|
||||
: (committedSelection?.modelId ?? "")
|
||||
|
||||
// Poll VS Code LM models
|
||||
// Fetch VS Code LM models on mount, when the dropdown is focused, and via
|
||||
// the explicit refresh link (no interval polling — ENG-2344), so models
|
||||
// registered after mount (e.g. Copilot enabled later) are still discovered.
|
||||
const requestVsCodeLmModels = useCallback(async () => {
|
||||
try {
|
||||
const response = await ModelsServiceClient.getVsCodeLmModels(EmptyRequest.create({}))
|
||||
@@ -45,8 +46,6 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
requestVsCodeLmModels()
|
||||
}, [requestVsCodeLmModels])
|
||||
|
||||
useInterval(requestVsCodeLmModels, 2000)
|
||||
|
||||
const handleModelSelect = (modelId: string) => {
|
||||
if (!modelId) {
|
||||
return
|
||||
@@ -73,7 +72,10 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownContainer className="dropdown-container" zIndex={DROPDOWN_Z_INDEX - 2}>
|
||||
<DropdownContainer
|
||||
className="dropdown-container"
|
||||
onFocusCapture={() => void requestVsCodeLmModels()}
|
||||
zIndex={DROPDOWN_Z_INDEX - 2}>
|
||||
<label htmlFor="vscode-lm-model">
|
||||
<span style={{ fontWeight: 500 }}>Language Model</span>
|
||||
</label>
|
||||
@@ -104,7 +106,13 @@ export const VSCodeLmProvider = ({ currentMode }: VSCodeLmProviderProps) => {
|
||||
is GitHub Copilot — install the{" "}
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot">Copilot extension</a> and
|
||||
enable models in Copilot settings — but any extension that registers a language model provider will appear
|
||||
here.
|
||||
here.{" "}
|
||||
<VSCodeLink
|
||||
onClick={() => void requestVsCodeLmModels()}
|
||||
style={{ display: "inline", fontSize: "inherit" }}>
|
||||
Refresh the model list
|
||||
</VSCodeLink>{" "}
|
||||
after enabling models.
|
||||
</p>
|
||||
)}
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.46",
|
||||
"version": "3.0.47",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
|
||||
@@ -521,6 +521,22 @@ export class ClineCore {
|
||||
readMessages: RuntimeHost["readSessionMessages"] = (...args) =>
|
||||
this.host.readSessionMessages(...args);
|
||||
|
||||
/**
|
||||
* Reads message history for a session, preferring the live in-memory
|
||||
* conversation when the session is still resident in this host.
|
||||
*
|
||||
* The persisted transcript only catches up at assistant-message/turn
|
||||
* boundaries, so `readMessages` can miss an in-flight (or just-aborted)
|
||||
* turn. Use this when the current conversation matters — e.g. seeding a
|
||||
* replacement session during a plan/act mode switch. Falls back to the
|
||||
* persisted transcript when the session is not resident or the host does
|
||||
* not track live sessions.
|
||||
*/
|
||||
readLiveMessages: RuntimeHost["readSessionMessages"] = (sessionId) =>
|
||||
this.host.readLiveSessionMessages
|
||||
? this.host.readLiveSessionMessages(sessionId)
|
||||
: this.host.readSessionMessages(sessionId);
|
||||
|
||||
async restore(input: RestoreInput): Promise<RestoreResult> {
|
||||
const normalizedStart = input.start
|
||||
? normalizeClineCoreStartInput(input.start, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
buildRunCommandsDescription,
|
||||
createDefaultTools,
|
||||
createEditorTool,
|
||||
createReadFilesTool,
|
||||
createSearchTool,
|
||||
createShellTool,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from "./definitions";
|
||||
import { CommandExitError } from "./executors/bash";
|
||||
import { RUN_COMMAND_QUERY_PREVIEW_LIMIT, TimeoutError } from "./helpers";
|
||||
import { INPUT_ARG_CHAR_LIMIT } from "./schemas";
|
||||
import { type EditFileInput, INPUT_ARG_CHAR_LIMIT } from "./schemas";
|
||||
import type { SkillsExecutorWithMetadata } from "./types";
|
||||
|
||||
function hasSchemaKey(value: unknown, key: string): boolean {
|
||||
@@ -1944,6 +1945,35 @@ describe("default editor tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a stringified insert_line but not a non-numeric one", async () => {
|
||||
const execute = vi.fn(async () => "patched");
|
||||
const tool = createEditorTool(execute);
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
};
|
||||
// Deliberately wrong-typed: this is what an LLM can put on the wire.
|
||||
const inputWith = (insert_line: unknown) =>
|
||||
({
|
||||
path: "/tmp/example.ts",
|
||||
new_text: "after",
|
||||
insert_line,
|
||||
}) as EditFileInput;
|
||||
|
||||
await tool.execute(inputWith("3"), context);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ insert_line: 3 }),
|
||||
process.cwd(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// A word such as "end" has no line number to infer, so it must keep failing.
|
||||
await expect(tool.execute(inputWith("end"), context)).rejects.toThrow(
|
||||
/insert_line/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a recoverable tool error when text exceeds the soft character limit", async () => {
|
||||
const execute = vi.fn(async () => "patched");
|
||||
const tools = createDefaultTools({
|
||||
|
||||
@@ -18,7 +18,9 @@ const AbsolutePath = z
|
||||
|
||||
export const ReadFileLineRangeSchema = z
|
||||
.object({
|
||||
start_line: z
|
||||
// Models sometimes emit line numbers as strings; coerce so a `"3"` does not
|
||||
// reject the whole tool call. The advertised JSON Schema is unaffected.
|
||||
start_line: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
@@ -27,7 +29,7 @@ export const ReadFileLineRangeSchema = z
|
||||
.describe(
|
||||
"Optional one-based starting line number to read from; use null or omit for the start of the file",
|
||||
),
|
||||
end_line: z
|
||||
end_line: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
@@ -207,7 +209,8 @@ export const EditFileInputSchema = z
|
||||
.describe(
|
||||
`The new content to write when creating a missing file, the replacement text for edits, or the inserted text when insert_line is provided. Keep this at or below ${INPUT_ARG_CHAR_LIMIT} characters when possible; for large edits, use multiple calls with small chunks of old_text and new_text to iteratively edit the file.`,
|
||||
),
|
||||
insert_line: z
|
||||
// See start_line above: coerced so a stringified line number still applies.
|
||||
insert_line: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
|
||||
@@ -1180,6 +1180,71 @@ describe("LocalRuntimeHost", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("readLiveSessionMessages serves in-memory messages for resident sessions before persistence", async () => {
|
||||
const sessionId = "sess-live-messages";
|
||||
const manifest = createManifest(sessionId);
|
||||
// The in-flight conversation exists only on the agent; nothing has been
|
||||
// flushed to the messages file yet (mid-turn, or an aborted turn).
|
||||
const liveMessages: MessageWithMetadata[] = [
|
||||
{ role: "user" as const, content: "list the files in this folder" },
|
||||
{ role: "assistant" as const, content: "I will list them now." },
|
||||
];
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: join(isolatedHomeDir, "never-written.json"),
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({
|
||||
updated: true,
|
||||
endedAt: "2026-01-01T00:00:05.000Z",
|
||||
}),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const runtimeBuilder = {
|
||||
build: vi.fn().mockReturnValue({
|
||||
tools: [],
|
||||
teamRuntime: undefined,
|
||||
teamRestoredFromPersistence: false,
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
};
|
||||
const agent = {
|
||||
run: vi.fn().mockResolvedValue(createResult()),
|
||||
continue: vi.fn().mockResolvedValue(createResult()),
|
||||
getMessages: vi.fn().mockReturnValue(liveMessages),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
canStartRun: vi.fn().mockReturnValue(true),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: runtimeBuilder as never,
|
||||
createAgent: () => agent as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({ sessionId }),
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// The live read sees the conversation; the persisted read still lags.
|
||||
await expect(manager.readLiveSessionMessages(sessionId)).resolves.toEqual(
|
||||
liveMessages,
|
||||
);
|
||||
await expect(manager.readSessionMessages(sessionId)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reads manifest-only session records and messages", async () => {
|
||||
const sessionId = "manifest-only-session";
|
||||
const messagesPath = join(isolatedHomeDir, "messages.json");
|
||||
|
||||
@@ -1313,6 +1313,26 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
}
|
||||
}
|
||||
|
||||
async readLiveSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<LlmsProviders.Message[]> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) return [];
|
||||
// Resident sessions are authoritative: disk persistence lags at
|
||||
// assistant-message/turn boundaries and abort() does not flush, so a
|
||||
// mid-turn read of the persisted file would silently drop the
|
||||
// in-flight exchange (e.g. hosts that abort a turn and immediately
|
||||
// re-read messages to rebuild the session for a plan/act mode switch).
|
||||
const live = this.sessions.get(target);
|
||||
if (live) {
|
||||
const messages = live.agent.getMessages();
|
||||
if (messages.length > 0) {
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
return this.readSessionMessages(target);
|
||||
}
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<LlmsProviders.Message[]> {
|
||||
|
||||
@@ -339,6 +339,16 @@ export interface RuntimeHost {
|
||||
sessionId: string,
|
||||
): Promise<SessionCompactionState | undefined>;
|
||||
readSessionMessages(sessionId: string): Promise<LlmsProviders.Message[]>;
|
||||
/**
|
||||
* Like {@link readSessionMessages}, but prefers the resident session's
|
||||
* in-memory conversation over the persisted transcript. Disk persistence
|
||||
* happens at assistant-message/turn boundaries (and abort() does not
|
||||
* flush), so this is the accurate read for callers that need the
|
||||
* conversation of an in-flight or just-aborted turn — e.g. rebuilding a
|
||||
* session for a mode switch. Optional: hosts without live-session access
|
||||
* (e.g. hub clients) fall back to the persisted transcript.
|
||||
*/
|
||||
readLiveSessionMessages?(sessionId: string): Promise<LlmsProviders.Message[]>;
|
||||
dispatchHookEvent(payload: HookEventPayload): Promise<void>;
|
||||
subscribe(
|
||||
listener: (event: CoreSessionEvent) => void,
|
||||
|
||||
@@ -36,4 +36,46 @@ describe("provider settings", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the regional endpoint from apiLine when no base URL is set", () => {
|
||||
expect(
|
||||
toProviderConfig({ provider: "zai", apiLine: "china" }),
|
||||
).toMatchObject({
|
||||
apiLine: "china",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
});
|
||||
|
||||
expect(
|
||||
toProviderConfig({ provider: "moonshot", apiLine: "china" }),
|
||||
).toMatchObject({
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
});
|
||||
|
||||
expect(
|
||||
toProviderConfig({ provider: "qwen", apiLine: "international" }),
|
||||
).toMatchObject({
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an explicit base URL win over apiLine", () => {
|
||||
expect(
|
||||
toProviderConfig({
|
||||
provider: "zai",
|
||||
apiLine: "china",
|
||||
baseUrl: "https://proxy.example.com/v4",
|
||||
}),
|
||||
).toMatchObject({
|
||||
baseUrl: "https://proxy.example.com/v4",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the provider default base URL when no apiLine is set", () => {
|
||||
expect(toProviderConfig({ provider: "zai" })).toMatchObject({
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
});
|
||||
expect(toProviderConfig({ provider: "moonshot" })).toMatchObject({
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,8 +222,11 @@ export function toProviderConfig(
|
||||
const generatedDefaultModelId = Object.keys(generatedKnownModels)[0];
|
||||
|
||||
const apiKey = getPersistedProviderApiKey(normalizedProviderId, settings);
|
||||
// Precedence: explicit base URL > regional API line endpoint (e.g.
|
||||
// Qwen/Moonshot/Z.AI "china" vs "international") > provider default.
|
||||
const resolvedBaseUrl =
|
||||
settings.baseUrl ??
|
||||
Llms.resolveProviderApiLineBaseUrl(normalizedProviderId, settings.apiLine) ??
|
||||
(normalizedProviderId === "oca"
|
||||
? settings.oca?.mode === "internal"
|
||||
? DEFAULT_INTERNAL_OCA_BASE_URL
|
||||
|
||||
@@ -84,11 +84,14 @@ export {
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
isProviderApiLine,
|
||||
isRegisteredHandlerAsync,
|
||||
normalizeProviderId,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
type ProviderApiLine,
|
||||
registerAsyncHandler,
|
||||
registerHandler,
|
||||
resolveProviderApiLineBaseUrl,
|
||||
} from "./providers";
|
||||
export {
|
||||
type ProviderUsageCostDisplay,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "./providers/builtins";
|
||||
export {
|
||||
isProviderApiLine,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
type ProviderApiLine,
|
||||
resolveProviderApiLineBaseUrl,
|
||||
} from "./providers/builtins";
|
||||
export {
|
||||
type ApiHandler,
|
||||
BUILT_IN_PROVIDER,
|
||||
|
||||
@@ -25,6 +25,8 @@ export type ProviderFamily =
|
||||
| "ollama"
|
||||
| "sap-ai-core";
|
||||
|
||||
export type ProviderApiLine = "china" | "international";
|
||||
|
||||
export interface BuiltinSpec {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +44,13 @@ export interface BuiltinSpec {
|
||||
modelsSourceUrl?: string;
|
||||
docsUrl?: string;
|
||||
defaults?: GatewayProviderSettings;
|
||||
/**
|
||||
* Regional endpoint routing facts: base URL per API line. Used when the
|
||||
* caller selects an `apiLine` without an explicit base URL. The line that
|
||||
* matches `defaults.baseUrl` is included so the mapping is exhaustive and
|
||||
* self-documenting.
|
||||
*/
|
||||
apiLineBaseUrls?: Readonly<Partial<Record<ProviderApiLine, string>>>;
|
||||
configFields?: readonly ProviderConfigField[];
|
||||
metadata?: GatewayProviderMetadata;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,17 @@ export const BUILTIN_PROVIDER_REGISTRATIONS: GatewayProviderRegistration[] =
|
||||
...spec.defaults,
|
||||
apiKeyEnv: spec.apiKeyEnv,
|
||||
baseUrl: spec.defaults?.baseUrl,
|
||||
// Surface the regional endpoint facts as a default option so the
|
||||
// registry can resolve a base URL from a caller-selected
|
||||
// `options.apiLine` (see GatewayRegistry.createProvider).
|
||||
...(spec.apiLineBaseUrls
|
||||
? {
|
||||
options: {
|
||||
...(spec.defaults?.options ?? {}),
|
||||
apiLineBaseUrls: spec.apiLineBaseUrls,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
loadProvider: async () => ({
|
||||
createProvider: await loadFamilyFactory(resolveRuntimeFamily(spec)),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CLINE_ENVIRONMENTS,
|
||||
} from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BUILTIN_SPECS } from "./builtins";
|
||||
import { BUILTIN_SPECS, resolveProviderApiLineBaseUrl } from "./builtins";
|
||||
import { getModelsForProvider, getProvider } from "./model-registry";
|
||||
import { GENERATED_PROVIDER_SPECS } from "./providers.generated";
|
||||
|
||||
@@ -143,6 +143,9 @@ describe("built-in provider metadata", () => {
|
||||
});
|
||||
|
||||
it("uses generated specs directly when no runtime override is required", () => {
|
||||
// moonshot is intentionally absent: it carries a Cline-specific
|
||||
// regional routing override (apiLineBaseUrls) on top of its generated
|
||||
// spec.
|
||||
const generatedOnlyProviderIds = [
|
||||
"fireworks",
|
||||
"poolside",
|
||||
@@ -150,7 +153,6 @@ describe("built-in provider metadata", () => {
|
||||
"baseten",
|
||||
"requesty",
|
||||
"huggingface",
|
||||
"moonshot",
|
||||
"wandb",
|
||||
"xiaomi",
|
||||
"tencent-tokenhub",
|
||||
@@ -252,3 +254,71 @@ describe("built-in provider metadata", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("regional API line base URLs", () => {
|
||||
it("exposes china/international endpoints on regional provider specs", () => {
|
||||
const expectations: Record<
|
||||
string,
|
||||
{ china: string; international: string }
|
||||
> = {
|
||||
qwen: {
|
||||
china: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
international: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
"qwen-code": {
|
||||
china: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
international: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
moonshot: {
|
||||
china: "https://api.moonshot.cn/v1",
|
||||
international: "https://api.moonshot.ai/v1",
|
||||
},
|
||||
zai: {
|
||||
china: "https://open.bigmodel.cn/api/paas/v4",
|
||||
international: "https://api.z.ai/api/paas/v4",
|
||||
},
|
||||
"zai-coding-plan": {
|
||||
china: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
international: "https://api.z.ai/api/coding/paas/v4",
|
||||
},
|
||||
minimax: {
|
||||
china: "https://api.minimaxi.com/anthropic/v1",
|
||||
international: "https://api.minimax.io/anthropic/v1",
|
||||
},
|
||||
};
|
||||
|
||||
for (const [providerId, expected] of Object.entries(expectations)) {
|
||||
const spec = BUILTIN_SPECS.find((s) => s.id === providerId);
|
||||
expect(spec?.apiLineBaseUrls, providerId).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the regional base URL for a selected api line", () => {
|
||||
expect(resolveProviderApiLineBaseUrl("zai", "china")).toBe(
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
);
|
||||
expect(resolveProviderApiLineBaseUrl("moonshot", "china")).toBe(
|
||||
"https://api.moonshot.cn/v1",
|
||||
);
|
||||
expect(resolveProviderApiLineBaseUrl("qwen", "international")).toBe(
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown lines and non-regional providers", () => {
|
||||
expect(resolveProviderApiLineBaseUrl("zai", undefined)).toBeUndefined();
|
||||
expect(resolveProviderApiLineBaseUrl("zai", "mars")).toBeUndefined();
|
||||
expect(
|
||||
resolveProviderApiLineBaseUrl("anthropic", "china"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the international line consistent with the spec default base URL for zai and moonshot", () => {
|
||||
for (const providerId of ["zai", "moonshot", "minimax"]) {
|
||||
const spec = BUILTIN_SPECS.find((s) => s.id === providerId);
|
||||
expect(spec?.apiLineBaseUrls?.international, providerId).toBe(
|
||||
spec?.defaults?.baseUrl,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
ProviderClient,
|
||||
ProviderProtocol,
|
||||
} from "../catalog/types";
|
||||
import type { BuiltinSpec } from "./builtin-types";
|
||||
import type { BuiltinSpec, ProviderApiLine } from "./builtin-types";
|
||||
import {
|
||||
ClineFreeModelLimitError,
|
||||
ClineNotSubscribedError,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "./errors";
|
||||
import { normalizeProviderId } from "./ids";
|
||||
import { filterOpenAICodexModels } from "./openai-codex-models";
|
||||
import { GENERATED_PROVIDER_SPECS } from "./providers.generated";
|
||||
import {
|
||||
@@ -67,7 +68,11 @@ const OPENROUTER_STICKY_SESSION_METADATA: GatewayProviderMetadata = {
|
||||
*/
|
||||
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 32768;
|
||||
|
||||
export type { BuiltinSpec, ProviderFamily } from "./builtin-types";
|
||||
export type {
|
||||
BuiltinSpec,
|
||||
ProviderApiLine,
|
||||
ProviderFamily,
|
||||
} from "./builtin-types";
|
||||
|
||||
type BuiltinSpecOverride = Pick<BuiltinSpec, "id"> &
|
||||
Partial<Omit<BuiltinSpec, "id">>;
|
||||
@@ -210,6 +215,11 @@ const OCA_CONFIG_FIELDS: readonly ProviderConfigField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const QWEN_API_LINE_BASE_URLS: Readonly<Record<ProviderApiLine, string>> = {
|
||||
china: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
international: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
};
|
||||
|
||||
const QWEN_CONFIG_FIELDS: readonly ProviderConfigField[] = [
|
||||
API_KEY_FIELD,
|
||||
BASE_URL_FIELD,
|
||||
@@ -790,6 +800,7 @@ const OPENAI_COMPATIBLE_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
apiKeyEnv: ["QWEN_API_KEY"],
|
||||
modelsProviderId: "qwen",
|
||||
defaults: { baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
apiLineBaseUrls: QWEN_API_LINE_BASE_URLS,
|
||||
configFields: QWEN_CONFIG_FIELDS,
|
||||
metadata: QWEN_CACHE_ROUTING_METADATA,
|
||||
},
|
||||
@@ -802,9 +813,19 @@ const OPENAI_COMPATIBLE_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
defaultModelId: "qwen3-coder-plus",
|
||||
modelsProviderId: "qwen-code",
|
||||
defaults: { baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
apiLineBaseUrls: QWEN_API_LINE_BASE_URLS,
|
||||
configFields: QWEN_CONFIG_FIELDS,
|
||||
metadata: QWEN_CACHE_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
// Fully described by models.dev except for the regional endpoint
|
||||
// routing policy (`apiLineBaseUrls`), which is Cline-specific.
|
||||
id: "moonshot",
|
||||
apiLineBaseUrls: {
|
||||
china: "https://api.moonshot.cn/v1",
|
||||
international: "https://api.moonshot.ai/v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doubao",
|
||||
name: "Doubao",
|
||||
@@ -826,6 +847,10 @@ const OPENAI_COMPATIBLE_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
apiKeyEnv: ["ZHIPU_API_KEY"],
|
||||
modelsProviderId: "zai",
|
||||
defaults: { baseUrl: "https://api.z.ai/api/paas/v4" },
|
||||
apiLineBaseUrls: {
|
||||
china: "https://open.bigmodel.cn/api/paas/v4",
|
||||
international: "https://api.z.ai/api/paas/v4",
|
||||
},
|
||||
metadata: GLM_THINKING_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
@@ -838,6 +863,10 @@ const OPENAI_COMPATIBLE_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
apiKeyEnv: ["ZHIPU_API_KEY"],
|
||||
modelsProviderId: "zai-coding-plan",
|
||||
defaults: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
|
||||
apiLineBaseUrls: {
|
||||
china: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
international: "https://api.z.ai/api/coding/paas/v4",
|
||||
},
|
||||
metadata: GLM_THINKING_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
@@ -1055,6 +1084,10 @@ const BUILTIN_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
|
||||
apiKeyEnv: ["MINIMAX_API_KEY"],
|
||||
modelsProviderId: "minimax",
|
||||
defaults: { baseUrl: "https://api.minimax.io/anthropic/v1" },
|
||||
apiLineBaseUrls: {
|
||||
china: "https://api.minimaxi.com/anthropic/v1",
|
||||
international: "https://api.minimax.io/anthropic/v1",
|
||||
},
|
||||
metadata: MINIMAX_THINKING_ROUTING_METADATA,
|
||||
},
|
||||
{
|
||||
@@ -1097,6 +1130,37 @@ export const BUILTIN_SPECS: BuiltinSpec[] = mergeBuiltinSpecs(
|
||||
BUILTIN_SPEC_OVERRIDES,
|
||||
);
|
||||
|
||||
const API_LINE_BASE_URLS_BY_PROVIDER_ID: ReadonlyMap<
|
||||
string,
|
||||
Readonly<Partial<Record<ProviderApiLine, string>>>
|
||||
> = new Map(
|
||||
BUILTIN_SPECS.flatMap((spec) =>
|
||||
spec.apiLineBaseUrls ? [[spec.id, spec.apiLineBaseUrls] as const] : [],
|
||||
),
|
||||
);
|
||||
|
||||
export function isProviderApiLine(value: unknown): value is ProviderApiLine {
|
||||
return value === "china" || value === "international";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the regional base URL for a provider's selected API line (e.g.
|
||||
* Qwen/Moonshot/Z.AI/MiniMax "china" vs "international" endpoints). Returns
|
||||
* undefined when the provider has no regional endpoints or the api line is
|
||||
* not a recognized value. Callers must let an explicit user-configured base
|
||||
* URL win over this resolution.
|
||||
*/
|
||||
export function resolveProviderApiLineBaseUrl(
|
||||
providerId: string,
|
||||
apiLine: unknown,
|
||||
): string | undefined {
|
||||
if (!isProviderApiLine(apiLine)) {
|
||||
return undefined;
|
||||
}
|
||||
return API_LINE_BASE_URLS_BY_PROVIDER_ID.get(normalizeProviderId(providerId))
|
||||
?.[apiLine];
|
||||
}
|
||||
|
||||
function getModels(spec: BuiltinSpec): Record<string, ModelInfo> {
|
||||
if (spec.modelsFactory) {
|
||||
return spec.modelsFactory();
|
||||
|
||||
@@ -3796,6 +3796,116 @@ describe("sdk-gateway", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["zai", "china", "glm-5.2", "https://open.bigmodel.cn/api/paas/v4"],
|
||||
["zai", "international", "glm-5.2", "https://api.z.ai/api/paas/v4"],
|
||||
["moonshot", "china", "kimi-k3", "https://api.moonshot.cn/v1"],
|
||||
["moonshot", "international", "kimi-k3", "https://api.moonshot.ai/v1"],
|
||||
[
|
||||
"qwen",
|
||||
"china",
|
||||
"qwen-plus-latest",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
],
|
||||
[
|
||||
"qwen",
|
||||
"international",
|
||||
"qwen-plus-latest",
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
],
|
||||
])(
|
||||
"routes %s to the %s regional endpoint when options.apiLine is set",
|
||||
async (providerId, apiLine, modelId, expectedBaseUrl) => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "text-delta", textDelta: "Regional" },
|
||||
{ type: "finish", usage: { inputTokens: 2, outputTokens: 1 } },
|
||||
]),
|
||||
});
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{ providerId, apiKey: "test-key", options: { apiLine } },
|
||||
],
|
||||
});
|
||||
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId,
|
||||
modelId,
|
||||
messages: baseMessages,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(openaiCompatibleFactorySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: expectedBaseUrl }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets an explicit base URL win over options.apiLine", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "text-delta", textDelta: "Explicit" },
|
||||
{ type: "finish", usage: { inputTokens: 2, outputTokens: 1 } },
|
||||
]),
|
||||
});
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "zai",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://proxy.example.com/v4",
|
||||
options: { apiLine: "china" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId: "zai",
|
||||
modelId: "glm-5.2",
|
||||
messages: baseMessages,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(openaiCompatibleFactorySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://proxy.example.com/v4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unrecognized apiLine values and keeps the provider default", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
{ type: "text-delta", textDelta: "Default" },
|
||||
{ type: "finish", usage: { inputTokens: 2, outputTokens: 1 } },
|
||||
]),
|
||||
});
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "zai",
|
||||
apiKey: "test-key",
|
||||
options: { apiLine: "mars" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await collect(
|
||||
await gateway.stream({
|
||||
providerId: "zai",
|
||||
modelId: "glm-5.2",
|
||||
messages: baseMessages,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(openaiCompatibleFactorySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://api.z.ai/api/paas/v4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows unregistered model ids on known providers", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: makeStreamParts([
|
||||
|
||||
@@ -83,6 +83,29 @@ function mergeProviderMetadata(
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a regional base URL from merged provider options: `apiLine` is the
|
||||
* caller-selected line ("china" | "international") and `apiLineBaseUrls` maps
|
||||
* lines to endpoints (registered as a builtin default from the provider
|
||||
* manifest). Explicit caller base URLs always win over this resolution.
|
||||
*/
|
||||
function resolveApiLineBaseUrl(
|
||||
options: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const apiLine = options.apiLine;
|
||||
if (apiLine !== "china" && apiLine !== "international") {
|
||||
return undefined;
|
||||
}
|
||||
const baseUrls = options.apiLineBaseUrls;
|
||||
if (typeof baseUrls !== "object" || baseUrls === null) {
|
||||
return undefined;
|
||||
}
|
||||
const baseUrl = (baseUrls as Record<string, unknown>)[apiLine];
|
||||
return typeof baseUrl === "string" && baseUrl.trim().length > 0
|
||||
? baseUrl
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function createUnregisteredModel(
|
||||
provider: GatewayProviderManifest,
|
||||
modelId: string,
|
||||
@@ -251,6 +274,10 @@ export class GatewayRegistry {
|
||||
record.defaults?.metadata,
|
||||
config?.metadata,
|
||||
);
|
||||
const options = {
|
||||
...(record.defaults?.options ?? {}),
|
||||
...(config?.options ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
@@ -260,17 +287,17 @@ export class GatewayRegistry {
|
||||
apiKeyResolver:
|
||||
config?.apiKeyResolver ?? record.defaults?.apiKeyResolver,
|
||||
apiKeyEnv: config?.apiKeyEnv ?? record.defaults?.apiKeyEnv,
|
||||
baseUrl: config?.baseUrl ?? record.defaults?.baseUrl,
|
||||
baseUrl:
|
||||
config?.baseUrl ??
|
||||
resolveApiLineBaseUrl(options) ??
|
||||
record.defaults?.baseUrl,
|
||||
headers: {
|
||||
...(record.defaults?.headers ?? {}),
|
||||
...(config?.headers ?? {}),
|
||||
},
|
||||
timeoutMs: config?.timeoutMs ?? record.defaults?.timeoutMs,
|
||||
fetch: config?.fetch ?? record.defaults?.fetch ?? this.fallbackFetch,
|
||||
options: {
|
||||
...(record.defaults?.options ?? {}),
|
||||
...(config?.options ?? {}),
|
||||
},
|
||||
options,
|
||||
metadata,
|
||||
},
|
||||
createProvider: record.createProvider,
|
||||
|
||||
Reference in New Issue
Block a user