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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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.
43 changes: 43 additions & 0 deletions web-ui/src/hooks/use-project-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 52 additions & 4 deletions web-ui/src/hooks/use-project-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.");
}
Expand Down
Loading