feat(desktop): scheduled sessions UX — unified details dialog, run-now handoff, hidden steering, stuck-thinking fix (#13573)

* feat(desktop): merge schedule details into one view and open run-now sessions

The schedule details dialog drops its Overview/Runs tabs: one scrollable
column with the meta grid, the configuration JSON (capped at max-h-64
with internal scroll so it cannot crowd out what follows), and a Runs
section beneath it showing the three most recent runs with a ghost
"Show all N runs" expander (collapsed again whenever a different
schedule's details open). The "Full configuration for this schedule"
subtext is gone; the dialog passes aria-describedby={undefined} so
Radix does not warn about the missing description.

Run now hands you into the session it starts. The trigger command
queues the run and returns before the runner attaches a session id, so
after the toast the handler polls the schedule overview once a second
for up to 15 seconds — which doubles as keeping the page's run status
fresh (refreshSchedules now returns the fetched overview to make that
single-stream) — until the triggered execution reports its session id,
then calls onOpenSession. Guarded so it never auto-navigates after the
user left the page.

* feat(desktop): hide runtime steering messages from transcripts

Scheduled/automation runs inject user-role steering messages each
iteration ("[SYSTEM] This run is not complete until you call
submit_and_exit...", plus a team-obligations variant). The chat view
rendered them as user bubbles, as if the person had typed them — in a
scheduled session the transcript was mostly [SYSTEM] noise.

They are machinery talking to the model, not something the person said
or needs to read, so the transcript now hides them entirely:
MessageBubble renders null for any [SYSTEM]-prefixed user message.
Grouping still treats them as working-row machinery via a single
isSystemSteeringMessage predicate — they collapse into the run's work
span, are never a turn boundary, can never be mistaken for a run's
answer, and never advance the run count even when metadata is missing —
so work-block folding and checkpoint/edit run numbering stay correct.
A finished scheduled session now reads as prompt, work summary, answer.

* fix(desktop): poll history while an attached session's event stream is dead

Opening a scheduled session while (or right after) it runs left the
view stuck on the thinking shimmer until the user switched away and
back. Root cause is in core: the hub daemon executes scheduled runs on
a private LocalRuntimeHost inside createLocalHubScheduleRuntimeHandlers,
while the hub server only projects live events from its own session
host — so session.attach succeeds but no assistant/tool/status events
ever flow. And since multiple hub daemons share cron.db, a run claimed
by a different daemon is invisible to this hub regardless. The proper
core rewiring is tracked as ENG-2474.

Client-side heal that covers every case: while an attached history
session reports a busy status and no chat_event chunk has arrived for
five seconds (and no assistant bubble is mid-stream), poll every three
seconds — re-read canonical history, merged through the same dedupe
path hydration uses, and the session record's status — so the
transcript and the thinking indicator settle in place. Locally driven
turns keep chunks flowing, so the quiet-window guard keeps the fallback
inert there.

* chore(desktop): format workspace selector components

Biome formatting drift that landed on main; picked up by a formatter
pass over components/views/chat.

* fix(desktop): keep stale-stream poll inert during locally driven turns

The fallback poll could fire between a local submit and the model's
first chunk (optimistic user bubble added, stream quiet past the
window, no assistant bubble yet). It then replaced the optimistic
bubble — raw prompt text — with its canonical history twin, which is
stored wrapped in a user_input envelope. The rekey handler that runs
when the stream starts looks for a trailing user bubble matching the
raw prompt, finds only the wrapped copy, and appends a second bubble:
duplicated messages in normal interactive chat.

The poll now stays inert while a local turn is in flight
(turnEpoch !== turnSettledEpoch, or outstanding optimistic user
messages), checked both before polling and again after the snapshot
returns. Hydration marks the turn settled — the mount defaults
(epoch 0, settled -1) otherwise read as an open turn and would keep
the fallback inert forever for the scheduled-session case it exists
for. Applying a polled snapshot also rebuilds the live tool routing
keys, same as hydration, so later tool events update canonical rows
in place instead of appending.

* fix(desktop): keep the working indicator alive for narrating scheduled runs

Watching a scheduled run live: the first tool row appeared, then the
thinking indicator vanished with nothing streaming, and the rest of
the run (final answer, submit_and_exit) only showed up seconds later
in one lump.

inferHydratedChatStatus treats a "running" session record whose
transcript ends on an assistant message as a session that died without
a status flip and reports "completed". That heuristic is right for
stale records, but scheduled/automation models narrate between tool
calls, so a polled snapshot can genuinely end on assistant text
mid-run — the completed flip hid the working indicator, folded the
run early, and disarmed the stale-stream poll (status left the busy
set), dead-ending live updates until an in-flight poll happened to
deliver the finished run.

The heuristic now only applies once the transcript has actually gone
quiet (newest message older than two minutes — comfortably past model
latency plus tool runs). A recently active transcript keeps the
record's "running" verdict, so the indicator stays up and polling
stays armed until the record itself settles.

* fix(desktop): stale-stream poll mirrors the session record instead of inferring

Replaces the previous fix for the vanishing working indicator (the
time-window guard added to inferHydratedChatStatus) with a version
that adds no inference at all: the heuristic is restored to exactly
its long-standing form, and the poll now maps the session record's
status verbatim (mapSessionRecordStatus).

The record is the right authority in the poll's context: the sessions
this fallback serves have a live host maintaining their record, and it
flips to a terminal status when the run ends. Transcript-shape
inference belongs only where it has always lived — hydrating sessions
whose records may be orphaned — and would misread a mid-run snapshot
ending on assistant narration as a finished session, hiding the
working indicator and disarming the poll.

* fix(desktop): address review findings on steering detection and run-now matching

Steering detection additionally requires the injected-message marker
(meta.userRunSpan === 0) beside the [SYSTEM] prefix, so a person's
genuine prompt that happens to start with "[SYSTEM]" stays visible
and turn-counted. The failure direction is deliberate: an unstamped
injected reminder would merely show as a user bubble, while the
content-only check could hide a real prompt.

Run-now only follows the execution id the trigger reply itself named;
the newest-execution-for-this-schedule fallback could open a previous
run's session when the trigger failed to enqueue one.

* fix(desktop): report a failed run-now instead of confirming a start

A trigger reply without an execution means no run was enqueued (the
schedule may have been disabled or deleted since the page loaded). The
handler previously toasted "Run started" regardless and then silently
skipped the session-open polling. It now shows a destructive
"Run not started" toast, refreshes the schedule list so the row
reflects reality, and skips the polling entirely.
This commit is contained in:
Saoud Rizwan
2026-08-25 23:28:21 -07:00
committed by GitHub
parent 110138b540
commit 6fc40127a6
10 changed files with 700 additions and 145 deletions
@@ -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<HTMLButtonElement>("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", () => {
@@ -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
@@ -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,
@@ -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({
</div>
) : (
<div
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
ref={branchListRef}
>
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto"
ref={branchListRef}
>
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
@@ -358,7 +358,10 @@ export function WorkspaceSelector({
</span>
</Button>
)}
<div ref={workspaceListRef} className="flex flex-col gap-0.5 max-h-28 overflow-y-auto">
<div
ref={workspaceListRef}
className="flex flex-col gap-0.5 max-h-28 overflow-y-auto"
>
{filteredWorkspaces.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
{looksLikeFolderPath(search)
@@ -459,7 +462,10 @@ export function WorkspaceSelector({
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Branches
</div>
<div ref={branchListRef} className="flex flex-col gap-0.5 max-h-36 overflow-y-auto">
<div
ref={branchListRef}
className="flex flex-col gap-0.5 max-h-36 overflow-y-auto"
>
{filteredBranches.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
No branches found
@@ -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<Set<string>>(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<RoutineSchedule | null>(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({
}
}}
>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
<DialogContent
aria-describedby={undefined}
className="flex max-h-[85vh] flex-col sm:max-w-2xl"
>
<DialogHeader>
<DialogTitle>{viewingSchedule?.name ?? "Schedule"}</DialogTitle>
<DialogDescription>
Full configuration for this schedule.
</DialogDescription>
</DialogHeader>
{viewingSchedule && (
<Tabs className="min-h-0 flex-1" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="runs">
Runs
{viewingExecutions.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
{viewingExecutions.length}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent
className="mt-4 flex min-h-0 flex-1 flex-col gap-3"
value="overview"
>
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
<p>
<span className="text-muted-foreground/70">Schedule:</span>{" "}
{formatScheduleTrigger(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Mode:</span>{" "}
{viewingSchedule.mode}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Enabled:</span>{" "}
{viewingSchedule.enabled ? "yes" : "no"}
</p>
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(viewingSchedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(viewingSchedule.nextRunAt)}
</p>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
<div className="grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
<p>
<span className="text-muted-foreground/70">Schedule:</span>{" "}
{formatScheduleTrigger(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Mode:</span>{" "}
{viewingSchedule.mode}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(viewingSchedule)}
</p>
<p>
<span className="text-muted-foreground/70">Enabled:</span>{" "}
{viewingSchedule.enabled ? "yes" : "no"}
</p>
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(viewingSchedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(viewingSchedule.nextRunAt)}
</p>
</div>
{/* The JSON block scrolls internally past its cap so it
cannot push the runs below it out of easy reach. */}
<pre className="max-h-64 shrink-0 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{JSON.stringify(viewingSchedule, null, 2)}
</pre>
<div className="mt-1 flex items-center justify-between">
<h3 className="text-sm font-semibold">Runs</h3>
<span className="text-xs text-muted-foreground">
{viewingExecutions.length} result
{viewingExecutions.length === 1 ? "" : "s"}
</span>
</div>
{viewingExecutions.length === 0 ? (
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
No runs yet.
</div>
{/* 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. */}
<pre className="min-h-0 overflow-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{JSON.stringify(viewingSchedule, null, 2)}
</pre>
</TabsContent>
<TabsContent
className="mt-4 min-h-0 flex-1 overflow-y-auto"
value="runs"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Runs</h3>
<span className="text-xs text-muted-foreground">
{viewingExecutions.length} result
{viewingExecutions.length === 1 ? "" : "s"}
</span>
</div>
{viewingExecutions.length === 0 ? (
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
No runs yet.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
{viewingExecutions.map((execution) => {
const status = execution.status?.toLowerCase() ?? "";
const succeeded = ["success", "completed"].includes(
status,
);
const failed = ["failed", "timeout", "aborted"].includes(
status,
);
return (
<button
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
disabled={!execution.sessionId || !onOpenSession}
key={execution.executionId}
onClick={() => {
if (execution.sessionId) {
void onOpenSession?.(execution.sessionId);
}
}}
type="button"
>
{succeeded ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
) : failed ? (
<XCircle className="size-4 shrink-0 text-destructive" />
) : (
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium capitalize">
{execution.status || "Unknown result"}
) : (
<div className="overflow-hidden rounded-lg border border-border">
{(showAllViewingRuns
? viewingExecutions
: viewingExecutions.slice(0, 3)
).map((execution) => {
const status = execution.status?.toLowerCase() ?? "";
const succeeded = ["success", "completed"].includes(status);
const failed = ["failed", "timeout", "aborted"].includes(
status,
);
return (
<button
className="group flex w-full items-center gap-3 border-b border-border px-3 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
disabled={!execution.sessionId || !onOpenSession}
key={execution.executionId}
onClick={() => {
if (execution.sessionId) {
void onOpenSession?.(execution.sessionId);
}
}}
type="button"
>
{succeeded ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
) : failed ? (
<XCircle className="size-4 shrink-0 text-destructive" />
) : (
<Clock3 className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium capitalize">
{execution.status || "Unknown result"}
</span>
{execution.errorMessage && (
<span className="block truncate text-xs text-destructive">
{execution.errorMessage}
</span>
{execution.errorMessage && (
<span className="block truncate text-xs text-destructive">
{execution.errorMessage}
</span>
)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatExecutionTimestamp(execution)}
</span>
{execution.sessionId && onOpenSession && (
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</button>
);
})}
</div>
)}
</TabsContent>
</Tabs>
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatExecutionTimestamp(execution)}
</span>
{execution.sessionId && onOpenSession && (
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
)}
</button>
);
})}
</div>
)}
{!showAllViewingRuns && viewingExecutions.length > 3 ? (
<Button
className="self-start text-muted-foreground"
onClick={() => setShowAllViewingRuns(true)}
size="sm"
type="button"
variant="ghost"
>
Show all {viewingExecutions.length} runs
<ChevronDown className="size-3.5" />
</Button>
) : null}
</div>
)}
</DialogContent>
</Dialog>
@@ -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>): 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");
});
});
@@ -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);
}
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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(
@@ -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<ChatSessionStatus>([
"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<PromptInQueue[]>([]);
const messagesRef = useRef<ChatMessage[]>([]);
// 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<PromptInQueue[]>([]);
const liveToolMessageIdsRef = useRef<Record<string, string>>({});
const pendingToolOutputRef = useRef(new Map<string, PendingToolOutput>());
@@ -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<ChatMessage[]>("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([]);