Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
reconcileRetainedMountedThreadIds,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
shouldQuietlyRecoverManagedPrimaryEnvironment,
shouldShowBranchMismatchBanner,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
Expand All @@ -34,6 +35,49 @@ const projectId = ProjectId.make("project-1");
const threadId = ThreadId.make("thread-1");
const now = "2026-03-29T00:00:00.000Z";

describe("managed primary recovery", () => {
it.each(["connecting", "reconnecting"])(
"keeps the managed primary surface interactive while %s",
(connectionPhase) => {
expect(
shouldQuietlyRecoverManagedPrimaryEnvironment({
managed: true,
activeEnvironmentId: environmentId,
primaryEnvironmentId: environmentId,
connectionPhase,
}),
).toBe(true);
},
);

it("does not hide unavailable state for unmanaged, secondary, or failed environments", () => {
expect(
shouldQuietlyRecoverManagedPrimaryEnvironment({
managed: false,
activeEnvironmentId: environmentId,
primaryEnvironmentId: environmentId,
connectionPhase: "reconnecting",
}),
).toBe(false);
expect(
shouldQuietlyRecoverManagedPrimaryEnvironment({
managed: true,
activeEnvironmentId: environmentId,
primaryEnvironmentId: EnvironmentId.make("environment-primary"),
connectionPhase: "reconnecting",
}),
).toBe(false);
expect(
shouldQuietlyRecoverManagedPrimaryEnvironment({
managed: true,
activeEnvironmentId: environmentId,
primaryEnvironmentId: environmentId,
connectionPhase: "error",
}),
).toBe(false);
});
});

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: threadId,
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;

export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);

export function shouldQuietlyRecoverManagedPrimaryEnvironment(input: {
readonly managed: boolean;
readonly activeEnvironmentId: EnvironmentId | null;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly connectionPhase: string;
}): boolean {
return Boolean(
input.managed &&
input.activeEnvironmentId !== null &&
input.activeEnvironmentId === input.primaryEnvironmentId &&
["connecting", "reconnecting"].includes(input.connectionPhase),
);
}

export function resolveThreadMetadataUpdateForNextTurn(input: {
currentModelSelection: ModelSelection;
nextModelSelection?: ModelSelection;
Expand Down
42 changes: 28 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ import {
setActivePreviewTab,
useThreadPreviewState,
} from "../previewStateStore";
import { managedWorkspaceBrowserUrl } from "~/managedDevPc";
import { isManagedDevPc, managedWorkspaceBrowserUrl } from "~/managedDevPc";
import { addBrowserSurface } from "./preview/addBrowserSurface";
import { closePreviewSession } from "./preview/closePreviewSession";
import { subscribePreviewAction } from "./preview/previewActionBus";
Expand Down Expand Up @@ -269,6 +269,7 @@ import {
resolveSendEnvMode,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
shouldQuietlyRecoverManagedPrimaryEnvironment,
waitForStartedServerThread,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
Expand Down Expand Up @@ -1625,6 +1626,17 @@ function ChatViewContent(props: ChatViewProps) {
connection: activeEnvironment.connection,
};
}, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]);
const quietlyRecoveringManagedPrimary = shouldQuietlyRecoverManagedPrimaryEnvironment({
managed: isManagedDevPc,
activeEnvironmentId: activeEnvironment?.environmentId ?? null,
primaryEnvironmentId,
connectionPhase: activeEnvironmentConnectionPhase,
});
const activeEnvironmentActionUnavailable =
activeEnvironmentUnavailable && !quietlyRecoveringManagedPrimary;
const activeEnvironmentActionUnavailableState = quietlyRecoveringManagedPrimary
? null
: activeEnvironmentUnavailableState;
const handleReconnectActiveEnvironment = useCallback(
async (environmentId: EnvironmentId) => {
const result = await retryEnvironment(environmentId);
Expand Down Expand Up @@ -1838,13 +1850,13 @@ function ChatViewContent(props: ChatViewProps) {
const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
const items: ComposerBannerStackItem[] = [];
if (activeEnvironmentUnavailableState) {
const connection = activeEnvironmentUnavailableState.connection;
if (activeEnvironmentActionUnavailableState) {
const connection = activeEnvironmentActionUnavailableState.connection;
const isReconnecting =
connection.phase === "connecting" || connection.phase === "reconnecting";
if (isReconnecting) {
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
id: `environment-unavailable:${activeEnvironmentActionUnavailableState.environmentId}`,
variant: "info",
icon: <LoaderCircleIcon className="animate-spin" />,
title: connection.phase === "connecting" ? "Connecting…" : "Reconnecting…",
Expand All @@ -1853,10 +1865,10 @@ function ChatViewContent(props: ChatViewProps) {
});
} else {
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
id: `environment-unavailable:${activeEnvironmentActionUnavailableState.environmentId}`,
variant: connection.phase === "error" ? "error" : "warning",
icon: <WifiOffIcon />,
title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`,
title: `${activeEnvironmentActionUnavailableState.label}: ${connectionStatusTitle(connection)}`,
description:
connection.error ??
"Reconnect this environment before sending messages or running actions.",
Expand All @@ -1866,7 +1878,7 @@ function ChatViewContent(props: ChatViewProps) {
size="xs"
onClick={() =>
void handleReconnectActiveEnvironment(
activeEnvironmentUnavailableState.environmentId,
activeEnvironmentActionUnavailableState.environmentId,
)
}
>
Expand Down Expand Up @@ -1922,7 +1934,7 @@ function ChatViewContent(props: ChatViewProps) {
}
return items;
}, [
activeEnvironmentUnavailableState,
activeEnvironmentActionUnavailableState,
handleReconnectActiveEnvironment,
navigate,
setDismissedVersionMismatchKey,
Expand Down Expand Up @@ -4413,7 +4425,7 @@ function ChatViewContent(props: ChatViewProps) {
const localApi = readLocalApi();
if (!localApi || !activeThread || isRevertingCheckpoint) return;

if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
if (activeEnvironmentActionUnavailable && activeEnvironmentUnavailableLabel) {
setThreadError(
activeThread.id,
`Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
Expand Down Expand Up @@ -4455,7 +4467,7 @@ function ChatViewContent(props: ChatViewProps) {
},
[
activeThread,
activeEnvironmentUnavailable,
activeEnvironmentActionUnavailable,
activeEnvironmentUnavailableLabel,
environmentId,
isConnecting,
Expand All @@ -4473,7 +4485,7 @@ function ChatViewContent(props: ChatViewProps) {
!activeThread ||
isSendBusy ||
isConnecting ||
activeEnvironmentUnavailable ||
activeEnvironmentActionUnavailable ||
sendInFlightRef.current
)
return;
Expand Down Expand Up @@ -5060,6 +5072,7 @@ function ChatViewContent(props: ChatViewProps) {
!isServerThread ||
isSendBusy ||
isConnecting ||
activeEnvironmentActionUnavailable ||
sendInFlightRef.current
) {
return;
Expand Down Expand Up @@ -5202,6 +5215,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
activeEnvironmentActionUnavailable,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand All @@ -5227,7 +5241,7 @@ function ChatViewContent(props: ChatViewProps) {
!isServerThread ||
isSendBusy ||
isConnecting ||
activeEnvironmentUnavailable ||
activeEnvironmentActionUnavailable ||
sendInFlightRef.current
) {
return;
Expand Down Expand Up @@ -5364,7 +5378,7 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadBranch,
activeThread,
beginLocalDispatch,
activeEnvironmentUnavailable,
activeEnvironmentActionUnavailable,
createThread,
deleteThread,
isConnecting,
Expand Down Expand Up @@ -5848,7 +5862,7 @@ function ChatViewContent(props: ChatViewProps) {
isConnecting={isConnecting}
isSendBusy={isSendBusy}
isPreparingWorktree={isPreparingWorktree}
environmentUnavailable={activeEnvironmentUnavailableState}
environmentUnavailable={activeEnvironmentActionUnavailableState}
activePendingApproval={activePendingApproval}
pendingApprovals={pendingApprovals}
pendingUserInputs={pendingUserInputs}
Expand Down
4 changes: 2 additions & 2 deletions packages/client-runtime/src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type EnvironmentRpcFailure,
type EnvironmentRpcSuccess,
type EnvironmentRpcUnavailableError,
request,
requestWhenConnected,
} from "../rpc/client.ts";

type CommandType = ClientOrchestrationCommand["type"];
Expand Down Expand Up @@ -80,7 +80,7 @@ function timestampedCommandMetadata(input: {
}

function dispatch(command: ClientOrchestrationCommand) {
return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command);
return requestWhenConnected(ORCHESTRATION_WS_METHODS.dispatchCommand, command);
Comment on lines 82 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit deferred dispatch to managed recovery

When the primary environment is not a managed DevPC and is in connecting/backoff, this shared dispatch still calls requestWhenConnected, and dispatchSession waits for the next session for every PrimaryConnectionTarget. Sidebar/project mutations such as archive/delete/update call this helper without ChatView's managed-only guard, so disconnected local/unmanaged users now get actions that remain pending until a retry succeeds instead of the existing unavailable error. Please keep the deferred path behind the managed-primary recovery opt-in or use the immediate request for normal orchestration dispatches.

Useful? React with 👍 / 👎.

}

export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn(
Expand Down
45 changes: 44 additions & 1 deletion packages/client-runtime/src/rpc/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ import {
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as RpcSession from "../rpc/session.ts";
import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts";
import {
EnvironmentRpcRequestObserver,
request,
requestWhenConnected,
runStream,
subscribe,
} from "./client.ts";

const TARGET = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("environment-1"),
Expand Down Expand Up @@ -77,6 +83,43 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
});

describe("environment RPC", () => {
it.effect("defers a primary command until the reconnecting session is available", () =>
Effect.gen(function* () {
const requests: string[] = [];
const client = {
[WS_METHODS.cloudGetRelayClientStatus]: () =>
Effect.sync(() => {
requests.push("dispatched");
return { status: "available" as const, version: "2026.8.0" };
}),
} as unknown as WsRpcProtocolClient;
const { activeSession, supervisor } = yield* makeHarness();
const reconnectingState: SupervisorConnectionState = {
...AVAILABLE_CONNECTION_STATE,
desired: true,
phase: "backoff",
};
yield* SubscriptionRef.set(supervisor.state, reconnectingState);

const requestFiber = yield* requestWhenConnected(
WS_METHODS.cloudGetRelayClientStatus,
{},
).pipe(
Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
Effect.forkChild,
);
yield* Effect.yieldNow;
expect(requests).toEqual([]);

yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
expect(yield* Fiber.join(requestFiber)).toEqual({
status: "available",
version: "2026.8.0",
});
expect(requests).toEqual(["dispatched"]);
}),
);

it.effect("observes unary requests until they complete", () =>
Effect.gen(function* () {
const observations: string[] = [];
Expand Down
Loading
Loading