diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx index 9d59058978..ee9aafa731 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx @@ -823,6 +823,107 @@ describe("ChatMessages tool disclosures", () => { expect(writeText).toHaveBeenCalledWith("Original prompt"); }); + it("hides runtime steering notes from the transcript", async () => { + await renderMessages([ + { + id: "user-prompt", + sessionId: "session-1", + role: "user", + content: "tell me the current time", + createdAt: 1, + }, + { + id: "steer-1", + sessionId: "session-1", + role: "user", + content: + "[SYSTEM] This run is not complete until you call one of these terminal completion tools: submit_and_exit.", + createdAt: 2, + meta: { userRunSpan: 0 }, + }, + ]); + + // Steering nudges are machinery talking to the model — not rendered + // at all, and never as a user bubble. + expect(container.textContent).toContain("tell me the current time"); + expect(container.textContent).not.toContain("[SYSTEM]"); + expect(container.textContent).not.toContain( + "This run is not complete until you call", + ); + }); + + it("shows a genuine user prompt that happens to start with [SYSTEM]", async () => { + await renderMessages([ + { + id: "user-prompt", + sessionId: "session-1", + role: "user", + content: "[SYSTEM] is a prefix I typed myself, explain it", + createdAt: 1, + }, + ]); + + // Only injected reminders (userRunSpan 0) are steering; a person's + // own prompt stays visible. + expect(container.textContent).toContain( + "is a prefix I typed myself, explain it", + ); + }); + + it("keeps steering notes hidden inside the expanded work block", async () => { + await renderMessages([ + { + id: "user-prompt", + sessionId: "session-1", + role: "user", + content: "tell me the current time", + createdAt: 1, + }, + { + id: "steer-1", + sessionId: "session-1", + role: "user", + content: "[SYSTEM] This run is not complete until you finish.", + createdAt: 2, + meta: { userRunSpan: 0 }, + }, + { + id: "tool-1", + sessionId: "session-1", + role: "tool", + content: JSON.stringify({ + toolName: "run_commands", + input: {}, + result: {}, + }), + createdAt: 3, + }, + { + id: "answer", + sessionId: "session-1", + role: "assistant", + content: "It is 12:28 PM PT.", + createdAt: 4, + }, + ]); + + // The steering note is working-rows machinery grouped with the run, + // and stays hidden even when the work block is expanded. + expect(container.textContent).toContain("It is 12:28 PM PT."); + expect(container.textContent).not.toContain( + "This run is not complete until you finish.", + ); + + const trigger = [ + ...container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Worked")); + expect(trigger).toBeDefined(); + await act(async () => trigger?.click()); + expect(container.textContent).not.toContain( + "This run is not complete until you finish.", + ); + }); + it("counts folded system-displayed runs before an editable user message", async () => { const onEditMessage = vi.fn(async () => undefined); await renderMessages( @@ -1641,17 +1742,18 @@ describe("ChatMessages work collapse", () => { expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2); }); - it.each(["cancelled", "failed", "error"] as const)( - "keeps an interrupted run's rows visible even with partial trailing text (%s)", - async (status) => { - // Stop can land mid-answer, leaving partial assistant text after the - // tool calls; the run still must not fold into a summary. - await renderMessages(completedRun, { status }); + it.each([ + "cancelled", + "failed", + "error", + ] as const)("keeps an interrupted run's rows visible even with partial trailing text (%s)", async (status) => { + // Stop can land mid-answer, leaving partial assistant text after the + // tool calls; the run still must not fold into a summary. + await renderMessages(completedRun, { status }); - expect(container.querySelector(".cline-chat-work")).toBeNull(); - expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2); - }, - ); + expect(container.querySelector(".cline-chat-work")).toBeNull(); + expect(container.querySelectorAll(".cline-chat-tool")).toHaveLength(2); + }); }); describe("ChatMessages thinking indicator", () => { diff --git a/apps/examples/desktop-app/webview/components/views/chat/messages/group-messages.ts b/apps/examples/desktop-app/webview/components/views/chat/messages/group-messages.ts index c74dc555e4..60509eb3b5 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/messages/group-messages.ts +++ b/apps/examples/desktop-app/webview/components/views/chat/messages/group-messages.ts @@ -29,6 +29,24 @@ export type ChatRenderItem = items: ChatRenderItem[]; }; +/** + * Runtime steering notes injected into the conversation as user-role + * messages (completion-tool reminders, team-obligation nudges). They are + * machinery talking to the model, not the person talking, so the transcript + * renders them as subtle system rows and folds them into the run's working + * span instead of showing user bubbles. + */ +export function isSystemSteeringMessage(message: ChatMessage): boolean { + return ( + message.role === "user" && + // Injected reminders carry userRunSpan 0 (they are not user turns); + // requiring it keeps a person's genuine prompt that happens to start + // with "[SYSTEM]" visible and turn-counted. + message.meta?.userRunSpan === 0 && + message.content.trimStart().startsWith("[SYSTEM]") + ); +} + export function hasMessageReasoning(message: ChatMessage): boolean { return Boolean(message.reasoning?.trim() || message.reasoningRedacted); } @@ -66,7 +84,8 @@ export function buildUserRunCountMap( for (const message of messages) { const userRunSpan = - message.meta?.userRunSpan ?? (message.role === "user" ? 1 : 0); + message.meta?.userRunSpan ?? + (message.role === "user" && !isSystemSteeringMessage(message) ? 1 : 0); const storedRunCount = message.meta?.runCount ?? message.meta?.checkpoint?.runCount; if (storedRunCount !== undefined) { @@ -119,8 +138,9 @@ export type CollapseWorkOptions = { */ function isCollapsibleWorkItem(item: ChatRenderItem): boolean { if (item.type === "tools") return true; + if (item.type !== "message") return false; + if (isSystemSteeringMessage(item.message)) return true; return ( - item.type === "message" && item.message.role === "assistant" && !item.message.images?.length && !item.message.media?.length @@ -177,7 +197,11 @@ export function collapseCompletedWork( let lastUserIndex = -1; for (let index = items.length - 1; index >= 0; index--) { const item = items[index]; - if (item.type === "message" && item.message.role === "user") { + if ( + item.type === "message" && + item.message.role === "user" && + !isSystemSteeringMessage(item.message) + ) { lastUserIndex = index; break; } @@ -195,7 +219,9 @@ export function collapseCompletedWork( // message is the run's answer and stays visible below the summary. const last = span.at(-1); const answer = - last?.type === "message" && last.message.content.trim() + last?.type === "message" && + last.message.role === "assistant" && + last.message.content.trim() ? last : undefined; // A span is settled once a later user message exists. The trailing span diff --git a/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx b/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx index 58ab056021..38ab0070aa 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/messages/message-bubble.tsx @@ -27,6 +27,7 @@ import type { import { cn } from "@/lib/utils"; import { MemoizedMarkdown } from "../../../ui/markdown"; import { formatChatMessageContent } from "../message-content"; +import { isSystemSteeringMessage } from "./group-messages"; import { ReasoningBlock } from "./reasoning-block"; function AssistantImageCarousel({ @@ -217,6 +218,14 @@ export const MessageBubble = memo(function MessageBubble({ const isUser = message.role === "user"; const isError = message.role === "error"; const checkpoint = message.meta?.checkpoint; + // Runtime steering notes (completion nudges in scheduled/automation runs, + // team-obligation reminders) are user-role messages the machinery sends to + // the model, not something the person said or needs to read — hide them + // from the transcript entirely. Grouping still treats them as working-row + // machinery (never a turn boundary, an answer, or a run-count increment). + if (isSystemSteeringMessage(message)) { + return null; + } const displayContent = formatChatMessageContent( message.role, message.content, diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx b/apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx index 0e5c3e67a1..4b5dc1800b 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx @@ -12,8 +12,8 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; import { scrollCurrentOptionIntoView } from "@/lib/scroll-current-option"; +import { cn } from "@/lib/utils"; import { looksLikeFolderPath, normalizeWorkspacePath, @@ -399,9 +399,9 @@ function BranchPicker({ ) : (
+ className="flex max-h-56 flex-col gap-0.5 overflow-y-auto" + ref={branchListRef} + > {filteredBranches.length === 0 ? (
No branches found diff --git a/apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx b/apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx index a334153bf6..fd6118ee1d 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx @@ -358,7 +358,10 @@ export function WorkspaceSelector({ )} -
+
{filteredWorkspaces.length === 0 ? (
{looksLikeFolderPath(search) @@ -459,7 +462,10 @@ export function WorkspaceSelector({
Branches
-
+
{filteredBranches.length === 0 ? (
No branches found diff --git a/apps/examples/desktop-app/webview/components/views/settings/routine-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/routine-view.tsx index 7822ce495e..2d3bf4caea 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/routine-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/routine-view.tsx @@ -7,6 +7,7 @@ import { } from "@cline/shared/browser"; import { CheckCircle2, + ChevronDown, Circle, Clock3, ExternalLink, @@ -63,7 +64,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, @@ -527,6 +527,15 @@ export function RoutineSchedulesContent({ // rejected synchronously — two rapid clicks can both fire before React // re-renders the disabled state, and state alone can't distinguish them. const busyScheduleIdsRef = useRef>(new Set()); + // Guards the run-now follow-up: an auto-navigation into the started + // session should not fire from a page the user already left. + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const beginScheduleAction = (scheduleId: string): boolean => { if (busyScheduleIdsRef.current.has(scheduleId)) { return false; @@ -550,6 +559,7 @@ export function RoutineSchedulesContent({ return next; }); }; + const [showAllViewingRuns, setShowAllViewingRuns] = useState(false); const [viewingSchedule, setViewingSchedule] = useState(null); const [schedulePendingDelete, setSchedulePendingDelete] = @@ -773,6 +783,7 @@ export function RoutineSchedulesContent({ lastExecutions, fetchedAt: now, }; + return response; } catch (error) { const message = error instanceof Error ? error.message : String(error); setErrorMessage(message); @@ -824,17 +835,62 @@ export function RoutineSchedulesContent({ setScheduleTriggering(schedule.scheduleId, true); setErrorMessage(null); try { - await desktopClient.invoke("trigger_routine_schedule", { + const reply = await desktopClient.invoke<{ + execution?: RoutineExecution | null; + }>("trigger_routine_schedule", { schedule_id: schedule.scheduleId, }); + // A reply without an execution means no run was enqueued (the + // schedule may have been disabled or deleted since the page + // loaded) — say so instead of confirming a start. + if (!reply?.execution) { + toast({ + title: "Run not started", + description: `"${schedule.name}" did not queue a run — the schedule may be disabled or deleted.`, + variant: "destructive", + }); + await refreshSchedules({ force: true, showLoading: false }); + return; + } toast({ title: "Run started", description: `"${schedule.name}" was queued to run now.`, }); - await refreshSchedules({ force: true, showLoading: false }); - window.setTimeout(() => { - void refreshSchedules({ force: true, showLoading: false }); - }, 1_000); + // The trigger queues the run and returns before the runner starts + // the agent session, so the session id usually is not attached + // yet. Poll the overview (which also keeps the page's run status + // fresh) until it appears, then jump into the session. + // Only ever follow the execution the trigger itself named; matching + // "the schedule's newest execution" could open a previous run's + // session when the trigger failed to enqueue one. + const executionId = reply.execution.executionId ?? null; + let sessionId = reply.execution.sessionId?.trim() || null; + const deadline = Date.now() + 15_000; + while ( + !sessionId && + executionId && + mountedRef.current && + Date.now() < deadline + ) { + const overview = await refreshSchedules({ + force: true, + showLoading: false, + }); + const executions = [ + ...(overview?.activeExecutions ?? []), + ...(overview?.lastExecutions ?? []), + ]; + const match = executions.find( + (execution) => execution.executionId === executionId, + ); + sessionId = match?.sessionId?.trim() || null; + if (!sessionId) { + await new Promise((resolve) => window.setTimeout(resolve, 1_000)); + } + } + if (sessionId && mountedRef.current) { + await onOpenSession?.(sessionId); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); setErrorMessage(message); @@ -1092,6 +1148,13 @@ export function RoutineSchedulesContent({ ); }, [schedules]); + // Collapse the runs list back to the recent-three preview whenever a + // different schedule's details are opened. + const viewingScheduleId = viewingSchedule?.scheduleId ?? null; + // biome-ignore lint/correctness/useExhaustiveDependencies: viewingScheduleId is the reset trigger, not a value the effect reads + useEffect(() => { + setShowAllViewingRuns(false); + }, [viewingScheduleId]); const viewingExecutions = useMemo(() => { if (!viewingSchedule) { return []; @@ -1423,131 +1486,121 @@ export function RoutineSchedulesContent({ } }} > - + {viewingSchedule?.name ?? "Schedule"} - - Full configuration for this schedule. - {viewingSchedule && ( - - - Overview - - Runs - {viewingExecutions.length > 0 && ( - - {viewingExecutions.length} - - )} - - - -
-

- Schedule:{" "} - {formatScheduleTrigger(viewingSchedule)} -

-

- Mode:{" "} - {viewingSchedule.mode} -

-

- Model:{" "} - {formatScheduleModel(viewingSchedule)} -

-

- Enabled:{" "} - {viewingSchedule.enabled ? "yes" : "no"} -

-

- Last run:{" "} - {formatDateTime(viewingSchedule.lastRunAt)} -

-

- Next run:{" "} - {formatDateTime(viewingSchedule.nextRunAt)} -

+
+
+

+ Schedule:{" "} + {formatScheduleTrigger(viewingSchedule)} +

+

+ Mode:{" "} + {viewingSchedule.mode} +

+

+ Model:{" "} + {formatScheduleModel(viewingSchedule)} +

+

+ Enabled:{" "} + {viewingSchedule.enabled ? "yes" : "no"} +

+

+ Last run:{" "} + {formatDateTime(viewingSchedule.lastRunAt)} +

+

+ Next run:{" "} + {formatDateTime(viewingSchedule.nextRunAt)} +

+
+ {/* The JSON block scrolls internally past its cap so it + cannot push the runs below it out of easy reach. */} +
+								{JSON.stringify(viewingSchedule, null, 2)}
+							
+
+

Runs

+ + {viewingExecutions.length} result + {viewingExecutions.length === 1 ? "" : "s"} + +
+ {viewingExecutions.length === 0 ? ( +
+ No runs yet.
- {/* The JSON block absorbs the overflow so the dialog - itself never scrolls: min-h-0 lets it shrink to the - space the capped dialog leaves, and it scrolls - internally past that. */} -
-									{JSON.stringify(viewingSchedule, null, 2)}
-								
- - -
-

Runs

- - {viewingExecutions.length} result - {viewingExecutions.length === 1 ? "" : "s"} - -
- {viewingExecutions.length === 0 ? ( -
- No runs yet. -
- ) : ( -
- {viewingExecutions.map((execution) => { - const status = execution.status?.toLowerCase() ?? ""; - const succeeded = ["success", "completed"].includes( - status, - ); - const failed = ["failed", "timeout", "aborted"].includes( - status, - ); - return ( - - ); - })} -
- )} -
- + + + {formatExecutionTimestamp(execution)} + + {execution.sessionId && onOpenSession && ( + + )} + + ); + })} +
+ )} + {!showAllViewingRuns && viewingExecutions.length > 3 ? ( + + ) : null} +
)}
diff --git a/apps/examples/desktop-app/webview/hooks/chat-session/helpers.test.ts b/apps/examples/desktop-app/webview/hooks/chat-session/helpers.test.ts index b0197082ca..e6bd1f25a5 100644 --- a/apps/examples/desktop-app/webview/hooks/chat-session/helpers.test.ts +++ b/apps/examples/desktop-app/webview/hooks/chat-session/helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ChatSessionConfig } from "@/lib/chat-schema"; -import { resolveCredentialError } from "./helpers"; +import { inferHydratedChatStatus, resolveCredentialError } from "./helpers"; function makeConfig(overrides: Partial): ChatSessionConfig { return { @@ -53,3 +53,30 @@ describe("resolveCredentialError", () => { ).toBeNull(); }); }); + +describe("inferHydratedChatStatus", () => { + it("treats an assistant-answered running record as completed", () => { + // The stale-record heuristic: a "running" record whose transcript + // ends on an assistant answer is read as a session that died without + // a status flip. (The stale-stream poll deliberately bypasses this + // via mapSessionRecordStatus — see use-chat-session.) + expect( + inferHydratedChatStatus("running", [ + { + id: "u", + sessionId: "s", + role: "user", + content: "prompt", + createdAt: 1, + }, + { + id: "a", + sessionId: "s", + role: "assistant", + content: "answer", + createdAt: 2, + }, + ]), + ).toBe("completed"); + }); +}); diff --git a/apps/examples/desktop-app/webview/hooks/chat-session/helpers.ts b/apps/examples/desktop-app/webview/hooks/chat-session/helpers.ts index ec51ff69a3..d93d1d1d0d 100644 --- a/apps/examples/desktop-app/webview/hooks/chat-session/helpers.ts +++ b/apps/examples/desktop-app/webview/hooks/chat-session/helpers.ts @@ -221,3 +221,16 @@ export function inferHydratedChatStatus( } return mapHistoryStatusToChatStatus(fallback); } + +/** + * The session record's status mapped verbatim — no transcript inference. For + * callers observing a session whose record is actively maintained by the + * executing host (the stale-stream poll), the record is the authority; + * inferHydratedChatStatus's stale-record heuristic would misread a mid-run + * snapshot that happens to end on assistant narration as a finished session. + */ +export function mapSessionRecordStatus( + status: SessionHistoryStatus, +): ChatSessionStatus { + return mapHistoryStatusToChatStatus(status); +} diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx index d6932b2772..65d794b0d9 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx @@ -166,6 +166,197 @@ describe("useChatSession", () => { }); }); + it("heals a running attached session with a dead event stream by polling history", async () => { + // Scheduled runs can execute on a host whose live events never reach + // this client; the transcript must still settle without a remount. + const hydratedSessionId = "session-dead-stream"; + let readCount = 0; + let recordReads = 0; + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + if (command === "get_process_context") { + return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + } + if (command === "read_session_messages") { + readCount += 1; + const base = [ + { + id: "history-user", + sessionId: hydratedSessionId, + role: "user", + content: "tell me the current time", + createdAt: 1, + }, + ]; + return readCount === 1 + ? base + : [ + ...base, + { + id: "history-answer", + sessionId: hydratedSessionId, + role: "assistant", + content: "It is 12:28 PM PT.", + createdAt: 2, + }, + ]; + } + if (command === "get_discovered_session") { + recordReads += 1; + // Still running on the first poll — the snapshot already + // ends on assistant narration, which must NOT read as + // finished while the record says running. + return { + sessionId: hydratedSessionId, + status: recordReads === 1 ? "running" : "completed", + }; + } + if (command === "read_session_hooks") return []; + if (command === "chat_session_command") { + const request = args?.request as { action?: string } | undefined; + if (request?.action === "attach") { + return { + sessionId: hydratedSessionId, + status: "running", + provider: "cline", + model: "test-model", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + }; + } + return { promptsInQueue: [] }; + } + return []; + }, + ); + + // Fake timers must be active before hydration so the fallback's + // interval registers on the fake clock. + vi.useFakeTimers(); + try { + await act(async () => { + await current.hydrateSession({ + sessionId: hydratedSessionId, + status: "running", + provider: "cline", + model: "test-model", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + startedAt: "2026-08-12T00:00:00.000Z", + }); + }); + expect(current.status).toBe("running"); + expect(current.messages).toHaveLength(1); + + // No chat_event chunks arrive. The first poll surfaces the + // narration mid-run; the record still says running, and the + // record — not transcript shape — decides the status. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_100); + }); + expect(current.messages).toHaveLength(2); + expect(current.messages[1]?.content).toBe("It is 12:28 PM PT."); + expect(current.status).toBe("running"); + + // The record flips to completed; the next poll mirrors it. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_100); + }); + } finally { + vi.useRealTimers(); + } + + expect(current.status).toBe("completed"); + }); + + it("keeps the stale-stream poll inert while a local turn is in flight", async () => { + // Regression: the fallback poll replaced an optimistic user bubble + // (raw prompt) with its canonical envelope-wrapped twin, desyncing + // the rekey bookkeeping so the stream appended a duplicate bubble. + const hydratedSessionId = "session-local-turn"; + let readCount = 0; + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + if (command === "get_process_context") { + return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + } + if (command === "read_session_messages") { + readCount += 1; + return [ + { + id: "history-user", + sessionId: hydratedSessionId, + role: "user", + content: "earlier prompt", + createdAt: 1, + }, + ]; + } + if (command === "get_discovered_session") { + return { sessionId: hydratedSessionId, status: "running" }; + } + if (command === "read_session_hooks") return []; + if (command === "chat_session_command") { + const request = args?.request as { action?: string } | undefined; + if (request?.action === "attach" || request?.action === "start") { + return { + sessionId: hydratedSessionId, + status: "idle", + provider: "cline", + model: "test-model", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + }; + } + if (request?.action === "send") { + // Keep the send unresolved: the local turn stays in + // flight for the whole test. + return await new Promise(() => {}); + } + return { promptsInQueue: [] }; + } + return []; + }, + ); + + vi.useFakeTimers(); + try { + await act(async () => { + await current.hydrateSession({ + sessionId: hydratedSessionId, + status: "idle", + provider: "cline", + model: "test-model", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + startedAt: "2026-08-12T00:00:00.000Z", + }); + }); + const readsAfterHydration = readCount; + + await act(async () => { + void current.sendPrompt("what time is it"); + await Promise.resolve(); + }); + expect(current.status).toBe("starting"); + expect( + current.messages.filter((m) => m.content === "what time is it"), + ).toHaveLength(1); + + // Model produces nothing for a long quiet window; the poll must + // not fire while the local turn is unsettled. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(readCount).toBe(readsAfterHydration); + expect( + current.messages.filter((m) => m.content === "what time is it"), + ).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + it("routes command updates after attaching to an in-flight tool call", async () => { const hydratedSessionId = "session-in-flight-command"; invokeMock.mockImplementation( diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts index fddfd5239b..62de3db652 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts @@ -11,6 +11,7 @@ import { extractAssistantTurnDataFromRpcMessages, inferHydratedChatStatus, makeId, + mapSessionRecordStatus, normalizeRuntimeConfig, resolveCredentialError, } from "@/hooks/chat-session/helpers"; @@ -91,6 +92,12 @@ const BUSY_STATUSES = new Set([ "stopping", ]); +// Stale-stream fallback cadence for attached sessions (see the polling +// effect below): only poll after the live stream has been quiet this long, +// and re-check at this interval while it stays quiet. +const STALE_STREAM_QUIET_MS = 5_000; +const STALE_STREAM_POLL_INTERVAL_MS = 3_000; + type PendingToolOutput = { text: string; truncated: boolean; @@ -382,6 +389,9 @@ export function useChatSession() { >([]); const [promptsInQueue, setPromptsInQueue] = useState([]); const messagesRef = useRef([]); + // When the last chat_event chunk for the active session arrived. The + // stale-stream fallback below only polls while this stays quiet. + const lastLiveChunkAtRef = useRef(0); const promptsInQueueRef = useRef([]); const liveToolMessageIdsRef = useRef>({}); const pendingToolOutputRef = useRef(new Map()); @@ -1224,6 +1234,7 @@ export function useChatSession() { if (!listeningSessionId || payload.sessionId !== listeningSessionId) { return; } + lastLiveChunkAtRef.current = Date.now(); if (abortedRef.current) { return; } @@ -1800,6 +1811,119 @@ export function useChatSession() { }; }, [clearLiveToolRefs, finalizeSettledTurn]); + // ---- Stale-stream fallback for attached sessions ---- + // Scheduled/automation runs execute on a session host whose events are + // not projected through the hub's live pipeline (and with several hub + // daemons sharing cron.db, a different daemon can claim the run + // entirely), so an attached session can sit at "running" with a dead + // event stream — stuck on the thinking shimmer until a remount re-reads + // history. While an attached session is busy and the stream is quiet, + // poll canonical history and the session record so the transcript and + // status heal in place. A locally driven turn keeps chunks flowing, so + // the quiet-window guard keeps this fallback out of the way there. + useEffect(() => { + if (!sessionId || hydratedHistorySessionId !== sessionId) { + return; + } + if (!BUSY_STATUSES.has(status)) { + return; + } + let cancelled = false; + let polling = false; + const poll = async () => { + if (cancelled || polling) { + return; + } + if (Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS) { + return; + } + // An assistant bubble mid-stream means the live pipeline works; + // canonical history could lag behind it. + if (activeAssistantMessageIdRef.current) { + return; + } + // A locally driven turn is in flight (submit/queue bumps the epoch; + // settling closes it). Its optimistic user bubble carries the raw + // prompt while canonical history stores it wrapped in a + // user_input envelope, so replacing state mid-turn desyncs the + // rekey bookkeeping and the stream then appends a duplicate + // bubble. The fallback exists for externally driven runs + // (schedules, other clients) — stay inert until the local turn + // settles. + if ( + turnEpochRef.current !== turnSettledEpochRef.current || + outstandingOptimisticUserIdsRef.current.size > 0 + ) { + return; + } + polling = true; + try { + const pollStartedAt = Date.now(); + const [historyMessages, record] = await Promise.all([ + desktopClient + .invoke("read_session_messages", { + sessionId, + maxMessages: MAX_MESSAGES, + }) + .catch(() => null), + desktopClient + .invoke<{ status?: string } | null>("get_discovered_session", { + sessionId, + }) + .catch(() => null), + ]); + if ( + cancelled || + activeSessionIdRef.current !== sessionId || + // The live stream resumed (or a local turn started) while + // the poll was in flight; live state is fresher than the + // snapshot we just read. + Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS || + activeAssistantMessageIdRef.current || + turnEpochRef.current !== turnSettledEpochRef.current || + outstandingOptimisticUserIdsRef.current.size > 0 + ) { + return; + } + if (Array.isArray(historyMessages) && historyMessages.length > 0) { + const mergedMessages = mergeHydratedMessagesWithLive({ + hydrated: historyMessages, + current: messagesRef.current, + sessionId, + hydrationStartedAt: pollStartedAt, + }); + // Same as hydration: canonical rows may have replaced live + // tool rows, so rebuild the tool routing keys or later + // tool events would append instead of updating in place. + const liveToolState = deriveLiveToolState(mergedMessages); + liveToolMessageIdsRef.current = liveToolState.messageIds; + liveToolInputsRef.current = liveToolState.inputs; + setMessages(mergedMessages); + } + // The record is the authority here: the sessions this poll + // serves have a live host maintaining their record, and it + // flips to a terminal status when the run ends. Transcript + // inference (inferHydratedChatStatus) would misread a mid-run + // snapshot ending on assistant narration as finished, hiding + // the working indicator and disarming this poll. + const nextStatus = record?.status?.trim(); + if (nextStatus) { + setStatus(mapSessionRecordStatus(nextStatus as SessionHistoryStatus)); + } + } finally { + polling = false; + } + }; + const interval = window.setInterval( + () => void poll(), + STALE_STREAM_POLL_INTERVAL_MS, + ); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [hydratedHistorySessionId, sessionId, status]); + // ---- Shared: start a new session via RPC ---- const startSession = useCallback( @@ -2742,6 +2866,10 @@ export function useChatSession() { activeSessionIdRef.current = session.sessionId; activeAssistantMessageIdRef.current = null; setActiveAssistantMessageId(null); + // A freshly hydrated session has no local turn in flight; without + // this the mount defaults (epoch 0, settled -1) read as an open + // turn and keep the stale-stream fallback inert forever. + turnSettledEpochRef.current = turnEpochRef.current; setHydratedHistorySessionId(session.sessionId); setPendingToolApprovals([]); setPendingAskQuestions([]);