Skip to content
Merged
16 changes: 9 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import {
useServerConfig,
useServerKeybindings,
} from "~/rpc/serverState";
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";

const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
Expand Down Expand Up @@ -1599,17 +1600,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setThreadError = useCallback(
(targetThreadId: ThreadId | null, error: string | null) => {
if (!targetThreadId) return;
const nextError = sanitizeThreadErrorMessage(error);
if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {
setStoreThreadError(targetThreadId, error);
setStoreThreadError(targetThreadId, nextError);
return;
}
setLocalDraftErrorsByThreadId((existing) => {
if ((existing[targetThreadId] ?? null) === error) {
if ((existing[targetThreadId] ?? null) === nextError) {
return existing;
}
return {
...existing,
[targetThreadId]: error,
[targetThreadId]: nextError,
};
});
},
Expand Down Expand Up @@ -3153,14 +3155,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
createdAt: new Date().toISOString(),
})
.catch((err: unknown) => {
setStoreThreadError(
setThreadError(
activeThreadId,
err instanceof Error ? err.message : "Failed to submit approval decision.",
);
});
setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
[activeThreadId, setThreadError],
);

const onRespondToUserInput = useCallback(
Expand All @@ -3181,14 +3183,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
createdAt: new Date().toISOString(),
})
.catch((err: unknown) => {
setStoreThreadError(
setThreadError(
activeThreadId,
err instanceof Error ? err.message : "Failed to submit user input.",
);
});
setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
},
[activeThreadId, setStoreThreadError],
[activeThreadId, setThreadError],
);

const setActivePendingUserInputQuestionIndex = useCallback(
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/components/WebSocketConnectionSurface.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";

import type { WsConnectionStatus } from "../rpc/wsConnectionState";
import { shouldAutoReconnect } from "./WebSocketConnectionSurface";

function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionStatus {
return {
attemptCount: 0,
closeCode: null,
closeReason: null,
connectedAt: null,
disconnectedAt: null,
hasConnected: false,
lastError: null,
lastErrorAt: null,
nextRetryAt: null,
online: true,
phase: "idle",
reconnectAttemptCount: 0,
reconnectMaxAttempts: 8,
reconnectPhase: "idle",
socketUrl: null,
...overrides,
};
}

describe("WebSocketConnectionSurface.logic", () => {
it("forces reconnect on online when the app was offline", () => {
expect(
shouldAutoReconnect(
makeStatus({
disconnectedAt: "2026-04-03T20:00:00.000Z",
online: false,
phase: "disconnected",
}),
"online",
),
).toBe(true);
});

it("forces reconnect on focus only for previously connected disconnected states", () => {
expect(
shouldAutoReconnect(
makeStatus({
hasConnected: true,
online: true,
phase: "disconnected",
reconnectAttemptCount: 3,
reconnectPhase: "waiting",
}),
"focus",
),
).toBe(true);

expect(
shouldAutoReconnect(
makeStatus({
hasConnected: false,
online: true,
phase: "disconnected",
reconnectAttemptCount: 1,
reconnectPhase: "waiting",
}),
"focus",
),
).toBe(false);
});

it("forces reconnect on focus for exhausted reconnect loops", () => {
expect(
shouldAutoReconnect(
makeStatus({
hasConnected: true,
online: true,
phase: "disconnected",
reconnectAttemptCount: 8,
reconnectPhase: "exhausted",
}),
"focus",
),
).toBe(true);
});
});
Loading
Loading