Skip to content
Open
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
103 changes: 103 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,112 @@ 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,
}),
).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" });
});

it("blocks browsing an environment that is not connected", () => {
expect(
resolveBrowseAvailability({
environmentLabel: "workstation",
connectionPhase: "available",
connectionError: null,
}),
).toEqual({ _tag: "Unavailable", message: "workstation isn't connected." });
});

it("blocks browsing an offline environment", () => {
expect(
resolveBrowseAvailability({
environmentLabel: "workstation",
connectionPhase: "offline",
connectionError: null,
}),
).toEqual({ _tag: "Unavailable", message: "workstation is offline." });
});

it("surfaces the connection failure reason when the environment is unreachable", () => {
expect(
resolveBrowseAvailability({
environmentLabel: "remote-box",
connectionPhase: "error",
connectionError: "ssh: connect: host unreachable",
}),
).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,
}),
).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",
}),
).toEqual({
_tag: "Unavailable",
message: "Reconnecting to remote-box. Reason: socket closed",
});
});

it("falls back to a generic label when the environment has no label", () => {
expect(
resolveBrowseAvailability({
environmentLabel: null,
connectionPhase: "offline",
connectionError: null,
}),
).toEqual({ _tag: "Unavailable", message: "this environment is offline." });
});

it("blocks browsing when no environment is selected", () => {
expect(
resolveBrowseAvailability({
environmentLabel: null,
connectionPhase: null,
connectionError: null,
}),
).toEqual({ _tag: "Unavailable", message: "Select an environment first." });
});
});

describe("enumerateCommandPaletteItems", () => {
it("assigns positional jump shortcuts to the first nine displayed items", () => {
const items = Array.from({ length: 10 }, (_, index) => ({
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import {
type KeybindingCommand,
type FilesystemBrowseEntry,
Expand Down Expand Up @@ -296,6 +297,57 @@ export function filterCommandPaletteGroups(input: {
});
}

/**
* 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" }
| { 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;
}): 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 first.");
}

switch (input.connectionPhase) {
case "connected":
return AVAILABLE_BROWSE;
case "connecting":
return unavailable(`Connecting to ${label}...`);
case "reconnecting":
return unavailable(withReason(`Reconnecting to ${label}.`));
case "offline":
return unavailable(`${label} is offline.`);
case "available":
return unavailable(`${label} isn't connected.`);
case "error":
return unavailable(withReason(`Can't reach ${label}.`));
}
}

export function buildBrowseGroups(input: {
browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
browseQuery: string;
Expand Down
78 changes: 64 additions & 14 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
getCommandPaletteMode,
ITEM_ICON_CLASS,
RECENT_THREAD_LIMIT,
resolveBrowseAvailability,
} from "./CommandPalette.logic";
import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
Expand Down Expand Up @@ -715,10 +716,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,
Expand All @@ -731,6 +738,13 @@ 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,
});
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 }),
Expand Down Expand Up @@ -1271,6 +1285,32 @@ 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. Reuse the browse verdict so the toast states the reason
// ("<env> isn't connected.") rather than a bare status word.
const targetEnvironment =
environments.find((environment) => environment.environmentId === input.environmentId) ??
null;
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: targetAvailability.message,
}),
);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing env skips refuse guard

Low Severity

When environments.find misses the target id, targetAvailability stays null and the new refuse path never runs, so add continues instead of failing closed. resolveBrowseAvailability already maps a null phase to an Unavailable verdict with a clear message, but that branch is skipped here.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc2294e. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Guard blocks opening existing projects

Medium Severity

The new availability refuse runs before findProjectByPath, so an unreachable environment blocks reopening a project that already exists locally. That hits the desktop folder-picker path, which stays enabled while browse submit is disabled, and turns a local open into a Failed to add project toast.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc2294e. Configure here.


if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) {
toastManager.add(
stackedThreadToast({
Expand Down Expand Up @@ -1603,17 +1643,24 @@ 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 =
remoteProjectInputPlaceholder(addProjectCloneFlow) ??
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 &&
Expand Down Expand Up @@ -1966,13 +2013,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) {
Expand Down Expand Up @@ -2029,16 +2077,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.",
}
: {})}
/>
</CommandPanel>
<CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading