From b44a1b5c2791ae9b34ff2f6a322ae36e8d0b988b Mon Sep 17 00:00:00 2001 From: Arafatkatze Date: Mon, 16 Mar 2026 14:48:43 -0700 Subject: [PATCH] Add manual project-path fallback when picker is unavailable --- AGENTS.md | 3 +- .../src/hooks/use-project-navigation.test.ts | 43 ++++++++++++++ web-ui/src/hooks/use-project-navigation.ts | 56 +++++++++++++++++-- 3 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 web-ui/src/hooks/use-project-navigation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7065150..4bbe958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,4 +86,5 @@ Dark theme - Do NOT use Blueprint, Tailwind's light-mode defaults, or any `dark:` prefix. The theme is always dark. Misc. tribal knowledge -- Kanban is launched from the user's shell and inherits its environment. For agent detection and task-agent startup, prefer direct PATH checks and direct process launches over spawning an interactive shell. Avoid `zsh -i`, shell fallback command discovery, or "launch shell then type command into it" on hot paths. On setups with heavy shell init like `conda` or `nvm`, doing that per task can freeze the runtime and even make new Terminal.app windows feel hung when several tasks start at once. It's fine to use an actual interactive shell for explicit shell terminals, not for normal agent session work. \ No newline at end of file +- Kanban is launched from the user's shell and inherits its environment. For agent detection and task-agent startup, prefer direct PATH checks and direct process launches over spawning an interactive shell. Avoid `zsh -i`, shell fallback command discovery, or "launch shell then type command into it" on hot paths. On setups with heavy shell init like `conda` or `nvm`, doing that per task can freeze the runtime and even make new Terminal.app windows feel hung when several tasks start at once. It's fine to use an actual interactive shell for explicit shell terminals, not for normal agent session work. +- When Kanban runs on a headless remote Linux instance (for example over SSH+tunnel), native folder picker commands may be unavailable (`zenity`/`kdialog`). Treat this as a normal remote-runtime limitation and use manual path entry fallback instead of requiring desktop packages. diff --git a/web-ui/src/hooks/use-project-navigation.test.ts b/web-ui/src/hooks/use-project-navigation.test.ts new file mode 100644 index 0000000..e304da5 --- /dev/null +++ b/web-ui/src/hooks/use-project-navigation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + isDirectoryPickerUnavailableErrorMessage, + parseRemovedProjectPathFromStreamError, +} from "@/hooks/use-project-navigation"; + +describe("parseRemovedProjectPathFromStreamError", () => { + it("extracts removed project paths", () => { + expect( + parseRemovedProjectPathFromStreamError("Project no longer exists on disk and was removed: /tmp/project"), + ).toBe("/tmp/project"); + }); + + it("returns null when prefix is not present", () => { + expect(parseRemovedProjectPathFromStreamError("Something else happened")).toBeNull(); + }); +}); + +describe("isDirectoryPickerUnavailableErrorMessage", () => { + it("detects headless Linux picker failures", () => { + expect( + isDirectoryPickerUnavailableErrorMessage( + 'Could not open directory picker. Install "zenity" or "kdialog" and try again.', + ), + ).toBe(true); + }); + + it("detects other platform picker-unavailable errors", () => { + expect( + isDirectoryPickerUnavailableErrorMessage( + 'Could not open directory picker. Install PowerShell ("powershell" or "pwsh") and try again.', + ), + ).toBe(true); + expect( + isDirectoryPickerUnavailableErrorMessage('Could not open directory picker. Command "osascript" is not available.'), + ).toBe(true); + }); + + it("does not treat cancellation as unavailable", () => { + expect(isDirectoryPickerUnavailableErrorMessage("No directory was selected.")).toBe(false); + }); +}); diff --git a/web-ui/src/hooks/use-project-navigation.ts b/web-ui/src/hooks/use-project-navigation.ts index 5437594..4ea28ea 100644 --- a/web-ui/src/hooks/use-project-navigation.ts +++ b/web-ui/src/hooks/use-project-navigation.ts @@ -7,6 +7,14 @@ import { useRuntimeStateStream } from "@/runtime/use-runtime-state-stream"; import { useWindowEvent } from "@/utils/react-use"; const REMOVED_PROJECT_ERROR_PREFIX = "Project no longer exists on disk and was removed:"; +const DIRECTORY_PICKER_UNAVAILABLE_MARKERS = [ + "could not open directory picker", + 'install "zenity" or "kdialog"', + 'install powershell ("powershell" or "pwsh")', + 'command "osascript" is not available', +] as const; +const MANUAL_PROJECT_PATH_PROMPT_MESSAGE = + "Kanban could not open a directory picker on this runtime. Enter a project path to add:"; export function parseRemovedProjectPathFromStreamError(streamError: string | null): string | null { if (!streamError || !streamError.startsWith(REMOVED_PROJECT_ERROR_PREFIX)) { @@ -15,6 +23,29 @@ export function parseRemovedProjectPathFromStreamError(streamError: string | nul return streamError.slice(REMOVED_PROJECT_ERROR_PREFIX.length).trim(); } +export function isDirectoryPickerUnavailableErrorMessage(message: string | null | undefined): boolean { + if (!message) { + return false; + } + const normalized = message.trim().toLowerCase(); + if (!normalized) { + return false; + } + return DIRECTORY_PICKER_UNAVAILABLE_MARKERS.some((marker) => normalized.includes(marker)); +} + +function promptForManualProjectPath(): string | null { + if (typeof window === "undefined") { + return null; + } + const rawValue = window.prompt(MANUAL_PROJECT_PATH_PROMPT_MESSAGE); + if (rawValue === null) { + return null; + } + const normalized = rawValue.trim(); + return normalized || null; +} + interface UseProjectNavigationInput { onProjectSwitchStart: () => void; } @@ -81,14 +112,31 @@ export function useProjectNavigation({ try { const trpcClient = getRuntimeTrpcClient(currentProjectId); const picked = await trpcClient.projects.pickDirectory.mutate(); - if (!picked.ok || !picked.path) { - if (picked?.error && picked.error !== "No directory was selected.") { - throw new Error(picked.error); + + let projectPath: string | null = null; + if (picked.ok && picked.path) { + projectPath = picked.path; + } else if (!picked.ok && picked.error === "No directory was selected.") { + return; + } else if (!picked.ok && isDirectoryPickerUnavailableErrorMessage(picked.error)) { + showAppToast({ + intent: "warning", + icon: "warning-sign", + message: "Directory picker unavailable on this runtime. Enter the project path manually.", + timeout: 5000, + }); + projectPath = promptForManualProjectPath(); + if (!projectPath) { + return; } + } else { + throw new Error(picked.error ?? "Could not pick project directory."); + } + if (!projectPath) { return; } - const added = await trpcClient.projects.add.mutate({ path: picked.path }); + const added = await trpcClient.projects.add.mutate({ path: projectPath }); if (!added.ok || !added.project) { throw new Error(added.error ?? "Could not add project."); }