diff --git a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx index fb04e530193..c99ce16fbb0 100644 --- a/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx +++ b/src/browser/components/ArchivedWorkspaces/ArchivedWorkspaces.tsx @@ -8,7 +8,7 @@ import type { WorkspaceRemovalDescendant, } from "@/common/types/workspace"; import { getErrorMessage } from "@/common/utils/errors"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; @@ -334,6 +334,7 @@ export const ArchivedWorkspaces: React.FC = ({ const { unarchiveWorkspace, removeWorkspace, setSelectedWorkspace } = useWorkspaceContext(); const { api } = useAPI(); + const liveApi = useLiveAPI(); const [searchQuery, setSearchQuery] = React.useState(""); const [processingIds, setProcessingIds] = React.useState>(new Set()); const [deleteWorktreeIds, setDeleteWorktreeIds] = React.useState>(new Set()); @@ -626,7 +627,7 @@ export const ArchivedWorkspaces: React.FC = ({ }; const handleBulkDeleteWorktree = async () => { - if (!api) { + if (!liveApi) { return; } @@ -654,7 +655,7 @@ export const ArchivedWorkspaces: React.FC = ({ setDeleteWorktreeIds((prev) => new Set(prev).add(id)); try { - const result = await api.workspace.deleteWorktree({ workspaceId: id }); + const result = await liveApi.workspace.deleteWorktree({ workspaceId: id }); if (!result.success) { setBulkOperation((prev) => prev @@ -701,7 +702,7 @@ export const ArchivedWorkspaces: React.FC = ({ const rect = anchorEl?.getBoundingClientRect(); const anchor = rect ? { top: rect.top + window.scrollY, left: rect.right + 10 } : undefined; - if (!api) { + if (!liveApi) { deleteWorktreeError.showError(workspaceId, "Not connected to server", anchor); return; } @@ -709,7 +710,7 @@ export const ArchivedWorkspaces: React.FC = ({ setProcessingIds((prev) => new Set(prev).add(workspaceId)); setDeleteWorktreeIds((prev) => new Set(prev).add(workspaceId)); try { - const result = await api.workspace.deleteWorktree({ workspaceId }); + const result = await liveApi.workspace.deleteWorktree({ workspaceId }); if (result.success) { onWorkspacesChanged?.(); return; diff --git a/src/browser/components/DirectoryPickerModal/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal/DirectoryPickerModal.tsx index f3168383103..bf04ba2134b 100644 --- a/src/browser/components/DirectoryPickerModal/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal/DirectoryPickerModal.tsx @@ -12,7 +12,7 @@ import { Checkbox } from "../Checkbox/Checkbox"; import { Input } from "@/browser/components/Input/Input"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { DirectoryTree } from "../DirectoryTree/DirectoryTree"; -import { useAPI } from "@/browser/contexts/API"; +import { useLiveAPI } from "@/browser/contexts/API"; import { formatKeybind, isMac } from "@/browser/utils/ui/keybinds"; import { getErrorMessage } from "@/common/utils/errors"; @@ -31,7 +31,7 @@ export const DirectoryPickerModal: React.FC = ({ onClose, onSelectPath, }) => { - const { api } = useAPI(); + const liveApi = useLiveAPI(); const [root, setRoot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -45,7 +45,7 @@ export const DirectoryPickerModal: React.FC = ({ const loadDirectory = useCallback( async (path: string) => { - if (!api) { + if (!liveApi) { setError("Not connected to server"); return; } @@ -54,7 +54,7 @@ export const DirectoryPickerModal: React.FC = ({ setCanCreateFolder(false); try { - const result = await api.general.listDirectory({ path }); + const result = await liveApi.general.listDirectory({ path }); if (!result.success) { const errorMessage = typeof result.error === "string" ? result.error : "Unknown error"; @@ -82,7 +82,7 @@ export const DirectoryPickerModal: React.FC = ({ setIsLoading(false); } }, - [api] + [liveApi] ); // Sync pathInput with initialPath when modal opens (component stays mounted) @@ -110,13 +110,16 @@ export const DirectoryPickerModal: React.FC = ({ // If user has typed a different path, try to load it first if (trimmedInput && trimmedInput !== root?.path) { - if (!api) return; + if (!liveApi) { + setError("Not connected to server"); + return; + } setIsLoading(true); setError(null); setCanCreateFolder(false); try { - const result = await api.general.listDirectory({ path: trimmedInput }); + const result = await liveApi.general.listDirectory({ path: trimmedInput }); if (!result.success) { const errorMessage = typeof result.error === "string" ? result.error : "Unknown error"; const isNotFound = @@ -150,17 +153,21 @@ export const DirectoryPickerModal: React.FC = ({ onSelectPath(root.path); onClose(); - }, [onClose, onSelectPath, root, pathInput, api]); + }, [onClose, onSelectPath, root, pathInput, liveApi]); const handleCreateFolder = useCallback(async () => { const trimmedPath = pathInput.trim(); - if (!trimmedPath || !api) return; + if (!trimmedPath) return; + if (!liveApi) { + setError("Not connected to server"); + return; + } setIsLoading(true); setError(null); try { - const createResult = await api.general.createDirectory({ path: trimmedPath }); + const createResult = await liveApi.general.createDirectory({ path: trimmedPath }); if (!createResult.success) { setError(createResult.error ?? "Failed to create folder"); setCanCreateFolder(false); @@ -176,7 +183,7 @@ export const DirectoryPickerModal: React.FC = ({ } finally { setIsLoading(false); } - }, [pathInput, api, loadDirectory]); + }, [pathInput, liveApi, loadDirectory]); const handleOpenChange = useCallback( (open: boolean) => { diff --git a/src/browser/components/LegacyPlanImportBanner/LegacyPlanImportBanner.tsx b/src/browser/components/LegacyPlanImportBanner/LegacyPlanImportBanner.tsx index 32883834637..89a5a5f5b57 100644 --- a/src/browser/components/LegacyPlanImportBanner/LegacyPlanImportBanner.tsx +++ b/src/browser/components/LegacyPlanImportBanner/LegacyPlanImportBanner.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { FileInput, Loader2 } from "lucide-react"; import { ChatDockSurface } from "@/browser/components/ChatPane/chatDockColumn"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { useOptionalCommandRegistry } from "@/browser/contexts/CommandRegistryContext"; import { useOptionalWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; import { refetchPostCompactionState } from "@/browser/hooks/usePostCompactionState"; @@ -26,7 +26,7 @@ export interface LegacyPlanImportNoticeProps { * The backend copies it (never replacing an existing plan) and leaves the legacy file in place. */ export const LegacyPlanImportNotice: React.FC = (props) => { - const { api } = useAPI(); + const liveApi = useLiveAPI(); const registerSource = useOptionalCommandRegistry()?.registerSource; const [importing, setImporting] = useState(false); const [error, setError] = useState(null); @@ -39,7 +39,7 @@ export const LegacyPlanImportNotice: React.FC = (pr const importPlan = async () => { if (inFlightRef.current) return; - if (!api) { + if (!liveApi) { setError("Not connected to the backend"); return; } @@ -47,7 +47,7 @@ export const LegacyPlanImportNotice: React.FC = (pr setImporting(true); setError(null); try { - const result = await api.workspace.importLegacyPlan({ workspaceId: props.workspaceId }); + const result = await liveApi.workspace.importLegacyPlan({ workspaceId: props.workspaceId }); if (!result.success) { setError(result.error); } else if (result.data.status === "nothing_to_import") { diff --git a/src/browser/components/ProjectCreateModal/ProjectCreateModal.cloneAbort.test.tsx b/src/browser/components/ProjectCreateModal/ProjectCreateModal.cloneAbort.test.tsx index 9e84fcc891f..5025de5e05a 100644 --- a/src/browser/components/ProjectCreateModal/ProjectCreateModal.cloneAbort.test.tsx +++ b/src/browser/components/ProjectCreateModal/ProjectCreateModal.cloneAbort.test.tsx @@ -7,6 +7,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { installDom } from "../../../../tests/ui/dom"; +import { createConnectionHarness, GAP_MODES } from "@/browser/contexts/connection.testHarness"; let cleanupDom: (() => void) | null = null; let currentClientMock: TestApiOverrides = {}; @@ -126,4 +127,43 @@ describe("ProjectAddForm", () => { await waitFor(() => expect(receivedSignal?.aborted).toBe(true)); await waitFor(() => expect(onIsCreatingChange).toHaveBeenCalledWith(false)); }); + + test.each([...GAP_MODES])( + "adding the existing clone while offline says it is not connected (%s)", + async (mode) => { + const createProject = mock(() => Promise.resolve({ success: false as const, error: "x" })); + const harness = createConnectionHarness(mode, () => ({ + projects: { + getDefaultProjectDir: () => Promise.resolve("/tmp"), + create: createProject, + clone: () => + Promise.resolve( + (async function* () { + await Promise.resolve(); + yield { + type: "error" as const, + code: "destination_exists" as const, + error: "Destination already exists", + normalizedPath: "/tmp/repo", + }; + })() + ), + }, + })); + const view = render( undefined} />, { + wrapper: harness.Wrapper, + }); + fireEvent.click(view.getByText("Clone repo")); + const repoInput = view.getByPlaceholderText("owner/repo or https://github.com/..."); + const user = userEvent.setup({ document: repoInput.ownerDocument }); + await user.type(repoInput, "owner/repo"); + fireEvent.click(view.getByText("Clone Project")); + const addButton = await view.findByText("Add this project instead"); + + await harness.drop(); + fireEvent.click(addButton); + await view.findByText("Not connected to server"); + expect(createProject).not.toHaveBeenCalled(); + } + ); }); diff --git a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx index 66463f819e1..7c284246a0c 100644 --- a/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx @@ -15,7 +15,7 @@ import { ToggleGroupItem, } from "@/browser/components/ToggleGroupPrimitive/ToggleGroupPrimitive"; import type { ProjectConfig } from "@/node/config"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; type ApiClient = ReturnType["api"]; @@ -125,6 +125,7 @@ export const ProjectCreateForm = React.forwardRef { @@ -401,6 +412,7 @@ function processTerminalOutput(raw: string): string { const ProjectCloneForm = React.forwardRef( function ProjectCloneForm(props, ref) { const { api } = useAPI(); + const liveApi = useLiveAPI(); const [repoUrl, setRepoUrl] = useState(""); const [cloneParentDir, setCloneParentDir] = useState(props.defaultProjectDir); const [hasEditedCloneParentDir, setHasEditedCloneParentDir] = useState(false); @@ -520,7 +532,7 @@ const ProjectCloneForm = React.forwardRef { - if (!api || !destinationExistsPath) { + if (!destinationExistsPath) { + return; + } + // The button stays enabled while offline: say why the click does nothing. + if (!liveApi) { + setError("Not connected to server"); return; } @@ -602,7 +619,7 @@ const ProjectCloneForm = React.forwardRef { setError(""); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index fac8a743759..2557dcbd038 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -42,7 +42,7 @@ import { isEditableElement, KEYBINDS, } from "@/browser/utils/ui/keybinds"; -import { useAPI, useLiveAPI } from "@/browser/contexts/API"; +import { useLiveAPI } from "@/browser/contexts/API"; import { CUSTOM_EVENTS, getStorageChangeEvent, @@ -767,7 +767,7 @@ const ProjectSidebarInner: React.FC = ({ useWorkspaceAttentionSubscription(sortedWorkspacesByProject, workspaceStore); const runtimeStatusStore = useRuntimeStatusStoreRaw(); const { navigateToProject, isAnalyticsOpen } = useRouter(); - const { api } = useAPI(); + const liveApi = useLiveAPI(); const { confirm: confirmDialog } = useConfirmDialog(); const settings = useSettings(); // The settings and analytics modals cover the chat without changing the route's workspace; @@ -1100,19 +1100,19 @@ const ProjectSidebarInner: React.FC = ({ const handleForkWorkspace = useCallback( async (workspaceId: string, buttonElement?: HTMLElement) => { const anchor = getErrorAnchor(buttonElement); - const error = await forkWorkspaceFromMenu(api, workspaceId); + const error = await forkWorkspaceFromMenu(liveApi, workspaceId); if (error) workspaceForkError.showError(workspaceId, error, anchor); }, - [api, workspaceForkError] + [liveApi, workspaceForkError] ); const handleStopRuntime = useCallback( async (workspaceId: string, buttonElement?: HTMLElement) => { const anchor = getErrorAnchor(buttonElement); - const error = await stopWorkspaceRuntime(api, runtimeStatusStore, workspaceId); + const error = await stopWorkspaceRuntime(liveApi, runtimeStatusStore, workspaceId); if (error) workspaceStopRuntimeError.showError(workspaceId, error, anchor); }, - [api, runtimeStatusStore, workspaceStopRuntimeError] + [liveApi, runtimeStatusStore, workspaceStopRuntimeError] ); useWorkspaceActionKeybinds({ diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx index 0874845de03..b1bd538bda0 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.test.tsx @@ -192,6 +192,10 @@ function installWorkspaceMenuBarTestDoubles() { spyOn(APIModule, "useAPI").mockImplementation( () => ({ api: mockApi }) as unknown as ReturnType ); + // Handlers read the live client; the same double stands for a live connection. + spyOn(APIModule, "useLiveAPI").mockImplementation( + () => mockApi as ReturnType + ); spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 6fee5112adf..7a284fd5a53 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -67,7 +67,7 @@ import { ArchiveIcon } from "../icons/ArchiveIcon/ArchiveIcon"; import { SkillIndicator } from "../SkillIndicator/SkillIndicator"; import { WorkspaceLinks } from "../WorkspaceLinks/WorkspaceLinks"; -import { useAPI } from "@/browser/contexts/API"; +import { useLiveAPI } from "@/browser/contexts/API"; import { useWorkspaceActions, useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; @@ -151,7 +151,7 @@ export const WorkspaceMenuBar: React.FC = ({ onOpenSidebar, sidebarFullscreen = false, }) => { - const { api } = useAPI(); + const liveApi = useLiveAPI(); const { preflightArchiveWorkspace, archiveWorkspace, archivingWorkspaceIds, setWorkspacePinned } = useWorkspaceActions(); const isArchiving = archivingWorkspaceIds.has(workspaceId); @@ -484,12 +484,12 @@ export const WorkspaceMenuBar: React.FC = ({ const handleForkWorkspace = async (anchorEl: HTMLElement) => { const anchor = getErrorAnchor(anchorEl); - const error = await forkWorkspaceFromMenu(api, workspaceId); + const error = await forkWorkspaceFromMenu(liveApi, workspaceId); if (error) forkError.showError(workspaceId, error, anchor); }; const handleStopRuntime = async () => { - const error = await stopWorkspaceRuntime(api, runtimeStatusStore, workspaceId); + const error = await stopWorkspaceRuntime(liveApi, runtimeStatusStore, workspaceId); if (error) { stopRuntimeError.showError(workspaceId, error, getErrorAnchor(moreActionsButtonRef.current)); } diff --git a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingDialog.tsx b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingDialog.tsx index b25a4279406..b1743cde1c8 100644 --- a/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingDialog.tsx +++ b/src/browser/components/WorkspaceUnrelatedMessagingModal/WorkspaceUnrelatedMessagingDialog.tsx @@ -1,4 +1,4 @@ -import { useAPI } from "@/browser/contexts/API"; +import { useLiveAPI } from "@/browser/contexts/API"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isLocalProjectRuntime, @@ -21,7 +21,8 @@ interface WorkspaceUnrelatedMessagingDialogProps { /** The "Messages from other workspaces" dialog wired to the backend, for both menus (#6202). */ export function WorkspaceUnrelatedMessagingDialog(props: WorkspaceUnrelatedMessagingDialogProps) { - const { api } = useAPI(); + // The live client only: offline the saves return "Not connected to server". + const api = useLiveAPI(); const { workspaceId } = props; // Mirrors TaskService.isLocalUnrelatedMessagingEndpoint: unrelated delivery requires local or // worktree runtimes on both endpoints, so remote/container workspaces get no consent switch — diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index e5a7592e6a6..b9b45549ccb 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -29,7 +29,7 @@ import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useAgent } from "@/browser/contexts/AgentContext"; import { ThinkingSelector } from "@/browser/components/ThinkingSelector/ThinkingSelector"; -import { useAPI, type APIClient } from "@/browser/contexts/API"; +import { useAPI, type APIClient, useLiveAPI } from "@/browser/contexts/API"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { normalizeSelectedModel } from "@/common/utils/ai/models"; import { @@ -313,6 +313,7 @@ interface EditSession { const ChatInputInner: React.FC = (props) => { const { api } = useAPI(); + const liveApi = useLiveAPI(); const { variant } = props; // A /side chat composer is mounted next to the main one. Window shortcuts act only in the pane // that owns the focused element, and unscoped palette events (open model selector, voice, toasts) @@ -638,7 +639,7 @@ const ChatInputInner: React.FC = (props) => { onSend: () => void handleSend(), isTranscriptionAvailable, useRecordingKeybinds: true, - api, + api: liveApi, }); const voiceInputUnavailableMessage = @@ -1898,7 +1899,7 @@ const ChatInputInner: React.FC = (props) => { // Without an API client (backend reconnecting) hydration cannot finish, and waiting would // hold this send in flight and fire it after reconnect; let the send path report // "Not connected to server" instead. - if (!api || reviewStateStore.isHydrated(workspaceId)) return renderTime; + if (!liveApi || reviewStateStore.isHydrated(workspaceId)) return renderTime; const attached = await reviewStateStore.readAttachedReviewsForSend(workspaceId); if (attached === null) { // The render-time notes may be a stale cache; send none rather than a possibly wrong set. @@ -1986,7 +1987,8 @@ const ChatInputInner: React.FC = (props) => { const commandFileParts = chatAttachmentsToFileParts(attachments, { validate: true }); const asyncCommandToken = ++asyncCommandTokenRef.current; const commandEnv: SlashCommandEnv = { - api, + // Commands report "Not connected to server" themselves when there is no live client. + api: liveApi, aiScopeId: sendOptionsScopeId, variant, workspaceId: commandWorkspaceId, @@ -2312,7 +2314,7 @@ const ChatInputInner: React.FC = (props) => { messageText, agentSkillDescriptors, mcpPromptDescriptors, - api, + api: liveApi, discovery: skillDiscovery, signal: resolutionSignal, }); @@ -2327,7 +2329,7 @@ const ChatInputInner: React.FC = (props) => { messageText, slashInvocation: resolution.skillInvocation, agentSkillDescriptors, - api, + api: liveApi, discovery: skillDiscovery, candidates: inlineReferenceCandidates, }), @@ -2335,7 +2337,7 @@ const ChatInputInner: React.FC = (props) => { messageText, slashInvocation: resolution.mcpPromptInvocation, descriptors: mcpPromptDescriptors, - api, + api: liveApi, discovery: skillDiscovery, candidates: inlineReferenceCandidates, signal: resolutionSignal, @@ -2411,7 +2413,7 @@ const ChatInputInner: React.FC = (props) => { let creationOptionsOverride: Partial | undefined; if (skillInvocation) { - if (!api) { + if (!liveApi) { pushToast({ type: "error", message: "Not connected to server" }); return; } @@ -2529,7 +2531,7 @@ const ChatInputInner: React.FC = (props) => { mcpPromptInvocation?.userText ?? messageText; - if (!api) { + if (!liveApi) { pushToast({ type: "error", message: "Not connected to server" }); return; } @@ -2540,7 +2542,11 @@ const ChatInputInner: React.FC = (props) => { let sendAttachments = attachments; const pendingFilesForSend = getPendingFileAttachments(attachments); if (pendingFilesForSend.length > 0) { - const stagingOutcome = await stagePendingFiles(api, props.workspaceId, pendingFilesForSend); + const stagingOutcome = await stagePendingFiles( + liveApi, + props.workspaceId, + pendingFilesForSend + ); if (stagingOutcome.staged.length > 0) { sendAttachments = replacePendingFilesWithStaged(attachments, stagingOutcome.staged); // Swap staged chips into the composer so a later failure or retry @@ -2692,7 +2698,7 @@ const ChatInputInner: React.FC = (props) => { metadata, sendOptions, } = prepareCompactionMessage({ - api, + api: liveApi, workspaceId: props.workspaceId, maxOutputTokens: parsed.maxOutputTokens, // Include current attachments + reviews in followUpContent so they're queued @@ -2875,10 +2881,10 @@ const ChatInputInner: React.FC = (props) => { props.onMessageSendStarted?.(overrides?.queueDispatchMode ?? "tool-end"); - let result: Awaited> | undefined; + let result: Awaited> | undefined; let thrown: unknown; try { - result = await api.workspace.sendMessage({ + result = await liveApi.workspace.sendMessage({ workspaceId: props.workspaceId, message: finalMessageText, options: { ...sendOptions, sendId }, @@ -2902,7 +2908,7 @@ const ChatInputInner: React.FC = (props) => { // draft entry.) if (outcome === "accepted" || outcome === "pending") accepted = true; } else if (!accepted) { - accepted = await isEditSendAccepted(api, props.workspaceId, sendId); + accepted = await isEditSendAccepted(liveApi, props.workspaceId, sendId); } if (!accepted) { diff --git a/src/browser/features/Messages/AssistantMessage.tsx b/src/browser/features/Messages/AssistantMessage.tsx index 89d98c67a5d..10780f1d179 100644 --- a/src/browser/features/Messages/AssistantMessage.tsx +++ b/src/browser/features/Messages/AssistantMessage.tsx @@ -13,7 +13,7 @@ import { Package, } from "lucide-react"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { Button } from "@/browser/components/Button/Button"; import { forkWorkspace } from "@/browser/utils/chatCommands"; import { @@ -50,6 +50,7 @@ export const AssistantMessage: React.FC = ({ }) => { const [showRaw, setShowRaw] = useState(false); const { api } = useAPI(); + const liveApi = useLiveAPI(); const forkError = usePopoverError(); const content = message.content; @@ -91,7 +92,7 @@ export const AssistantMessage: React.FC = ({ return; } - if (!api) { + if (!liveApi) { forkError.showError(message.historyId, "Not connected to server"); return; } @@ -106,7 +107,7 @@ export const AssistantMessage: React.FC = ({ // Response-level forks branch from this assistant turn instead of cloning the entire // transcript, so users can explore alternatives without carrying over later replies. const result = await forkWorkspace({ - client: api, + client: liveApi, sourceWorkspaceId: workspaceId, sourceMessageId: message.historyId, }); diff --git a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx index 97082bc6753..49fe34f0813 100644 --- a/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { AlertTriangle, RefreshCw } from "lucide-react"; import { useUserPreferences } from "@/browser/stores/AppConfigStore"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; import { getLastMainRetryCandidateMessage } from "@/common/utils/messages/retryEligibility"; import { @@ -149,6 +149,7 @@ const RetryButton: React.FC<{ disabled: boolean; onRetry: () => void }> = (props export const RetryBarrierContent: React.FC = (props) => { const { api } = useAPI(); + const liveApi = useLiveAPI(); const [countdown, setCountdown] = useState(0); const [manualRetryError, setManualRetryError] = useState(null); const [isManualRetrying, setIsManualRetrying] = useState(false); @@ -170,11 +171,12 @@ export const RetryBarrierContent: React.FC = (props) = const manualRetryRollbackPendingRef = useRef(false); const manualRetryRollbackArmedRef = useRef(false); const manualRetryRollbackBaselineMessageCountRef = useRef(null); - const apiRef = useRef(api); + // The rollback skips while offline, as it did when useAPI() went null in the gap. + const apiRef = useRef(liveApi); useEffect(() => { - apiRef.current = api; - }, [api]); + apiRef.current = liveApi; + }, [liveApi]); const rollbackManualRetryAutoRetryIfNeeded = useCallback( async (options?: { suppressErrors?: boolean }): Promise => { @@ -290,7 +292,7 @@ export const RetryBarrierContent: React.FC = (props) = } }, [isAutoRetryActive]); const handleManualRetry = async () => { - if (!api) { + if (!liveApi) { setManualRetryError("Not connected to server"); return; } @@ -327,7 +329,7 @@ export const RetryBarrierContent: React.FC = (props) = options = applyCompactionOverrides(options, lastUserMessage.compactionRequest.parsed); } - const enableResult = await api.workspace.setAutoRetryEnabled?.({ + const enableResult = await liveApi.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: true, persist: false, @@ -346,7 +348,7 @@ export const RetryBarrierContent: React.FC = (props) = manualRetryRollbackBaselineMessageCountRef.current = props.messages.length; } - const resumeResult = await api.workspace.resumeStream({ + const resumeResult = await liveApi.workspace.resumeStream({ workspaceId: props.workspaceId, options, }); diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index c7d8ae8b200..5999d1f6553 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { APIContext } from "@/browser/contexts/API"; +import { APIContext, liveClientOf } from "@/browser/contexts/API"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { cn } from "@/common/lib/utils"; import type { DisplayedMessage } from "@/common/types/message"; @@ -111,7 +111,8 @@ export const UserMessage: React.FC = ({ window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; const apiState = React.useContext(APIContext); - const api = apiState?.api ?? null; + // The live client only: offline, the download reports its own error. + const api = apiState == null ? null : liveClientOf(apiState); const workspaceContext = useOptionalWorkspaceContext(); // Prefer the transcript's own workspace: a /side chat transcript renders next to the routed // (selected) workspace, so the selection would download, cache-key, and guard against the wrong diff --git a/src/browser/features/RightSidebar/ArtifactsTab/ArtifactsReconnect.test.tsx b/src/browser/features/RightSidebar/ArtifactsTab/ArtifactsReconnect.test.tsx index 84777ede690..43103ca75f7 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/ArtifactsReconnect.test.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/ArtifactsReconnect.test.tsx @@ -418,6 +418,21 @@ describe.each([...GAP_MODES])("Artifacts tab across a dropped connection (%s)", await waitFor(() => expect(sends).toEqual([{ version: null }])); }); + test("Send while offline shows the reconnecting message and sends nothing", async () => { + const view = render(, { wrapper: ConnectionWrapper }); + const frame = (await view.findByTestId("artifact-frame")) as HTMLIFrameElement; + await flush(); + postFromFrame(frame, { xumArtifact: 1, type: "send", text: "Ship" }); + const sendButton = (await view.findByRole("button", { name: "Send" })) as HTMLButtonElement; + await waitFor(() => expect(sendButton.disabled).toBe(false), { + timeout: CONFIRM_ARM_DELAY_MS + 1000, + }); + await dropConnection(); + fireEvent.click(sendButton); + await view.findByText("Xum is reconnecting. Send again once it is back."); + expect(sends).toEqual([]); + }); + test("a canvas keeps its data through a refresh made while offline", async () => { files["data/s.json"] = ok("data/s.json", JSON.stringify({ series: [{ q: "Q1", v: 1 }] })); const content = JSON.stringify({ diff --git a/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.test.tsx b/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.test.tsx index b28a4354388..e976175a328 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.test.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; +import { createConnectionHarness, GAP_MODES } from "@/browser/contexts/connection.testHarness"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils"; @@ -40,8 +41,8 @@ let toolCalls: Array<{ consented: boolean; }> = []; -function Wrapper(props: { children: ReactNode }) { - const api: TestApiOverrides = { +function serverApi(): TestApiOverrides { + return { mcpApps: { getView: () => { return Promise.resolve({ @@ -94,9 +95,12 @@ function Wrapper(props: { children: ReactNode }) { }, }, }; +} + +function Wrapper(props: { children: ReactNode }) { return ( - {props.children} + {props.children} ); } @@ -176,6 +180,39 @@ describe("McpAppFrame", () => { cleanupDom = null; }); + // A failed approval dismisses the strip for good, so offline Allow waits for the next client. + test.each([...GAP_MODES])( + "Allow is disabled while offline and the prompt stays (%s)", + async (mode) => { + invocation = { serverName: "charts-raw", toolName: "show_chart", arguments: {} }; + viewCsp = { connectDomains: ["https://api.example.com"] }; + const harness = createConnectionHarness(mode, serverApi); + const view = render( + + + + + + ); + await view.findByTestId("mcp-app-frame"); + const allow = () => view.getByRole("button", { name: "Allow" }) as HTMLButtonElement; + await waitFor(() => expect(allow().disabled).toBe(false), { + timeout: CONFIRM_ARM_DELAY_MS + 1000, + }); + + await harness.drop(); + expect(allow().disabled).toBe(true); + harness.reconnect(); + await waitFor(() => expect(allow().disabled).toBe(false)); + await pressAllow(allow()); + await waitFor(() => + expect(approvals).toEqual([ + { serverName: "charts-raw", origins: ["https://api.example.com"] }, + ]) + ); + } + ); + test("declared domains run under the base policy until the user allows them", async () => { invocation = { serverName: "charts-raw", toolName: "show_chart", arguments: {} }; viewCsp = { diff --git a/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.tsx b/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.tsx index bff4404448b..6a7182a7b24 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/McpAppFrame.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { X } from "lucide-react"; import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { cn } from "@/common/lib/utils"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { useTheme } from "@/browser/contexts/ThemeContext"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; import { isLightThemeMode } from "@/browser/utils/highlighting/shiki-shared"; @@ -165,6 +165,11 @@ function inlineMaxHeight(): number { return Math.min(INLINE_MAX_HEIGHT, Math.round(window.innerHeight * 0.8)); } +/** Allow waits for the arming delay and for a live client (offline it would dismiss the prompt). */ +function isAllowDisabled(armed: boolean, liveApi: unknown): boolean { + return !armed || liveApi == null; +} + export function McpAppFrame(props: { workspaceId: string; view: McpAppViewRef; @@ -204,11 +209,13 @@ export function McpAppFrame(props: { const { toolCallId, serverName, resourceUri } = view; // The view's host lives as long as its frame. The API is null while Xum reconnects and a new // client afterwards; tearing the host down for either reset the view. So the host calls tools - // through the latest client, and a call made while offline fails. - const apiRef = useRef(api); + // through the latest live client, and a call made while offline fails with its own message + // (useAPI() can keep the last client while Xum reconnects). + const liveApi = useLiveAPI(); + const apiRef = useRef(liveApi); useEffect(() => { - apiRef.current = api; - }, [api]); + apiRef.current = liveApi; + }, [liveApi]); // Fetched once per tool call: a reconnect must not fetch it again (a failed refetch replaced a // working view with an error). A failed fetch is retried with the next client. const viewLoaded = loaded?.toolCallId === toolCallId && loaded.view != null; @@ -269,10 +276,11 @@ export function McpAppFrame(props: { const [domainsPromptId] = useState(newConfirmPromptId); const domainsArming = useConfirmArmed(domainsPrompt != null ? domainsPromptId : null); const allowDomains = async (origins: string[]) => { - if (!api) return; + // Offline the prompt stays, with Allow disabled: a failed call would dismiss it for good. + if (!liveApi) return; try { const requestSeq = beginMcpAppViewDomainsRequest(); - const result = await api.mcpApps.approveViewDomains({ + const result = await liveApi.mcpApps.approveViewDomains({ workspaceId, toolCallId, serverName: boundServerName, @@ -522,7 +530,7 @@ export function McpAppFrame(props: {