From 7efb9bd50ca0589772128e198b1f35025e8d3723 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 26 Jul 2026 14:04:45 -0700 Subject: [PATCH 1/2] fix(web): error instead of silently failing when browsing an unreachable environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add-project browser resolves paths on the selected environment's filesystem, but never read the browse query's error. Against an environment the client cannot reach, the result is a silent dead end: the directory list renders an empty "Directories" group that is indistinguishable from an empty directory, no reason is shown anywhere, and the submit button stays enabled — pressing Enter then does nothing at all, with no feedback. Add `resolveBrowseAvailability`, which maps the environment's connection phase plus any browse error onto an explicit availability verdict, and use it to fail closed: - the browse RPC is not issued unless the environment is connected; - `canSubmitBrowsePath` is false when unavailable, disabling the Enter path, the submit button, and the clone-destination step. This also withholds the "create this folder and add it as a project" affordance, so the UI can never offer to create a directory on a host it failed to inspect; - the reason renders in the results empty state instead of a blank list. `handleAddProjectForEnvironment` also refuses with an explicit toast when the target environment is not connected, which covers the desktop folder-picker entry point that bypasses the browse UI entirely. The composer's @-path mention search dropped its error the same way, so an unreachable environment rendered "No matching files or folders" — a definitive-sounding answer to a question we never got to ask. It now surfaces the actual failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/CommandPalette.logic.test.ts | 120 ++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 62 +++++++++ apps/web/src/components/CommandPalette.tsx | 72 +++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 12 +- 4 files changed, 248 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..a955bcf86e9 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,9 +5,129 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + resolveBrowseAvailability, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("resolveBrowseAvailability", () => { + it("allows browsing a connected environment", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "workstation", + connectionPhase: "connected", + connectionError: null, + browseError: null, + }), + ).toEqual({ _tag: "Available" }); + }); + + it("blocks browsing an environment that is not connected", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "workstation", + connectionPhase: "available", + connectionError: null, + browseError: null, + }), + ).toEqual({ + _tag: "Unavailable", + message: "workstation isn't connected, so its files can't be browsed.", + }); + }); + + it("blocks browsing an offline environment", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "workstation", + connectionPhase: "offline", + connectionError: null, + browseError: null, + }), + ).toEqual({ + _tag: "Unavailable", + message: "workstation is offline, so its files can't be browsed.", + }); + }); + + it("surfaces the connection failure reason when the environment is unreachable", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "remote-box", + connectionPhase: "error", + connectionError: "ssh: connect: host unreachable", + browseError: null, + }), + ).toEqual({ + _tag: "Unavailable", + message: "Can't reach remote-box. Reason: ssh: connect: host unreachable", + }); + }); + + it("reports a bare unreachable message when no reason is available", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "remote-box", + connectionPhase: "error", + connectionError: null, + browseError: null, + }), + ).toEqual({ _tag: "Unavailable", message: "Can't reach remote-box." }); + }); + + it("reports transient reconnects without claiming the host is gone", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "remote-box", + connectionPhase: "reconnecting", + connectionError: "socket closed", + browseError: null, + }), + ).toEqual({ + _tag: "Unavailable", + message: "Reconnecting to remote-box. Reason: socket closed", + }); + }); + + it("surfaces a browse failure on an otherwise connected environment", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: "remote-box", + connectionPhase: "connected", + connectionError: null, + browseError: "EACCES: permission denied", + }), + ).toEqual({ + _tag: "Unavailable", + message: "Can't browse remote-box. Reason: EACCES: permission denied", + }); + }); + + it("falls back to a generic label when the environment has no label", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: null, + connectionPhase: "offline", + connectionError: null, + browseError: null, + }), + ).toEqual({ + _tag: "Unavailable", + message: "this environment is offline, so its files can't be browsed.", + }); + }); + + it("blocks browsing when no environment is selected", () => { + expect( + resolveBrowseAvailability({ + environmentLabel: null, + connectionPhase: null, + connectionError: null, + browseError: null, + }), + ).toEqual({ _tag: "Unavailable", message: "Select an environment to browse." }); + }); +}); + describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { const items = Array.from({ length: 10 }, (_, index) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..b0c5b7cf0c7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,3 +1,4 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { type KeybindingCommand, type FilesystemBrowseEntry, @@ -296,6 +297,67 @@ export function filterCommandPaletteGroups(input: { }); } +/** + * Whether the filesystem behind the add-project browser can actually be + * reached right now. Browsing resolves paths on the *selected environment*, so + * an unreachable environment must fail loudly: without this the browse query + * just returns nothing, the directory list renders empty, and the palette + * cheerfully offers to "Create & Add" a folder on a host we never contacted. + */ +export type BrowseAvailability = + | { readonly _tag: "Available" } + | { readonly _tag: "Unavailable"; readonly message: string }; + +const AVAILABLE_BROWSE: BrowseAvailability = { _tag: "Available" }; + +function unavailable(message: string): BrowseAvailability { + return { _tag: "Unavailable", message }; +} + +export function resolveBrowseAvailability(input: { + readonly environmentLabel: string | null; + readonly connectionPhase: EnvironmentConnectionPhase | null; + readonly connectionError: string | null; + readonly browseError: string | null; +}): BrowseAvailability { + const label = input.environmentLabel ?? "this environment"; + + if (input.connectionPhase === null) { + return unavailable("Select an environment to browse."); + } + + switch (input.connectionPhase) { + case "connecting": + return unavailable(`Connecting to ${label}...`); + case "reconnecting": + return unavailable( + input.connectionError + ? `Reconnecting to ${label}. Reason: ${input.connectionError}` + : `Reconnecting to ${label}...`, + ); + case "offline": + return unavailable(`${label} is offline, so its files can't be browsed.`); + case "available": + return unavailable(`${label} isn't connected, so its files can't be browsed.`); + case "error": + return unavailable( + input.connectionError + ? `Can't reach ${label}. Reason: ${input.connectionError}` + : `Can't reach ${label}.`, + ); + case "connected": + break; + } + + // Connected, but the browse RPC itself failed (permission denied, path + // resolution error, environment wedged mid-request). + if (input.browseError !== null) { + return unavailable(`Can't browse ${label}. Reason: ${input.browseError}`); + } + + return AVAILABLE_BROWSE; +} + export function buildBrowseGroups(input: { browseEntries: ReadonlyArray; browseQuery: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..dd05135d347 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -44,6 +44,8 @@ import { } from "react"; import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; + import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -102,6 +104,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resolveBrowseAvailability, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -715,10 +718,16 @@ function OpenCommandPaletteDialog(props: { const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const browseFilterQuery = isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + // Browsing resolves paths on the selected environment's filesystem. Don't + // issue the request at all unless that environment is actually connected — + // otherwise the failure is indistinguishable from an empty directory. + const browseEnvironmentConnection = browseEnvironment?.connection ?? null; + const isBrowseEnvironmentConnected = browseEnvironmentConnection?.phase === "connected"; const browseQuery = useEnvironmentQuery( isBrowsing && browseDirectoryPath.length > 0 && browseEnvironmentId !== null && + isBrowseEnvironmentConnected && !relativePathNeedsActiveProject ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, @@ -731,6 +740,14 @@ function OpenCommandPaletteDialog(props: { ); const browseResult = browseQuery.data; const isBrowsePending = browseQuery.isPending; + const browseAvailability = resolveBrowseAvailability({ + environmentLabel: browseEnvironment?.label ?? null, + connectionPhase: browseEnvironmentConnection?.phase ?? null, + connectionError: browseEnvironmentConnection?.error ?? null, + browseError: browseQuery.error, + }); + const browseUnavailableMessage = + isBrowsing && browseAvailability._tag === "Unavailable" ? browseAvailability.message : null; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), @@ -1271,6 +1288,23 @@ function OpenCommandPaletteDialog(props: { }) => { const rawCwd = input.rawCwd; + // The path is resolved on the target environment, so refuse outright when + // that environment is unreachable rather than dispatching an add that can + // only fail — or silently create a directory we never verified. + const targetEnvironment = + environments.find((environment) => environment.environmentId === input.environmentId) ?? + null; + if (targetEnvironment !== null && targetEnvironment.connection.phase !== "connected") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to add project", + description: connectionStatusText(targetEnvironment.connection), + }), + ); + return; + } + if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { toastManager.add( stackedThreadToast({ @@ -1603,9 +1637,13 @@ function OpenCommandPaletteDialog(props: { if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; } else if (addProjectCloneFlow?.step === "confirm") { - displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; + displayedGroups = + relativePathNeedsActiveProject || browseUnavailableMessage !== null + ? [] + : cloneDestinationBrowseGroups; } else if (isBrowsing) { - displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; + displayedGroups = + relativePathNeedsActiveProject || browseUnavailableMessage !== null ? [] : browseGroups; } const inputPlaceholder = @@ -1613,7 +1651,10 @@ function OpenCommandPaletteDialog(props: { getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; - const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; + // An unreachable environment can't confirm whether the path exists, so never + // offer to add — or worse, create — a directory we were unable to inspect. + const canSubmitBrowsePath = + isBrowsing && !relativePathNeedsActiveProject && browseUnavailableMessage === null; const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && @@ -1966,13 +2007,14 @@ function OpenCommandPaletteDialog(props: { aria-label={`${submitActionLabel} (${addShortcutLabel})`} disabled={ relativePathNeedsActiveProject || + browseUnavailableMessage !== null || (isCloneDestinationStep && isRemoteProjectPending) } onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { - if (relativePathNeedsActiveProject) { + if (relativePathNeedsActiveProject || browseUnavailableMessage !== null) { return; } if (isCloneDestinationStep) { @@ -2029,16 +2071,18 @@ function OpenCommandPaletteDialog(props: { ? "Enter a Git clone URL and press Enter to continue." : "Enter a repository path and press Enter to look it up.", } - : addProjectCloneFlow?.step === "confirm" - ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } - : relativePathNeedsActiveProject - ? { emptyStateMessage: "Relative paths require an active project." } - : willCreateProjectPath - ? { - emptyStateMessage: - "Press Enter to create this folder and add it as a project.", - } - : {})} + : browseUnavailableMessage !== null + ? { emptyStateMessage: browseUnavailableMessage } + : addProjectCloneFlow?.step === "confirm" + ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } + : relativePathNeedsActiveProject + ? { emptyStateMessage: "Relative paths require an active project." } + : willCreateProjectPath + ? { + emptyStateMessage: + "Press Enter to create this folder and add it as a project.", + } + : {})} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..fe12c91ce64 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1156,10 +1156,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } - return composerTriggerKind === "path" - ? "No matching files or folders." - : "No matching command."; - }, [composerTriggerKind]); + if (composerTriggerKind === "path") { + // Path search runs against the thread's environment. A failed lookup is + // not the same as "this workspace has no such file" -- say so instead of + // rendering an empty menu that reads like a definitive answer. + return workspaceEntries.error ?? "No matching files or folders."; + } + return "No matching command."; + }, [composerTriggerKind, workspaceEntries.error]); // ------------------------------------------------------------------ // Provider traits UI From fc2294e7d4cae644350ede93fb656a29b31a511a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 26 Jul 2026 15:03:31 -0700 Subject: [PATCH 2/2] fix(web): key browse availability on reachability only, never on browse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a regression in the previous commit: treating any browse error as Unavailable made canSubmitBrowsePath false, which killed Create & Add on a perfectly reachable host. The server returns read_directory_failed whenever readdir on the parent fails, and only EACCES/EPERM are swallowed to an empty listing -- ENOENT propagates. So typing a folder that does not exist yet, which is precisely what Create & Add exists for, produced a browse error and the new gate refused to submit. Verified end to end: on a connected environment "~/brand-new-folder-xyz/" now shows an enabled "Create & Add", creates the directory, and adds the project. Availability is now derived from the connection phase alone. A browse failure on a reachable host says nothing about whether the environment can be reached, so it no longer participates. Also drop connectionStatusText from the add-project guard. It renders the bare phase word, so refusing an add against a non-connected environment produced "Failed to add project — Available". The toast now reuses the same availability verdict as the browser and reads " isn't connected." --- .../components/CommandPalette.logic.test.ts | 53 +++++++------------ .../src/components/CommandPalette.logic.ts | 48 +++++++---------- apps/web/src/components/CommandPalette.tsx | 18 ++++--- 3 files changed, 49 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index a955bcf86e9..07e679f6520 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -16,7 +16,20 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "workstation", connectionPhase: "connected", connectionError: null, - browseError: null, + }), + ).toEqual({ _tag: "Available" }); + }); + + it("stays available on a connected environment even when browse fails", () => { + // A browse failure on a reachable host is not an availability problem. The + // server returns read_directory_failed for a missing parent, which is the + // "type a new folder name and press Enter to create it" case -- gating on + // it would make Create & Add unable to ever create anything. + expect( + resolveBrowseAvailability({ + environmentLabel: "workstation", + connectionPhase: "connected", + connectionError: "read_directory_failed", }), ).toEqual({ _tag: "Available" }); }); @@ -27,12 +40,8 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "workstation", connectionPhase: "available", connectionError: null, - browseError: null, }), - ).toEqual({ - _tag: "Unavailable", - message: "workstation isn't connected, so its files can't be browsed.", - }); + ).toEqual({ _tag: "Unavailable", message: "workstation isn't connected." }); }); it("blocks browsing an offline environment", () => { @@ -41,12 +50,8 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "workstation", connectionPhase: "offline", connectionError: null, - browseError: null, }), - ).toEqual({ - _tag: "Unavailable", - message: "workstation is offline, so its files can't be browsed.", - }); + ).toEqual({ _tag: "Unavailable", message: "workstation is offline." }); }); it("surfaces the connection failure reason when the environment is unreachable", () => { @@ -55,7 +60,6 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "remote-box", connectionPhase: "error", connectionError: "ssh: connect: host unreachable", - browseError: null, }), ).toEqual({ _tag: "Unavailable", @@ -69,7 +73,6 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "remote-box", connectionPhase: "error", connectionError: null, - browseError: null, }), ).toEqual({ _tag: "Unavailable", message: "Can't reach remote-box." }); }); @@ -80,7 +83,6 @@ describe("resolveBrowseAvailability", () => { environmentLabel: "remote-box", connectionPhase: "reconnecting", connectionError: "socket closed", - browseError: null, }), ).toEqual({ _tag: "Unavailable", @@ -88,32 +90,14 @@ describe("resolveBrowseAvailability", () => { }); }); - it("surfaces a browse failure on an otherwise connected environment", () => { - expect( - resolveBrowseAvailability({ - environmentLabel: "remote-box", - connectionPhase: "connected", - connectionError: null, - browseError: "EACCES: permission denied", - }), - ).toEqual({ - _tag: "Unavailable", - message: "Can't browse remote-box. Reason: EACCES: permission denied", - }); - }); - it("falls back to a generic label when the environment has no label", () => { expect( resolveBrowseAvailability({ environmentLabel: null, connectionPhase: "offline", connectionError: null, - browseError: null, }), - ).toEqual({ - _tag: "Unavailable", - message: "this environment is offline, so its files can't be browsed.", - }); + ).toEqual({ _tag: "Unavailable", message: "this environment is offline." }); }); it("blocks browsing when no environment is selected", () => { @@ -122,9 +106,8 @@ describe("resolveBrowseAvailability", () => { environmentLabel: null, connectionPhase: null, connectionError: null, - browseError: null, }), - ).toEqual({ _tag: "Unavailable", message: "Select an environment to browse." }); + ).toEqual({ _tag: "Unavailable", message: "Select an environment first." }); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index b0c5b7cf0c7..ecfa7c6c8dc 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -298,11 +298,16 @@ export function filterCommandPaletteGroups(input: { } /** - * Whether the filesystem behind the add-project browser can actually be - * reached right now. Browsing resolves paths on the *selected environment*, so - * an unreachable environment must fail loudly: without this the browse query - * just returns nothing, the directory list renders empty, and the palette - * cheerfully offers to "Create & Add" a folder on a host we never contacted. + * Whether the *selected environment* can be reached right now. Browsing + * resolves paths on that environment's filesystem, so an unreachable one must + * fail loudly: otherwise the browse query returns nothing and the empty + * directory list is indistinguishable from a real empty directory. + * + * Deliberately keyed on reachability ONLY, never on a browse failure. Browse + * legitimately fails on a reachable host when the path does not exist yet -- + * the server surfaces `read_directory_failed` for a missing parent, which is + * exactly the "type a new folder name and press Enter to create it" case. If + * that blocked submission, Create & Add could never create anything. */ export type BrowseAvailability = | { readonly _tag: "Available" } @@ -318,44 +323,29 @@ export function resolveBrowseAvailability(input: { readonly environmentLabel: string | null; readonly connectionPhase: EnvironmentConnectionPhase | null; readonly connectionError: string | null; - readonly browseError: string | null; }): BrowseAvailability { const label = input.environmentLabel ?? "this environment"; + const withReason = (text: string) => + input.connectionError ? `${text} Reason: ${input.connectionError}` : text; if (input.connectionPhase === null) { - return unavailable("Select an environment to browse."); + return unavailable("Select an environment first."); } switch (input.connectionPhase) { + case "connected": + return AVAILABLE_BROWSE; case "connecting": return unavailable(`Connecting to ${label}...`); case "reconnecting": - return unavailable( - input.connectionError - ? `Reconnecting to ${label}. Reason: ${input.connectionError}` - : `Reconnecting to ${label}...`, - ); + return unavailable(withReason(`Reconnecting to ${label}.`)); case "offline": - return unavailable(`${label} is offline, so its files can't be browsed.`); + return unavailable(`${label} is offline.`); case "available": - return unavailable(`${label} isn't connected, so its files can't be browsed.`); + return unavailable(`${label} isn't connected.`); case "error": - return unavailable( - input.connectionError - ? `Can't reach ${label}. Reason: ${input.connectionError}` - : `Can't reach ${label}.`, - ); - case "connected": - break; + return unavailable(withReason(`Can't reach ${label}.`)); } - - // Connected, but the browse RPC itself failed (permission denied, path - // resolution error, environment wedged mid-request). - if (input.browseError !== null) { - return unavailable(`Can't browse ${label}. Reason: ${input.browseError}`); - } - - return AVAILABLE_BROWSE; } export function buildBrowseGroups(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index dd05135d347..4e15610f4d7 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -44,8 +44,6 @@ import { } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; - import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -744,7 +742,6 @@ function OpenCommandPaletteDialog(props: { environmentLabel: browseEnvironment?.label ?? null, connectionPhase: browseEnvironmentConnection?.phase ?? null, connectionError: browseEnvironmentConnection?.error ?? null, - browseError: browseQuery.error, }); const browseUnavailableMessage = isBrowsing && browseAvailability._tag === "Unavailable" ? browseAvailability.message : null; @@ -1290,16 +1287,25 @@ function OpenCommandPaletteDialog(props: { // The path is resolved on the target environment, so refuse outright when // that environment is unreachable rather than dispatching an add that can - // only fail — or silently create a directory we never verified. + // only fail. Reuse the browse verdict so the toast states the reason + // (" isn't connected.") rather than a bare status word. const targetEnvironment = environments.find((environment) => environment.environmentId === input.environmentId) ?? null; - if (targetEnvironment !== null && targetEnvironment.connection.phase !== "connected") { + const targetAvailability = + targetEnvironment === null + ? null + : resolveBrowseAvailability({ + environmentLabel: targetEnvironment.label, + connectionPhase: targetEnvironment.connection.phase, + connectionError: targetEnvironment.connection.error, + }); + if (targetAvailability?._tag === "Unavailable") { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to add project", - description: connectionStatusText(targetEnvironment.connection), + description: targetAvailability.message, }), ); return;