@parhelia/core 0.1.12776 → 0.1.12777

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  declare const buttonVariants: (props?: ({
3
- variant?: "select" | "link" | "outline" | "default" | "defaultInverted" | "destructive" | "secondary" | "ghost" | null | undefined;
3
+ variant?: "link" | "select" | "outline" | "default" | "defaultInverted" | "destructive" | "secondary" | "ghost" | null | undefined;
4
4
  size?: "select" | "default" | "icon" | "sm" | "xs" | "xxs" | "lg" | "iconSm" | "iconXs" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  type BaseButtonProps = React.ComponentProps<"button"> & {
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { AgentChatMessage, Agent, AgentMetadata } from "../services/agentService";
3
3
  import { AiProfile } from "../services/aiService";
4
- export declare function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact, displayMode, showSummaryInput, hideContext, hideBottomControls, hideGreeting, defaultCollapseJson, simpleMode, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder, }: {
4
+ export declare function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact, displayMode, showSummaryInput, hideContext, hideBottomControls, showStatusBar, showAgentConfigControls, hideGreeting, defaultCollapseJson, simpleMode, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder, }: {
5
5
  agentStub: Agent;
6
6
  initialMetadata?: AgentMetadata;
7
7
  profiles: AiProfile[];
@@ -17,6 +17,10 @@ export declare function AgentTerminal({ agentStub, initialMetadata, profiles, on
17
17
  showSummaryInput?: boolean;
18
18
  hideContext?: boolean;
19
19
  hideBottomControls?: boolean;
20
+ /** Force the bottom status bar (model/context/cost) to render even when bottom controls are hidden. Defaults to `!hideBottomControls`. */
21
+ showStatusBar?: boolean;
22
+ /** Force the agent configuration row (mode selector, Agent settings popover) to render even when bottom controls are hidden. Defaults to `!hideBottomControls`. */
23
+ showAgentConfigControls?: boolean;
20
24
  hideGreeting?: boolean;
21
25
  defaultCollapseJson?: boolean;
22
26
  simpleMode?: boolean;
@@ -21,7 +21,7 @@ import { QueuedPromptsPanel } from "./QueuedPromptsPanel";
21
21
  import { AgentCapacityBanner, AgentCostLimitBanner, AgentErrorBanner, } from "./AgentBanners";
22
22
  import { InitialThinkingSplash } from "./InitialThinkingSplash";
23
23
  import { AgentInlineDialogContent } from "./AgentInlineDialogContent";
24
- import { AGENT_HISTORY_LIMIT, MACHINE_CAPACITY_REASON, buildPlaceholderAgentDetails, formatAllowanceLabel, formatAllowanceSource, getAgentRunMessageAgentId, getAgentRunMessageDetail, getAgentRunMessageSeq, isInactiveServerExecutionStatus, isAgentErrorStatusValue, isHeartbeatRunEventMessage, mergeAgentOperationHistory, normalizeDialogAgentId, normalizeProfileAllowanceOperations, normalizeServerExecutionStatus, } from "./agentMessageHelpers";
24
+ import { AGENT_HISTORY_LIMIT, MACHINE_CAPACITY_REASON, buildPlaceholderAgentDetails, formatAllowanceLabel, formatAllowanceSource, getAgentRunMessageAgentId, getAgentRunMessageDetail, getAgentRunMessageSeq, isAgentErrorStatusValue, isHeartbeatRunEventMessage, mergeAgentOperationHistory, normalizeDialogAgentId, normalizeProfileAllowanceOperations, normalizeServerExecutionStatus, } from "./agentMessageHelpers";
25
25
  import { AgentDocumentList, } from "./AgentDocumentList";
26
26
  import { AgentEditOperationsPanel } from "./EditOperationsPanel";
27
27
  import { SpawnedAgentsPanel } from "./SpawnedAgentsPanel";
@@ -40,7 +40,7 @@ import { cn } from "../../lib/utils";
40
40
  import { sanitizeSvg } from "../../lib/sanitize";
41
41
  import { Select } from "../../components/ui/select";
42
42
  import { AgentTerminalStatusBar } from "./AgentTerminalStatusBar";
43
- import { checkReplayVerified, createRecoveryStateSlice, decideRecoveryAction, interpretAgentRunDiagnostics, reconcilePendingDialogTracker, } from "./agentDiagnostics";
43
+ import { isStreamStalled, shouldReloadAfterDiagnostics, } from "./agentDiagnostics";
44
44
  import { forceEditorSocketReconnect } from "../client/hooks/useEditorWebSocket";
45
45
  import { SimpleTabs } from "../ui/SimpleTabs";
46
46
  import { Splitter } from "../ui/Splitter";
@@ -80,7 +80,7 @@ function hasStaleRunningLoadSuppression(agentId) {
80
80
  // interface AgentTerminalProps {
81
81
  // agentStub: Agent;
82
82
  // }
83
- export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact = false, displayMode = "full", showSummaryInput = false, hideContext = false, hideBottomControls = false, hideGreeting = false, defaultCollapseJson = false, simpleMode = false, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages = false, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder = false, }) {
83
+ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact = false, displayMode = "full", showSummaryInput = false, hideContext = false, hideBottomControls = false, showStatusBar, showAgentConfigControls, hideGreeting = false, defaultCollapseJson = false, simpleMode = false, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages = false, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder = false, }) {
84
84
  // Derived from props. `isVisible` controls "is this terminal mounted and
85
85
  // streaming" (subscriptions, polling). `isFocused` controls "does this
86
86
  // terminal own keyboard focus and dialogs". Both fall back to legacy
@@ -1249,16 +1249,14 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1249
1249
  const lastSeqRef = useRef(0);
1250
1250
  const subscribedAgentIdRef = useRef(null);
1251
1251
  const reconcileServerStateInFlightRef = useRef(false);
1252
- // Recovery ladder state. The graduated replay reconnect → stale path lives in the
1253
- // diagnostics polling effect below. State is keyed by agentId so switching between
1254
- // agents in the same terminal doesn't cross-contaminate cooldowns.
1255
- const recoveryStateByAgentRef = useRef(new Map());
1256
- const replayVerifyTimeoutRef = useRef(null);
1257
- // Mirror refs so the one-shot verify timer (which fires outside the polling effect's
1258
- // closure) can read the latest snapshot/diagnostics without re-running the effect.
1259
- const runDiagnosticsSnapshotRef = useRef(null);
1260
- const lastDiagnosticsResponseRef = useRef(null);
1261
- const [showStaleAgentBanner, setShowStaleAgentBanner] = useState(false);
1252
+ // Stream-stall watchdog timestamps. `lastNonHeartbeatUpdateAtRef` is set every time
1253
+ // something non-heartbeat advances the run (prompt submit, isExecuting transition,
1254
+ // status/delta/complete/error event, socket reconnect, or a stall reload).
1255
+ // `lastStallReloadAtRef` enforces a minimum gap between automatic reloads so a
1256
+ // backend that keeps advancing while our socket keeps bouncing can't trigger reloads
1257
+ // every 15 seconds.
1258
+ const lastNonHeartbeatUpdateAtRef = useRef(0);
1259
+ const lastStallReloadAtRef = useRef(0);
1262
1260
  const toolCallFirstSeenAtRef = useRef({});
1263
1261
  const pendingToolCompletionTimersRef = useRef({});
1264
1262
  // Cache mode/model/profile changes made while the agent is still "new" (not yet persisted)
@@ -3019,6 +3017,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3019
3017
  if (seq) {
3020
3018
  lastSeqRef.current = seq;
3021
3019
  }
3020
+ if (!isHeartbeat) {
3021
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3022
+ }
3022
3023
  // Route based on delta type
3023
3024
  const agentStreamMessage = {
3024
3025
  type,
@@ -3092,6 +3093,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3092
3093
  if (seq) {
3093
3094
  lastSeqRef.current = seq;
3094
3095
  }
3096
+ // Status events never carry a "Heartbeat" payload — heartbeats are deltas. Any
3097
+ // status update is meaningful progress for the watchdog.
3098
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3095
3099
  // Route based on statusData.state
3096
3100
  try {
3097
3101
  // Normalize various status shapes and handle Cancelled uniformly
@@ -3425,12 +3429,14 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3425
3429
  }
3426
3430
  // Lifecycle: agent:run:complete
3427
3431
  if (messageType === "agent:run:complete") {
3432
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3428
3433
  const finalStatus = normalizeServerExecutionStatus(message.payload?.finalStatus);
3429
3434
  settleCompletedRun(finalStatus === "cancelled" ? "cancelled" : "completed");
3430
3435
  return;
3431
3436
  }
3432
3437
  // Lifecycle: agent:run:error
3433
3438
  if (messageType === "agent:run:error") {
3439
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3434
3440
  const errorMsg = toUserFacingAgentErrorMessage(message.payload?.error) ||
3435
3441
  "AI could not complete this request.";
3436
3442
  clearHeartbeatMessages();
@@ -4105,6 +4111,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4105
4111
  context: canonicalizeAgentMetadata(effectiveContext), // Use fresh live context when in live mode
4106
4112
  };
4107
4113
  console.log("[AgentTerminal] Calling startAgent API for agent:", agentId);
4114
+ // Reset the stall watchdog so its 15s window starts at submit, not at the last
4115
+ // delta of the previous run (which could be hours ago for a reused chat).
4116
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
4108
4117
  const response = await startAgent(request);
4109
4118
  console.log("[AgentTerminal] startAgent response:", response);
4110
4119
  const isQueuedForCapacity = response.reason === MACHINE_CAPACITY_REASON ||
@@ -4999,7 +5008,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4999
5008
  const lastEvent = recentAgentRunEvents[recentAgentRunEvents.length - 1];
5000
5009
  const { receivedSeqs, missingSeqs, totalCount } = currentRunDiagnostics;
5001
5010
  const observedMaxSeq = receivedSeqs[receivedSeqs.length - 1] ?? 0;
5002
- const inlineCallback = activeInlineDialogRef.current?.request.callbackId?.trim() || null;
5003
5011
  return {
5004
5012
  agentId: currentAgentId,
5005
5013
  isSubmitting,
@@ -5026,8 +5034,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5026
5034
  totalToolCallCount,
5027
5035
  incompleteToolCallCount,
5028
5036
  recentToolUiEvents,
5029
- activeInlineDialogCallbackId: inlineCallback,
5030
- pendingDialogReplayCallbackIds: Array.from(pendingDialogReplayCallbackIdsRef.current),
5031
5037
  };
5032
5038
  }, [
5033
5039
  assistantGroupCount,
@@ -5048,239 +5054,130 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5048
5054
  recentToolUiEvents,
5049
5055
  totalToolCallCount,
5050
5056
  agent?.statusMessage,
5051
- activeInlineDialog,
5052
5057
  ]);
5053
- // Mirror the latest snapshot/diagnostics so the one-shot replay verify timer can read
5054
- // them after the polling effect's closure has gone stale.
5058
+ // Bump the watchdog timestamp when isExecuting goes false→true. Covers resumed and
5059
+ // foreign starts (where another browser kicks off a run we're observing) on top of
5060
+ // the explicit submit-time bump in handleSubmit.
5061
+ const previousIsExecutingRef = useRef(false);
5055
5062
  useEffect(() => {
5056
- runDiagnosticsSnapshotRef.current = runDiagnosticsSnapshot;
5057
- }, [runDiagnosticsSnapshot]);
5058
- // Reset all per-agent recovery state when the active agent changes.
5063
+ if (isExecuting && !previousIsExecutingRef.current) {
5064
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5065
+ }
5066
+ previousIsExecutingRef.current = isExecuting;
5067
+ }, [isExecuting]);
5068
+ // When the editor socket reconnects, the subscription registry resubscribes for us.
5069
+ // Give it a fresh window before the watchdog judges silence — otherwise a slow
5070
+ // resubscribe handshake would look like a stall.
5059
5071
  useEffect(() => {
5060
- recoveryStateByAgentRef.current.clear();
5061
- if (replayVerifyTimeoutRef.current) {
5062
- clearTimeout(replayVerifyTimeoutRef.current);
5063
- replayVerifyTimeoutRef.current = null;
5072
+ if (isExecuting) {
5073
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5064
5074
  }
5065
- setShowStaleAgentBanner(false);
5066
- }, [currentAgentId]);
5067
- // When the editor socket reconnects (socketConnectionVersion bump) clear the stale
5068
- // banner — the registry has just re-issued subscriptions, so any "stuck" state should
5069
- // resolve on the next poll. If it doesn't, the banner will reappear.
5075
+ }, [editContext?.socketConnectionVersion, isExecuting]);
5076
+ // Reset watchdog cooldown timestamps when the active agent changes — otherwise a
5077
+ // recent reload on agent A would suppress the first reload for agent B.
5070
5078
  useEffect(() => {
5071
- setShowStaleAgentBanner(false);
5072
- }, [editContext?.socketConnectionVersion]);
5079
+ lastNonHeartbeatUpdateAtRef.current = 0;
5080
+ lastStallReloadAtRef.current = 0;
5081
+ }, [currentAgentId]);
5082
+ // Stream-stall watchdog. While the run is active and the terminal is visible, the
5083
+ // watchdog ticks every 5s and checks whether the gap since the last non-heartbeat
5084
+ // update exceeds 15s. If it does, it asks the backend whether it has produced
5085
+ // sequence numbers we have not applied — and only if so, falls back to what a user
5086
+ // would do: reload the agent and reconnect the socket.
5073
5087
  useEffect(() => {
5074
5088
  if (!effectiveIsVisible || !isExecuting || !currentAgentId) {
5075
5089
  return;
5076
5090
  }
5077
5091
  let disposed = false;
5078
5092
  const agentIdAtMount = currentAgentId;
5079
- const getRecoveryState = () => {
5080
- let state = recoveryStateByAgentRef.current.get(agentIdAtMount);
5081
- if (!state) {
5082
- state = createRecoveryStateSlice();
5083
- recoveryStateByAgentRef.current.set(agentIdAtMount, state);
5084
- }
5085
- return state;
5086
- };
5093
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5087
5094
  const logRecoveryEvent = (kind, payload) => {
5088
5095
  try {
5089
5096
  appendToolUiEvent(`recovery:${kind}`, JSON.stringify({ agentId: agentIdAtMount, ...payload }));
5090
5097
  }
5091
5098
  catch {
5092
- // Telemetry must never throw out of the polling path.
5099
+ // Telemetry must never throw out of the watchdog path.
5093
5100
  }
5094
5101
  };
5095
- const clearVerifyTimer = () => {
5096
- if (replayVerifyTimeoutRef.current) {
5097
- clearTimeout(replayVerifyTimeoutRef.current);
5098
- replayVerifyTimeoutRef.current = null;
5099
- }
5100
- };
5101
- const tryReconnect = (state, reason) => {
5102
- const now = Date.now();
5103
- if (state.lastReconnectAt > 0 &&
5104
- now - state.lastReconnectAt < 60_000) {
5105
- logRecoveryEvent("reconnect-cooldown", { reason });
5106
- return false;
5102
+ const sessionId = editContext?.sessionId;
5103
+ const tick = async () => {
5104
+ if (!isStreamStalled({
5105
+ isExecuting: true,
5106
+ isVisible: true,
5107
+ isStopping: isStoppingRef.current,
5108
+ isWaitingForCapacity: lastRunStatusReason === MACHINE_CAPACITY_REASON,
5109
+ lastUpdateAt: lastNonHeartbeatUpdateAtRef.current,
5110
+ now: Date.now(),
5111
+ })) {
5112
+ return;
5107
5113
  }
5108
- state.lastReconnectAt = now;
5109
- const closed = forceEditorSocketReconnect(reason);
5110
- logRecoveryEvent("reconnect-started", { reason, closed });
5111
- return true;
5112
- };
5113
- const checkServerCompletion = async () => {
5114
+ logRecoveryEvent("stall-check", {
5115
+ silentMs: Date.now() - lastNonHeartbeatUpdateAtRef.current,
5116
+ localSeq: lastSeqRef.current,
5117
+ });
5118
+ let diagnostics;
5114
5119
  try {
5115
- const diagnostics = await getAgentDiagnostics(agentIdAtMount, editContext?.sessionId);
5116
- if (disposed) {
5117
- return;
5118
- }
5119
- lastDiagnosticsResponseRef.current = diagnostics;
5120
- const serverStatus = diagnostics.execution?.status;
5121
- if (isInactiveServerExecutionStatus(serverStatus)) {
5122
- setShowStaleAgentBanner(false);
5123
- const lastRunRequestedAt = Date.parse(diagnostics.lastRunRequest?.requestedAt || "");
5124
- const cancelRequestedAt = Date.parse(diagnostics.lastCancellation?.requestedAt || "");
5125
- const cancellationAppliesToCurrentRun = Number.isFinite(cancelRequestedAt) &&
5126
- (!Number.isFinite(lastRunRequestedAt) ||
5127
- cancelRequestedAt >= lastRunRequestedAt);
5128
- settleCompletedRun(normalizeServerExecutionStatus(serverStatus) === "cancelled" ||
5129
- cancellationAppliesToCurrentRun
5130
- ? "cancelled"
5131
- : "completed");
5132
- return;
5133
- }
5134
- // Backstop: if the backend reports an error but we never received the
5135
- // agent:run:error lifecycle event (e.g. it was missed or the broadcast
5136
- // path failed to fire), surface the error locally instead of waiting
5137
- // for the user to reload.
5138
- if (normalizeServerExecutionStatus(serverStatus) === "error") {
5139
- const rawError = diagnostics.execution?.error ?? null;
5140
- const errorMsg = toUserFacingAgentErrorMessage(rawError) ||
5141
- rawError ||
5142
- "AI could not complete this request.";
5143
- clearHeartbeatMessages();
5144
- setLastRunStatusReason(null);
5145
- setError(errorMsg);
5146
- setAgent((prev) => prev ? { ...prev, status: "error", statusMessage: errorMsg } : prev);
5147
- setIsWaitingForResponse(false);
5148
- isWaitingRef.current = false;
5149
- setIsConnecting(false);
5150
- setIsAgentThinking(false);
5151
- setShowStaleAgentBanner(false);
5152
- return;
5153
- }
5154
- const snapshot = runDiagnosticsSnapshot;
5155
- if (!editContext?.socketDiagnostics) {
5156
- // No edit context to read socket diagnostics from — recovery decisions need it,
5157
- // and without it we'd misclassify the stream. Wait for the next tick.
5158
- return;
5159
- }
5160
- const summary = interpretAgentRunDiagnostics({
5161
- socketDiagnostics: editContext.socketDiagnostics,
5162
- localSnapshot: snapshot,
5163
- serverDiagnostics: diagnostics,
5164
- });
5165
- const state = getRecoveryState();
5166
- const now = Date.now();
5167
- reconcilePendingDialogTracker(state, diagnostics.pendingDialogs, now);
5168
- // --- Verify-window resolution. Structured to avoid the "expired branch
5169
- // unreachable" pitfall: first check whether a verify is in flight, then branch on
5170
- // whether it's still inside the window or just expired.
5171
- if (state.lastReplayVerifyDeadlineAt > 0) {
5172
- if (now < state.lastReplayVerifyDeadlineAt) {
5173
- // Still inside the 4s window — leave verification to the one-shot timer.
5174
- return;
5175
- }
5176
- const verified = checkReplayVerified(state, snapshot, diagnostics);
5177
- state.lastReplayVerifyDeadlineAt = 0;
5178
- state.lastReplayVerifySnapshot = null;
5179
- logRecoveryEvent(verified ? "replay-succeeded" : "replay-failed", { kind: "poll", summaryCode: summary.code });
5180
- if (verified) {
5181
- setShowStaleAgentBanner(false);
5182
- return;
5183
- }
5184
- // Fall through — decideRecoveryAction will likely return "reconnect" now.
5185
- }
5186
- const decision = decideRecoveryAction({
5187
- summary,
5188
- localSnapshot: snapshot,
5189
- serverDiagnostics: diagnostics,
5190
- recoveryState: state,
5191
- now,
5192
- });
5193
- state.lastDecidedAction = decision.action;
5194
- logRecoveryEvent("decided", {
5195
- action: decision.action,
5196
- summaryCode: summary.code,
5197
- reasonCode: decision.reasonCode,
5198
- replayKey: decision.replayKey,
5120
+ diagnostics = await getAgentDiagnostics(agentIdAtMount, sessionId);
5121
+ }
5122
+ catch (error) {
5123
+ console.warn("[AgentTerminal] Stall watchdog failed to fetch diagnostics", error);
5124
+ logRecoveryEvent("diagnostics-failed", {
5125
+ message: error instanceof Error ? error.message : String(error),
5199
5126
  });
5200
- switch (decision.action) {
5201
- case "replay": {
5202
- const sent = requestAgentSubscriptionReplay(agentIdAtMount);
5203
- if (!sent) {
5204
- tryReconnect(state, "agent-recovery-replay-no-socket");
5205
- break;
5206
- }
5207
- const serverLastSeq = diagnostics.currentSession?.lastDelivery?.lastSeq ??
5208
- diagnostics.transport?.lastSeq ??
5209
- null;
5210
- const replayCallbackId = decision.replayKey?.split("|").pop() ?? null;
5211
- state.lastReplayKey = decision.replayKey;
5212
- state.lastReplayAt = now;
5213
- state.lastReplayVerifyDeadlineAt = now + 4_000;
5214
- state.lastReplayVerifySnapshot = {
5215
- localSeq: snapshot.lastSeq,
5216
- serverLastSeq,
5217
- pendingDialogCallbackId: replayCallbackId === "none" ? null : replayCallbackId,
5218
- };
5219
- clearVerifyTimer();
5220
- replayVerifyTimeoutRef.current = setTimeout(() => {
5221
- replayVerifyTimeoutRef.current = null;
5222
- if (disposed)
5223
- return;
5224
- const latestSnapshot = runDiagnosticsSnapshotRef.current ?? snapshot;
5225
- const latestDiagnostics = lastDiagnosticsResponseRef.current;
5226
- const verified = checkReplayVerified(state, latestSnapshot, latestDiagnostics);
5227
- state.lastReplayVerifyDeadlineAt = 0;
5228
- state.lastReplayVerifySnapshot = null;
5229
- logRecoveryEvent(verified ? "replay-succeeded" : "replay-failed", { kind: "one-shot" });
5230
- if (verified) {
5231
- setShowStaleAgentBanner(false);
5232
- }
5233
- // Do not auto-escalate from here; the next poll will see the failed verify
5234
- // and ask decideRecoveryAction for the next step (typically reconnect).
5235
- }, 4_000);
5236
- logRecoveryEvent("replay-started", {
5237
- replayKey: decision.replayKey,
5238
- summaryCode: summary.code,
5239
- serverLastSeq,
5240
- localSeq: snapshot.lastSeq,
5241
- });
5242
- break;
5243
- }
5244
- case "reconnect": {
5245
- tryReconnect(state, "agent-recovery-escalation");
5246
- break;
5247
- }
5248
- case "stale": {
5249
- setShowStaleAgentBanner(true);
5250
- logRecoveryEvent("stale", {
5251
- summaryCode: summary.code,
5252
- reasonCode: decision.reasonCode,
5253
- });
5254
- break;
5255
- }
5256
- case "none":
5257
- break;
5258
- }
5127
+ // Reset so we don't refire diagnostics every 5s while the call keeps failing.
5128
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5129
+ return;
5130
+ }
5131
+ if (disposed || agentIdAtMount !== currentAgentId) {
5132
+ return;
5133
+ }
5134
+ const backendSeq = diagnostics.currentSession?.lastDelivery?.lastSeq ??
5135
+ diagnostics.transport?.lastSeq ??
5136
+ null;
5137
+ const localSeq = lastSeqRef.current;
5138
+ if (!shouldReloadAfterDiagnostics({ localSeq, backendSeq })) {
5139
+ // Backend is at or behind us — silence is something else (idle, server lag).
5140
+ // Suppress the watchdog for another full window instead of refiring every 5s.
5141
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5142
+ return;
5143
+ }
5144
+ const sinceLastReload = Date.now() - lastStallReloadAtRef.current;
5145
+ if (lastStallReloadAtRef.current > 0 && sinceLastReload < 30_000) {
5146
+ logRecoveryEvent("reload-cooldown", { sinceLastReload });
5147
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5148
+ return;
5149
+ }
5150
+ lastStallReloadAtRef.current = Date.now();
5151
+ logRecoveryEvent("reload-fired", { backendSeq, localSeq });
5152
+ lastSeqRef.current = 0;
5153
+ seenMessageIdsRef.current.clear();
5154
+ try {
5155
+ await loadAgent();
5259
5156
  }
5260
5157
  catch (error) {
5261
- console.warn("[AgentTerminal] Failed to reconcile agent run status", error);
5158
+ console.warn("[AgentTerminal] Stall reload loadAgent() failed", error);
5159
+ }
5160
+ if (disposed || agentIdAtMount !== currentAgentId) {
5161
+ return;
5262
5162
  }
5163
+ forceEditorSocketReconnect("agent-stream-stalled");
5164
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5263
5165
  };
5264
- // Avoid racing a freshly submitted run before the backend has registered it.
5265
- const timeoutId = window.setTimeout(checkServerCompletion, 10_000);
5266
- const intervalId = window.setInterval(checkServerCompletion, 15_000);
5166
+ const intervalId = window.setInterval(() => {
5167
+ void tick();
5168
+ }, 5_000);
5267
5169
  return () => {
5268
5170
  disposed = true;
5269
- window.clearTimeout(timeoutId);
5270
5171
  window.clearInterval(intervalId);
5271
- clearVerifyTimer();
5272
5172
  };
5273
5173
  }, [
5274
5174
  currentAgentId,
5275
5175
  editContext?.sessionId,
5276
- editContext?.socketConnectionVersion,
5277
- editContext?.socketDiagnostics,
5278
5176
  effectiveIsVisible,
5279
5177
  isExecuting,
5178
+ lastRunStatusReason,
5280
5179
  appendToolUiEvent,
5281
- clearHeartbeatMessages,
5282
- runDiagnosticsSnapshot,
5283
- settleCompletedRun,
5180
+ loadAgent,
5284
5181
  ]);
5285
5182
  const showInitialThinkingSplash = messages.length === 0 &&
5286
5183
  !error &&
@@ -5439,33 +5336,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5439
5336
  "Waiting for capacity. The agent will start automatically when a slot becomes available.";
5440
5337
  return _jsx(AgentCapacityBanner, { message: message });
5441
5338
  };
5442
- const renderStaleAgentBanner = () => {
5443
- if (!showStaleAgentBanner || !currentAgentId)
5444
- return null;
5445
- const handleRefreshStream = () => {
5446
- const state = recoveryStateByAgentRef.current.get(currentAgentId);
5447
- if (state) {
5448
- state.lastReplayKey = null;
5449
- state.lastReplayAt = 0;
5450
- }
5451
- requestAgentSubscriptionReplay(currentAgentId);
5452
- void loadAgent();
5453
- setShowStaleAgentBanner(false);
5454
- };
5455
- const handleReconnect = () => {
5456
- const state = recoveryStateByAgentRef.current.get(currentAgentId);
5457
- if (state) {
5458
- state.lastReconnectAt = 0;
5459
- }
5460
- forceEditorSocketReconnect("user-stale-agent-action");
5461
- setShowStaleAgentBanner(false);
5462
- };
5463
- const handleCancel = () => {
5464
- void handleStop();
5465
- setShowStaleAgentBanner(false);
5466
- };
5467
- return (_jsxs("div", { role: "status", className: "m-3 rounded border border-amber-300 bg-amber-50 p-3 text-[12px] text-amber-900", children: [_jsx("p", { className: "font-medium", children: "Agent run looks stuck" }), _jsx("p", { className: "mt-1 text-[11px] text-amber-800", children: "The backend reports this run is still active, but recent recovery attempts did not resolve the stream. Pick an action below \u2014 automatic retries are paused until then." }), _jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [_jsx(Button, { size: "sm", variant: "outline", onClick: handleRefreshStream, children: "Refresh stream" }), _jsx(Button, { size: "sm", variant: "outline", onClick: handleReconnect, children: "Reconnect socket" }), _jsx(Button, { size: "sm", variant: "outline", onClick: handleCancel, children: "Cancel agent" })] })] }));
5468
- };
5469
5339
  const renderBrowserClaimBanner = (variant = "inline") => {
5470
5340
  if (!agent?.id || !editContext?.sessionId)
5471
5341
  return null;
@@ -5589,7 +5459,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5589
5459
  ? getOperationsForMessageGroup(summaryMessages, agentOperations)
5590
5460
  : [];
5591
5461
  return (_jsxs("div", { className: `flex h-full min-h-0 flex-col ${className || ""}`, children: [fixedBrowserClaimBanner, readOnlyAccessNotice, error &&
5592
- !isAgentErrorStatusValue((agent || agentStub)?.status) && (_jsx("div", { className: "m-4 rounded-lg border-l-4 border-red-500 bg-red-50 p-3 select-text", children: _jsxs("div", { className: "flex items-start", children: [_jsx(AlertCircle, { className: "mt-0.5 h-5 w-5 text-red-400", strokeWidth: 1 }), _jsxs("div", { className: "ml-3", children: [_jsx("p", { className: "text-[11px] font-medium text-red-800", children: "Error" }), _jsx("p", { className: "mt-1 text-[11px] text-red-700", children: error })] })] }) })), renderCapacityBanner(), renderErrorBanner(), renderStaleAgentBanner(), _jsxs("div", { ref: messagesContainerRef, className: "flex-1 overflow-y-auto", onScroll: handleScroll, children: [showInitialThinkingSplash && (_jsx(InitialThinkingSplash, { svgIcon: activeProfile?.svgIcon })), inlineBrowserClaimBanner, inlineDialog ? (inlineDialog) : latestSummaryAssistantGroup ? (_jsx("div", { className: "space-y-0 divide-y divide-gray-100 select-text", children: _jsx(AiResponseMessage, { messages: summaryMessages, finished: !latestSummaryAssistantGroup.isLastGroup || !isExecuting, editOperations: summaryOperations, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5462
+ !isAgentErrorStatusValue((agent || agentStub)?.status) && (_jsx("div", { className: "m-4 rounded-lg border-l-4 border-red-500 bg-red-50 p-3 select-text", children: _jsxs("div", { className: "flex items-start", children: [_jsx(AlertCircle, { className: "mt-0.5 h-5 w-5 text-red-400", strokeWidth: 1 }), _jsxs("div", { className: "ml-3", children: [_jsx("p", { className: "text-[11px] font-medium text-red-800", children: "Error" }), _jsx("p", { className: "mt-1 text-[11px] text-red-700", children: error })] })] }) })), renderCapacityBanner(), renderErrorBanner(), _jsxs("div", { ref: messagesContainerRef, className: "flex-1 overflow-y-auto", onScroll: handleScroll, children: [showInitialThinkingSplash && (_jsx(InitialThinkingSplash, { svgIcon: activeProfile?.svgIcon })), inlineBrowserClaimBanner, inlineDialog ? (inlineDialog) : latestSummaryAssistantGroup ? (_jsx("div", { className: "space-y-0 divide-y divide-gray-100 select-text", children: _jsx(AiResponseMessage, { messages: summaryMessages, finished: !latestSummaryAssistantGroup.isLastGroup || !isExecuting, editOperations: summaryOperations, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5593
5463
  activeProfile?.displayTitle ||
5594
5464
  activeProfile?.name, allPendingApprovals: allPendingApprovals, onSwitchToAutonomous: handleSwitchToAutonomous, browserCaptureInlinePrompt: browserCaptureInlinePrompt, readOnly: readOnly, onQuickAction: (action) => {
5595
5465
  const text = (action.prompt ||
@@ -5951,9 +5821,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5951
5821
  const placeholderShowsOwnButtons = hideBottomControls && isInPlaceholderMode;
5952
5822
  if (placeholderShowsOwnButtons)
5953
5823
  return null;
5954
- return (_jsxs("div", { className: cn("mt-2 flex items-stretch gap-2", hideBottomControls || simpleMode || isInPlaceholderMode
5955
- ? "justify-end"
5956
- : "justify-between"), children: [!hideBottomControls && !simpleMode && !isInPlaceholderMode && (_jsxs("div", { className: "flex flex-wrap items-center justify-start gap-2", children: [readOnly && (_jsx("span", { className: "shrink-0 rounded bg-gray-100 px-1 py-0.5 text-[10px] text-gray-600", title: "You have View-only access to this agent.", "data-testid": "agent-read-only-badge", children: "Read-only" })), _jsx(Select, { "data-testid": "agent-mode-selector", size: "xs", maxWidth: 240, disabled: readOnly, className: cn("h-5 w-auto min-w-[95px] rounded border px-1.5 text-[11px] font-normal", mode === "read-only"
5824
+ const showConfigControls = (showAgentConfigControls ?? !hideBottomControls) &&
5825
+ !simpleMode &&
5826
+ !isInPlaceholderMode;
5827
+ return (_jsxs("div", { className: cn("mt-2 flex items-stretch gap-2", showConfigControls ? "justify-between" : "justify-end"), children: [showConfigControls && (_jsxs("div", { className: "flex flex-wrap items-center justify-start gap-2", children: [readOnly && (_jsx("span", { className: "shrink-0 rounded bg-gray-100 px-1 py-0.5 text-[10px] text-gray-600", title: "You have View-only access to this agent.", "data-testid": "agent-read-only-badge", children: "Read-only" })), _jsx(Select, { "data-testid": "agent-mode-selector", size: "xs", maxWidth: 240, disabled: readOnly, className: cn("h-5 w-auto min-w-[95px] rounded border px-1.5 text-[11px] font-normal", mode === "read-only"
5957
5828
  ? "border-green-300 bg-green-50! text-green-700 hover:bg-green-100!"
5958
5829
  : mode === "supervised"
5959
5830
  ? "border-amber-300 bg-amber-50! text-amber-700 hover:bg-amber-100!"
@@ -6212,7 +6083,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
6212
6083
  : allPendingApprovals.length > 0
6213
6084
  ? "Approve or reject pending tool calls first"
6214
6085
  : "Send", "aria-label": isExecuting ? "Stop" : "Send", "data-testid": "agent-send-stop-button", "data-executing": isExecuting ? "true" : "false", children: isExecuting ? (_jsx(Square, { className: "size-3", strokeWidth: 1 })) : (_jsx(Send, { className: "size-3", strokeWidth: 1 })) })] })] }));
6215
- })(), !hideBottomControls &&
6086
+ })(), (showStatusBar ?? !hideBottomControls) &&
6216
6087
  !simpleMode &&
6217
6088
  editContext &&
6218
6089
  !editContext.isMobile && (_jsx(AgentTerminalStatusBar, { agent: agent, contextWindowStatus: contextWindowStatus, effectiveModelName: effectiveModelName, socketDiagnostics: editContext.socketDiagnostics, runDiagnosticsSnapshot: runDiagnosticsSnapshot, liveTotals: liveTotals, totalTokens: liveTotals