feat: open task folders with host availability checks (#3784)

This commit is contained in:
José Almeida
2026-09-19 11:46:09 +01:00
committed by GitHub
parent d355a672b1
commit 32d640f264
49 changed files with 1826 additions and 64 deletions
@@ -0,0 +1,47 @@
package backendapp
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
editorcontroller "github.com/kandev/kandev/internal/editors/controller"
editormodels "github.com/kandev/kandev/internal/editors/models"
editorservice "github.com/kandev/kandev/internal/editors/service"
editorstore "github.com/kandev/kandev/internal/editors/store"
)
type bootEditorRepository struct{ editorstore.Repository }
func (bootEditorRepository) ListEditors(context.Context) ([]*editormodels.Editor, error) {
return nil, nil
}
func TestEditorsBootStateIncludesFolderCapability(t *testing.T) {
commands := map[string]string{"darwin": "open", "linux": "xdg-open", "windows": "explorer.exe"}
command := commands[runtime.GOOS]
if command == "" {
t.Skip("unsupported host platform")
}
for _, available := range []bool{false, true} {
t.Run(map[bool]string{false: "missing", true: "installed"}[available], func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
if available {
if err := os.WriteFile(filepath.Join(dir, command), []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil {
t.Fatal(err)
}
}
ctrl := editorcontroller.NewController(editorservice.NewService(bootEditorRepository{}, nil, nil))
builder := bootStateBuilder{p: routeParams{editorCtrl: ctrl}}
state := map[string]any{}
builder.addEditorsState(context.Background(), state)
editors := state["editors"].(map[string]any)
if editors["loaded"] != true || editors["folderOpeningAvailable"] != available {
t.Fatalf("editors boot state = %#v, want loaded with capability %v", editors, available)
}
})
}
}
@@ -441,9 +441,10 @@ func (b bootStateBuilder) addEditorsState(ctx context.Context, state map[string]
return
}
state["editors"] = map[string]any{
"items": response.Editors,
"loaded": true,
"loading": false,
"folderOpeningAvailable": response.FolderOpeningAvailable,
"items": response.Editors,
"loaded": true,
"loading": false,
}
}
@@ -52,7 +52,7 @@ func (c *Controller) ListEditors(ctx context.Context) (dto.EditorsResponse, erro
return result[i].Name < result[j].Name
})
}
return dto.EditorsResponse{Editors: result}, nil
return dto.EditorsResponse{Editors: result, FolderOpeningAvailable: service.FolderOpeningAvailable()}, nil
}
func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, req dto.OpenEditorRequest) (dto.OpenEditorResponse, error) {
+2 -1
View File
@@ -22,7 +22,8 @@ type EditorDTO struct {
}
type EditorsResponse struct {
Editors []EditorDTO `json:"editors"`
FolderOpeningAvailable bool `json:"folder_opening_available"`
Editors []EditorDTO `json:"editors"`
}
type OpenEditorRequest struct {
@@ -22,7 +22,7 @@ func serviceErrorStatus(err error) int {
return http.StatusNotFound
case errors.Is(err, service.ErrEditorConfigInvalid):
return http.StatusBadRequest
case errors.Is(err, service.ErrEditorUnavailable):
case errors.Is(err, service.ErrEditorUnavailable), errors.Is(err, service.ErrFolderUnavailable):
return http.StatusConflict
default:
return http.StatusInternalServerError
@@ -18,6 +18,7 @@ func TestServiceErrorStatus(t *testing.T) {
{service.ErrEditorNotFound, http.StatusNotFound},
{service.ErrEditorConfigInvalid, http.StatusBadRequest},
{service.ErrEditorUnavailable, http.StatusConflict},
{service.ErrFolderUnavailable, http.StatusConflict},
{fmt.Errorf("wrapped: %w", service.ErrWorkspaceNotFound), http.StatusNotFound},
{errors.New("something else"), http.StatusInternalServerError},
}
@@ -0,0 +1,32 @@
package service
import (
"errors"
"os/exec"
"runtime"
)
var ErrFolderUnavailable = errors.New("folder opener not available")
func folderOpenCommand(platform string) string {
switch platform {
case "darwin":
return "open"
case "linux":
return "xdg-open"
case "windows":
return "explorer"
default:
return ""
}
}
// FolderOpeningAvailable checks the same host executable used by OpenFolder.
func FolderOpeningAvailable() bool {
command := folderOpenCommand(runtime.GOOS)
if command == "" {
return false
}
_, err := exec.LookPath(command)
return err == nil
}
@@ -0,0 +1,43 @@
package service
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestFolderOpeningAvailable(t *testing.T) {
for _, tc := range []struct{ platform, command string }{
{"darwin", "open"}, {"linux", "xdg-open"}, {"windows", "explorer"}, {"other", ""},
} {
t.Run(tc.platform, func(t *testing.T) {
if got := folderOpenCommand(tc.platform); got != tc.command {
t.Fatalf("command = %q, want %q", got, tc.command)
}
})
}
dir := t.TempDir()
t.Setenv("PATH", dir)
if FolderOpeningAvailable() {
t.Fatal("missing opener reported available")
}
svc := &Service{}
if err := svc.OpenFolder(context.Background(), "session", ""); err != ErrFolderUnavailable {
t.Fatalf("missing opener error = %v", err)
}
command := folderOpenCommand(runtime.GOOS)
if command == "" {
return
}
if runtime.GOOS == "windows" {
command += ".exe"
}
if err := os.WriteFile(filepath.Join(dir, command), []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil {
t.Fatal(err)
}
if !FolderOpeningAvailable() {
t.Fatal("installed opener reported unavailable")
}
}
@@ -214,6 +214,10 @@ func (s *Service) OpenFolder(ctx context.Context, sessionID, worktreeID string)
if sessionID == "" {
return ErrEditorConfigInvalid
}
if !FolderOpeningAvailable() {
return ErrFolderUnavailable
}
session, err := s.taskRepo.GetTaskSession(ctx, sessionID)
if err != nil {
return err
@@ -223,17 +227,7 @@ func (s *Service) OpenFolder(ctx context.Context, sessionID, worktreeID string)
return err
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", worktreePath)
case "linux":
cmd = exec.Command("xdg-open", worktreePath)
case "windows":
cmd = exec.Command("explorer", worktreePath)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
cmd := exec.Command(folderOpenCommand(runtime.GOOS), worktreePath)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to open folder: %w", err)
@@ -96,6 +96,7 @@ function useFileActions({
}, [openFolder]);
return {
folderDisabled: !sessionId || !openFolder.available || openFolder.isLoading,
defaultEditorId,
enabledEditors,
handleCopyPath,
@@ -106,8 +107,14 @@ function useFileActions({
export function FileActionsMenuItems(props: FileActionsMenuItemsProps) {
const { t } = useTranslation();
const { defaultEditorId, enabledEditors, handleCopyPath, handleOpenFolder, handleOpenInEditor } =
useFileActions(props);
const {
folderDisabled,
defaultEditorId,
enabledEditors,
handleCopyPath,
handleOpenFolder,
handleOpenInEditor,
} = useFileActions(props);
return (
<>
@@ -136,7 +143,11 @@ export function FileActionsMenuItems(props: FileActionsMenuItemsProps) {
<IconCopy className="h-3.5 w-3.5" />
{t("editors:copyPath")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer text-xs" onSelect={handleOpenFolder}>
<DropdownMenuItem
className="cursor-pointer text-xs"
disabled={folderDisabled}
onSelect={handleOpenFolder}
>
<IconFolderShare className="h-3.5 w-3.5" />
{t("editors:openFolder")}
</DropdownMenuItem>
@@ -6,7 +6,7 @@ import { useWorkspaceRestoration } from "@/hooks/domains/session/use-workspace-r
import { useRepository } from "@/hooks/domains/workspace/use-repository";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useAppStore } from "@/components/state-provider";
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
import { useTaskFolderAction } from "@/hooks/use-task-folder-action";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { useFileBrowserSearch, useFileBrowserTree } from "./file-browser-hooks";
import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path";
@@ -45,7 +45,7 @@ export function useFileBrowserData(sessionId: string, environmentId: string | nu
const workspaceRestoration = useWorkspaceRestoration(session?.task_id, sessionId, environmentId);
const repository = useRepository(session?.repository_id ?? null);
const gitStatus = useSessionGitStatus(sessionId);
const { open: openFolder } = useOpenSessionFolder(sessionId);
const folderAction = useTaskFolderAction(sessionId);
const { copied, copy: copyPath } = useCopyToClipboard(1000);
const search = useFileBrowserSearch(sessionId);
const resetKey = useFileBrowserResetKey(sessionId, environmentId);
@@ -65,7 +65,7 @@ export function useFileBrowserData(sessionId: string, environmentId: string | nu
return {
isSessionFailed,
sessionError,
openFolder,
folderAction,
copied,
copyPath,
search,
@@ -14,7 +14,9 @@ export type FileBrowserHeaderProps = {
expandedPathsSize: number;
onCopyPath: (value: string) => void | Promise<void>;
onStartCreate?: () => void;
onOpenFolder: () => void;
onOpenFolder: (opener?: HTMLButtonElement) => void;
isOpeningFolder?: boolean;
isFolderDisabled?: boolean;
onCollapseAll: () => void;
showCreateButton: boolean;
onUploadFiles?: (mode: "files" | "folder") => void;
@@ -33,6 +35,8 @@ export function FileBrowserHeader({
onCopyPath,
onStartCreate,
onOpenFolder,
isOpeningFolder,
isFolderDisabled,
onCollapseAll,
showCreateButton,
onUploadFiles,
@@ -61,6 +65,8 @@ export function FileBrowserHeader({
onCopyPath={onCopyPath}
onStartCreate={onStartCreate}
onOpenFolder={onOpenFolder}
isOpeningFolder={isOpeningFolder}
isFolderDisabled={isFolderDisabled}
onStartSearch={() => search.setIsSearchActive(true)}
onCollapseAll={onCollapseAll}
showCreateButton={showCreateButton}
@@ -26,6 +26,8 @@ vi.mock("@/components/state-provider", () => ({
selector({
sessionWorktreesBySessionId: { itemsBySessionId: {} },
workspaceFilesRefresh: { bySessionId: {} },
worktrees: { items: {} },
repositories: { itemsByWorkspaceId: {} },
}),
useAppStoreApi: () => ({
getState: () => ({ workspaceRestoration: { byEnvironmentId: {} } }),
@@ -17,7 +17,7 @@ afterEach(() => {
});
describe("FileBrowserToolbar workspace actions", () => {
it("keeps Open workspace folder available while Add sources explains why it is unavailable", () => {
it("keeps Open workspace folder available while Add sources explains why it is unavailable", async () => {
const onAddSources = vi.fn();
const onOpenFolder = vi.fn();
@@ -50,7 +50,8 @@ describe("FileBrowserToolbar workspace actions", () => {
expect(addSources.className).toContain("min-h-11");
expect(screen.getByText("Wait for the active agent turn to finish")).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Open workspace folder" }));
expect(onOpenFolder).toHaveBeenCalledOnce();
await waitFor(() => expect(onOpenFolder).toHaveBeenCalledOnce());
expect(onOpenFolder).toHaveBeenCalledWith(trigger);
expect(onAddSources).not.toHaveBeenCalled();
});
@@ -12,6 +12,7 @@ import {
IconPlus,
IconUpload,
IconDots,
IconLoader2,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
@@ -58,7 +59,9 @@ type FileBrowserToolbarProps = {
expandedPathsSize: number;
onCopyPath: (text: string) => void;
onStartCreate?: () => void;
onOpenFolder: () => void;
onOpenFolder: (opener?: HTMLButtonElement) => void;
isOpeningFolder?: boolean;
isFolderDisabled?: boolean;
onStartSearch: () => void;
onCollapseAll: () => void;
showCreateButton: boolean;
@@ -210,18 +213,53 @@ function CreateMenu({
);
}
function OpenWorkspaceFolderMenuItem({
isOpening,
disabled,
onSelect,
}: {
isOpening?: boolean;
disabled?: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenuItem
className="min-h-11 cursor-pointer gap-2 sm:min-h-8"
disabled={disabled || isOpening}
aria-busy={isOpening}
onSelect={onSelect}
>
{isOpening ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<IconFolderOpen className="h-3.5 w-3.5" aria-hidden />
)}
{t("task:openWorkspaceFolder")}
</DropdownMenuItem>
);
}
function WorkspaceActionsMenu({
onAddSources,
onOpenFolder,
isOpeningFolder,
isFolderDisabled,
addSourcesButtonRef,
addSourcesDisabledReason,
}: Pick<
FileBrowserToolbarProps,
"onAddSources" | "onOpenFolder" | "addSourcesButtonRef" | "addSourcesDisabledReason"
| "onAddSources"
| "onOpenFolder"
| "isOpeningFolder"
| "isFolderDisabled"
| "addSourcesButtonRef"
| "addSourcesDisabledReason"
>) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const openSourcesAfterCloseRef = useRef(false);
const openFolderAfterCloseRef = useRef(false);
const restoreMobileFocusAfterDrawerClose = useMobileDrawerFocusRestoration(triggerRef);
const setTriggerRef = useCallback(
(node: HTMLButtonElement | null) => {
@@ -260,6 +298,13 @@ function WorkspaceActionsMenu({
align="end"
className="w-72"
onCloseAutoFocus={(event) => {
if (openFolderAfterCloseRef.current) {
event.preventDefault();
openFolderAfterCloseRef.current = false;
triggerRef.current?.focus();
onOpenFolder(triggerRef.current ?? undefined);
return;
}
if (!openSourcesAfterCloseRef.current) return;
event.preventDefault();
openSourcesAfterCloseRef.current = false;
@@ -284,13 +329,13 @@ function WorkspaceActionsMenu({
)}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 cursor-pointer gap-2 sm:min-h-8"
onSelect={onOpenFolder}
>
<IconFolderOpen className="h-3.5 w-3.5" />
{t("task:openWorkspaceFolder")}
</DropdownMenuItem>
<OpenWorkspaceFolderMenuItem
isOpening={isOpeningFolder}
disabled={isFolderDisabled}
onSelect={() => {
openFolderAfterCloseRef.current = true;
}}
/>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -304,6 +349,8 @@ export function FileBrowserToolbar({
onCopyPath,
onStartCreate,
onOpenFolder,
isOpeningFolder,
isFolderDisabled,
onStartSearch,
onCollapseAll,
showCreateButton,
@@ -346,6 +393,8 @@ export function FileBrowserToolbar({
<WorkspaceActionsMenu
onAddSources={onAddSources}
onOpenFolder={onOpenFolder}
isOpeningFolder={isOpeningFolder}
isFolderDisabled={isFolderDisabled}
addSourcesButtonRef={addSourcesButtonRef}
addSourcesDisabledReason={addSourcesDisabledReason}
/>
+11 -2
View File
@@ -1,5 +1,7 @@
"use client";
import { TaskFolderPicker } from "./task-folder-picker";
import React, {
useEffect,
useMemo,
@@ -511,6 +513,7 @@ export function FileBrowser({
addSourcesDisabledReason,
}: FileBrowserProps) {
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const { t } = useTranslation();
const showTouchActions = shouldShowFileTreeTouchActions(isMobile, isFinePointer);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -524,7 +527,7 @@ export function FileBrowser({
scrollAreaRef,
containerRef,
});
const { openFolder, copied, copyPath, search, treeState, fullPath, displayPath } = data;
const { folderAction, copied, copyPath, search, treeState, fullPath, displayPath } = data;
const workspaceBlocked =
data.workspaceRestoration.status !== null && data.workspaceRestoration.status !== "ready";
const { openPicker, uploads, elements } = useFileUploadEntryPoints(sessionId);
@@ -550,7 +553,9 @@ export function FileBrowser({
expandedPathsSize={treeState.expandedPaths.size}
onCopyPath={copyPath}
onStartCreate={!workspaceBlocked && onCreateFile ? handlers.handleStartCreate : undefined}
onOpenFolder={openFolder}
onOpenFolder={folderAction.open}
isOpeningFolder={folderAction.isLoading}
isFolderDisabled={folderAction.disabled}
onCollapseAll={treeState.collapseAll}
showCreateButton={!workspaceBlocked && Boolean(onCreateFile)}
onUploadFiles={!workspaceBlocked && sessionId ? handleToolbarUpload : undefined}
@@ -573,6 +578,10 @@ export function FileBrowser({
onUploadFilesHere={sessionId ? handleUploadHere : undefined}
showTouchActions={showTouchActions}
/>
<span role="status" className="sr-only">
{folderAction.isLoading ? t("editors:openingFolder") : ""}
</span>
<TaskFolderPicker action={folderAction} />
<FileUploadStatusList uploads={uploads} />
{elements}
</div>
@@ -0,0 +1,149 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { OpenTaskFolderButton } from "./open-task-folder-button";
const fixtures = vi.hoisted(() => ({
folderOpeningAvailable: true,
worktrees: [] as { id: string; repositoryId: string; path: string; branch: string }[],
openSessionFolder: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/hooks/domains/settings/use-editors", () => ({
useEditors: () => ({ folderOpeningAvailable: fixtures.folderOpeningAvailable }),
}));
vi.mock("@/lib/api", () => ({ openSessionFolder: fixtures.openSessionFolder }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: fixtures.toast }) }));
vi.mock("@/hooks/domains/session/use-session-worktrees", () => ({
useSessionWorktrees: () => fixtures.worktrees,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (select: (state: unknown) => unknown) =>
select({ repositories: { itemsByWorkspaceId: {} } }),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false, isFinePointer: true }),
}));
const OPEN_FOLDER_LABEL = "Open folder";
function view(sessionId: string | null) {
return (
<TooltipProvider>
<OpenTaskFolderButton sessionId={sessionId} />
</TooltipProvider>
);
}
beforeEach(() => {
vi.clearAllMocks();
fixtures.worktrees = [];
fixtures.folderOpeningAvailable = true;
fixtures.openSessionFolder.mockResolvedValue({ success: true });
});
afterEach(cleanup);
// @covers AC-TASKS-OPEN-FOLDER-001.1, AC-TASKS-OPEN-FOLDER-001.2, AC-TASKS-OPEN-FOLDER-001.3
describe("task folder action", () => {
it("opens without any editor configuration", async () => {
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
{ cache: "no-store" },
undefined,
),
);
});
it("disables opening without a session", () => {
render(view(null));
expect(
(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }) as HTMLButtonElement).disabled,
).toBe(true);
});
it("sends the only worktree explicitly", async () => {
fixtures.worktrees = [{ id: "wt-1", repositoryId: "r1", path: "/repo-one", branch: "main" }];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
{ cache: "no-store" },
{ worktree_id: "wt-1" },
),
);
});
it("requires a selection and opens only the chosen worktree", async () => {
fixtures.worktrees = [
{ id: "wt-1", repositoryId: "r1", path: "/repo-one", branch: "main" },
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /repo-two/ }));
await waitFor(() =>
expect(fixtures.openSessionFolder).toHaveBeenCalledWith(
"s1",
{ cache: "no-store" },
{ worktree_id: "wt-2" },
),
);
});
it("dismisses an open picker when the session changes", async () => {
fixtures.worktrees = [
{ id: "wt-1", repositoryId: "r1", path: "/repo-one", branch: "main" },
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
const { rerender } = render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
expect(screen.getByRole("dialog")).toBeTruthy();
rerender(view("s2"));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
rerender(view("s1"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("cancels a picker without opening a folder", async () => {
fixtures.worktrees = [
{ id: "wt-1", repositoryId: "r1", path: "/repo-one", branch: "main" },
{ id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" },
];
render(view("s1"));
fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
});
it("disables the action until opening completes", async () => {
let finish!: (value: { success: boolean }) => void;
fixtures.openSessionFolder.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
render(view("s1"));
const button = screen.getByRole("button", { name: OPEN_FOLDER_LABEL });
fireEvent.click(button);
expect((button as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
finish({ success: true });
});
expect((button as HTMLButtonElement).disabled).toBe(false);
});
});
it("blocks the picker and requests when the host opener is missing", () => {
fixtures.folderOpeningAvailable = false;
fixtures.worktrees = [
{ id: "wt-1", repositoryId: "r1", path: "/one", branch: "main" },
{ id: "wt-2", repositoryId: "r2", path: "/two", branch: "main" },
];
render(view("s1"));
const button = screen.getByRole("button", { name: OPEN_FOLDER_LABEL }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(screen.queryByRole("dialog")).toBeNull();
expect(fixtures.openSessionFolder).not.toHaveBeenCalled();
});
@@ -0,0 +1,46 @@
"use client";
import { IconFolderOpen, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { useTaskFolderAction } from "@/hooks/use-task-folder-action";
import { TaskFolderPicker } from "./task-folder-picker";
export function OpenTaskFolderButton({ sessionId }: { sessionId: string | null }) {
const { t } = useTranslation();
const action = useTaskFolderAction(sessionId);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={action.disabled ? 0 : -1} className="inline-flex">
<Button
variant="outline"
size="icon"
className="size-7 cursor-pointer"
aria-label={t("editors:openFolder")}
aria-busy={action.isLoading}
data-testid="open-task-folder"
disabled={action.disabled}
onClick={(event) => action.open(event.currentTarget)}
>
{action.isLoading ? (
<IconLoader2 className="size-4 animate-spin" aria-hidden />
) : (
<IconFolderOpen className="size-4" aria-hidden />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sessionId ? t("editors:openFolderOnHost") : t("task:selectASessionToOpenIts")}
</TooltipContent>
</Tooltip>
<span role="status" className="sr-only">
{action.isLoading ? t("editors:openingFolder") : ""}
</span>
<TaskFolderPicker action={action} />
</>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { useTaskFolderAction } from "@/hooks/use-task-folder-action";
export function TaskFolderPicker({ action }: { action: ReturnType<typeof useTaskFolderAction> }) {
const { t } = useTranslation();
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const touch = isMobile || !isFinePointer;
const choices = (
<div className="flex flex-col gap-1">
{action.options.map((option) => (
<Button
key={option.worktreeId}
variant="ghost"
className={`w-full justify-start cursor-pointer ${touch ? "h-11 min-h-11" : "h-7"}`}
onClick={() => action.select(option.worktreeId)}
disabled={action.isLoading}
>
<span className="min-w-0 truncate">{option.label}</span>
{option.branch && (
<span className="ml-auto min-w-0 truncate text-muted-foreground">{option.branch}</span>
)}
</Button>
))}
</div>
);
const title = t("editors:chooseFolder");
const description = t("editors:openFolderOnHost");
if (touch) {
return (
<MobilePickerSheet
open={action.pickerOpen}
onOpenChange={action.onOpenChange}
onCloseAutoFocus={action.onCloseAutoFocus}
title={title}
description={description}
contentTestId="task-folder-picker"
>
{choices}
</MobilePickerSheet>
);
}
return (
<Dialog open={action.pickerOpen} onOpenChange={action.onOpenChange}>
<DialogContent
showCloseButton={false}
enterConfirms={false}
onCloseAutoFocus={action.onCloseAutoFocus}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[60dvh] overflow-y-auto" data-testid="task-folder-picker">
{choices}
</div>
<Button
variant="outline"
className="justify-self-end cursor-pointer"
onClick={() => action.onOpenChange(false)}
>
{t("common:cancel")}
</Button>
</DialogContent>
</Dialog>
);
}
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PageTopbar, type ParentCrumb } from "@/components/page-topbar";
import { useOfficeProject } from "@/hooks/use-office-workspace-data";
import { TaskTopBarTitle } from "@/components/task/task-top-bar-title";
import { OpenTaskFolderButton } from "@/components/task/open-task-folder-button";
import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { TaskRightPanelsToggle } from "@/components/task/task-right-panels-toggle";
@@ -356,6 +357,7 @@ function TopbarToolsGroup({
activeSessionId={activeSessionId ?? null}
embeddedVscodeSupported={embeddedVscodeSupported ?? false}
/>
<OpenTaskFolderButton sessionId={activeSessionId ?? null} />
</div>
)}
{showDebugToggle && (
+6 -1
View File
@@ -2564,7 +2564,12 @@ export class ApiClient {
workspace_path?: string;
worktree_path?: string;
worktree_branch?: string;
worktrees?: Array<{ repository_id?: string; worktree_path?: string }>;
worktrees?: Array<{
id?: string;
worktree_id?: string;
repository_id?: string;
worktree_path?: string;
}>;
error_message?: string;
metadata?: Record<string, unknown>;
}>;
+47
View File
@@ -0,0 +1,47 @@
import type { Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { ApiClient } from "./api-client";
import { makeGitEnv } from "./git-helper";
export async function createFolderTestRepository(
apiClient: ApiClient,
workspaceId: string,
root: string,
) {
const directory = path.join(root, "repos", "folder-second-repo");
fs.mkdirSync(directory, { recursive: true });
const env = makeGitEnv(root);
execFileSync("git", ["init", "-b", "main"], { cwd: directory, env });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: directory, env });
return apiClient.createRepository(workspaceId, directory, "main", {
name: "Folder second repo",
pull_before_worktree: false,
});
}
export async function findFolderTestWorktree(
apiClient: ApiClient,
taskId: string,
sessionId: string,
repositoryId: string,
) {
const { sessions } = await apiClient.listTaskSessions(taskId);
const session = sessions.find((item) => item.id === sessionId);
const worktree = session?.worktrees?.find((item) => item.repository_id === repositoryId);
if (!worktree) throw new Error("Task session has no worktree for folder test repository");
const id = worktree.worktree_id || worktree.id;
if (!id) throw new Error("Folder test worktree has no identity");
return id;
}
export async function mockFolderAvailability(page: Page, available: boolean) {
await page.route("**/api/v1/editors", async (route) => {
const response = await route.fetch();
await route.fulfill({
response,
json: { ...(await response.json()), folder_opening_available: available },
});
});
}
@@ -7,6 +7,7 @@ import type { ApiClient } from "../../helpers/api-client";
import { waitForHttp } from "../../helpers/causal-waits";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
import { mockFolderAvailability } from "../../helpers/open-task-folder";
const WORKSPACE_SOURCES_PATH = /^\/api\/v1\/tasks\/[^/]+\/workspace-sources$/;
@@ -93,6 +94,13 @@ async function waitForWorkspaceReady(
}
test.describe("Attach local workspace sources", () => {
test.beforeEach(async ({ testPage }) => {
await mockFolderAvailability(testPage, true);
await testPage.route("**/api/v1/task-sessions/*/open-folder", (route) =>
route.fulfill({ json: { success: true } }),
);
});
test("adds a local repository and folder successively, scopes Changes to Git, and persists", async ({
testPage,
apiClient,
@@ -1,3 +1,4 @@
import { mockFolderAvailability } from "../../helpers/open-task-folder";
import { expect, test } from "../../fixtures/test-base";
import type { Locator, Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
@@ -85,6 +86,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor
})
.toBeTruthy();
await mockFolderAvailability(testPage, true);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
@@ -126,6 +128,9 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor
expect(addSourcesBox.height).toBeGreaterThanOrEqual(44);
expect(openFolderBox.height).toBeGreaterThanOrEqual(44);
if (!task.session_id) throw new Error("task creation did not return a session id");
await testPage.route("**/api/v1/task-sessions/*/open-folder", (route) =>
route.fulfill({ json: { success: true } }),
);
await Promise.all([
testPage.waitForRequest(
(request) =>
@@ -0,0 +1,121 @@
import { expect, test } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
createFolderTestRepository,
findFolderTestWorktree,
mockFolderAvailability,
} from "../../helpers/open-task-folder";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
// @covers AC-TASKS-OPEN-FOLDER-001.2, AC-TASKS-OPEN-FOLDER-001.4
test("phone Files action opens the selected repository folder", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repository = await createFolderTestRepository(
apiClient,
seedData.workspaceId,
backend.tmpDir,
);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Phone folder picker",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId, repository.id],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, true);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
if (!task.session_id) throw new Error("Task has no session");
const worktreeId = await findFolderTestWorktree(
apiClient,
task.id,
task.session_id,
repository.id,
);
const payloads: unknown[] = [];
await testPage.route("**/api/v1/task-sessions/*/open-folder", async (route) => {
payloads.push(route.request().postDataJSON());
await route.fulfill({ json: { success: true } });
});
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
const trigger = testPage.getByTestId("files-workspace-actions");
await trigger.tap();
const action = testPage.getByRole("menuitem", { name: "Open workspace folder" });
await action
.locator("xpath=ancestor::*[@role='menu'][1]")
.evaluate((element) =>
Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined)),
),
);
expect((await action.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await action.tap();
const picker = testPage.getByRole("dialog", { name: "Choose a folder" });
await expect(picker).toBeVisible();
await picker.evaluate((element) =>
Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const choice = picker.getByRole("button", { name: /Folder second repo/ });
expect((await choice.boundingBox())!.height).toBeGreaterThanOrEqual(44);
const [choiceBox, pickerBox] = await Promise.all([choice.boundingBox(), picker.boundingBox()]);
expect(choiceBox!.y).toBeGreaterThanOrEqual(pickerBox!.y);
expect(choiceBox!.y + choiceBox!.height).toBeLessThanOrEqual(pickerBox!.y + pickerBox!.height);
expect(pickerBox!.y + pickerBox!.height).toBeLessThanOrEqual(testPage.viewportSize()!.height);
await assertNoDocumentHorizontalOverflow(testPage, "folder picker");
await testPage.screenshot({ path: test.info().outputPath("mobile-folder-picker.png") });
await choice.tap();
await expect.poll(() => payloads).toEqual([{ worktree_id: worktreeId }]);
await expect(picker).not.toBeVisible();
await expect(trigger).toBeFocused();
});
test("missing host folder opener disables the action before any picker opens", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Unavailable folder opener",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, false);
let requests = 0;
await testPage.route("**/api/v1/task-sessions/*/open-folder", async (route) => {
requests++;
await route.fulfill({ json: { success: true } });
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
await testPage.getByTestId("files-workspace-actions").tap();
await expect(testPage.getByRole("menuitem", { name: "Open workspace folder" })).toBeDisabled();
await expect(testPage.getByRole("dialog", { name: "Choose a folder" })).not.toBeVisible();
expect(requests).toBe(0);
});
@@ -0,0 +1,148 @@
import { expect, test } from "../../fixtures/test-base";
import {
createFolderTestRepository,
findFolderTestWorktree,
mockFolderAvailability,
} from "../../helpers/open-task-folder";
import { SessionPage } from "../../pages/session-page";
// @covers AC-TASKS-OPEN-FOLDER-001.1, AC-TASKS-OPEN-FOLDER-001.3
test("task tools open the folder beside the IDE action and recover from errors", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Open task folder",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, true);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
let attempts = 0;
const requests: string[] = [];
await testPage.route("**/api/v1/task-sessions/*/open-folder", async (route) => {
requests.push(route.request().url());
attempts++;
await route.fulfill({
status: attempts === 1 ? 500 : 200,
json: attempts === 1 ? { error: "failed to open folder" } : { success: true },
});
});
const folder = testPage.getByTestId("open-task-folder");
await expect(folder).toBeVisible();
await expect(folder).toHaveAccessibleName("Open folder");
const editorBox = await testPage.getByTestId("editors-menu-list").boundingBox();
const folderBox = await folder.boundingBox();
expect(folderBox!.height).toBeCloseTo(28, 0);
expect(folderBox!.x).toBeGreaterThanOrEqual(editorBox!.x + editorBox!.width);
expect(folderBox!.x - editorBox!.x - editorBox!.width).toBeLessThan(20);
await testPage.screenshot({ path: test.info().outputPath("desktop-folder-shortcut.png") });
await folder.click();
await expect(testPage.getByText("Failed to open folder", { exact: true })).toBeVisible();
await folder.click();
await expect.poll(() => attempts).toBe(2);
expect(
requests.every((url) => url.endsWith(`/task-sessions/${task.session_id}/open-folder`)),
).toBe(true);
await expect(folder).toBeEnabled();
});
// @covers AC-TASKS-OPEN-FOLDER-001.2
test("desktop folder picker opens only the selected repository", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repository = await createFolderTestRepository(
apiClient,
seedData.workspaceId,
backend.tmpDir,
);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Desktop folder picker",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId, repository.id],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, true);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
if (!task.session_id) throw new Error("Task has no session");
const worktreeId = await findFolderTestWorktree(
apiClient,
task.id,
task.session_id,
repository.id,
);
const payloads: unknown[] = [];
await testPage.route("**/api/v1/task-sessions/*/open-folder", async (route) => {
payloads.push(route.request().postDataJSON());
await route.fulfill({ json: { success: true } });
});
const trigger = testPage.getByTestId("open-task-folder");
await trigger.focus();
await testPage.keyboard.press("Enter");
const picker = testPage.getByRole("dialog", { name: "Choose a folder" });
await expect(picker).toBeVisible();
await testPage.screenshot({ path: test.info().outputPath("desktop-folder-picker.png") });
await picker.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(picker).not.toBeVisible();
await expect(trigger).toBeFocused();
expect(payloads).toEqual([]);
await trigger.click();
await picker.getByRole("button", { name: /Folder second repo/ }).click();
await expect.poll(() => payloads).toEqual([{ worktree_id: worktreeId }]);
await expect(picker).not.toBeVisible();
});
test("missing host folder opener disables the action before any picker opens", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Unavailable folder opener",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await mockFolderAvailability(testPage, false);
let requests = 0;
await testPage.route("**/api/v1/task-sessions/*/open-folder", async (route) => {
requests++;
await route.fulfill({ json: { success: true } });
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await expect(testPage.getByTestId("open-task-folder")).toBeDisabled();
await expect(testPage.getByRole("dialog", { name: "Choose a folder" })).not.toBeVisible();
expect(requests).toBe(0);
});
@@ -0,0 +1,96 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore, useStore } from "zustand";
import { useEditors } from "./use-editors";
const mocks = vi.hoisted(() => ({ listEditors: vi.fn() }));
const makeStore = (capability?: boolean) =>
createStore<{
editors: {
items: never[];
loaded: boolean;
loading: boolean;
folderOpeningAvailable?: boolean;
};
setEditors: (items: never[], available?: boolean) => void;
setEditorsLoading: (loading: boolean) => void;
}>((set) => ({
editors: { items: [], loaded: true, loading: false, folderOpeningAvailable: capability },
setEditors: (items, available) =>
set((state) => ({
editors: { ...state.editors, items, loaded: true, folderOpeningAvailable: available },
})),
setEditorsLoading: (loading) => set((state) => ({ editors: { ...state.editors, loading } })),
}));
let store: ReturnType<typeof makeStore>;
vi.mock("@/lib/api", () => ({ listEditors: mocks.listEditors }));
vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => null }));
vi.mock("@/hooks/use-ensure-user-settings", () => ({ useEnsureUserSettings: () => {} }));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => store,
useAppStore: (select: (state: unknown) => unknown) => useStore(store, select),
}));
beforeEach(() => {
vi.resetAllMocks();
store = makeStore();
});
describe("folder capability hydration", () => {
it("shares discovery across consumers mounted in the same render", async () => {
let finish!: (value: { editors: never[]; folder_opening_available: boolean }) => void;
mocks.listEditors.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(() => [useEditors(), useEditors(), useEditors()]);
const count = mocks.listEditors.mock.calls.length;
await act(async () => {
finish({ editors: [], folder_opening_available: true });
});
expect(count).toBe(1);
expect(result.current.every((editor) => editor.folderOpeningAvailable)).toBe(true);
});
it("fetches missing capability even when editor items were already loaded", async () => {
let resolve!: (value: { editors: never[]; folder_opening_available: boolean }) => void;
mocks.listEditors.mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const { result } = renderHook(() => useEditors());
await waitFor(() => expect(mocks.listEditors).toHaveBeenCalledTimes(1));
expect(result.current.folderOpeningAvailable).toBe(false);
await act(async () => {
resolve({ editors: [], folder_opening_available: true });
});
expect(result.current.folderOpeningAvailable).toBe(true);
});
it.each([true, false])("uses the boot capability %s without another request", (available) => {
store = makeStore(available);
const { result } = renderHook(() => useEditors());
expect(result.current.folderOpeningAvailable).toBe(available);
expect(mocks.listEditors).not.toHaveBeenCalled();
});
it("retries transient discovery once while keeping folder opening disabled", async () => {
mocks.listEditors.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({
editors: [],
folder_opening_available: true,
});
const { result } = renderHook(() => useEditors());
expect(result.current.folderOpeningAvailable).toBe(false);
await waitFor(() => expect(result.current.folderOpeningAvailable).toBe(true), {
timeout: 3000,
});
expect(mocks.listEditors).toHaveBeenCalledTimes(2);
});
it("settles failed discovery as unavailable without repeated fetching", async () => {
mocks.listEditors.mockRejectedValue(new Error("offline"));
const { result } = renderHook(() => useEditors());
await waitFor(() => expect(store.getState().editors.folderOpeningAvailable).toBe(false), {
timeout: 3000,
});
expect(result.current.folderOpeningAvailable).toBe(false);
expect(mocks.listEditors).toHaveBeenCalledTimes(2);
});
});
+14 -6
View File
@@ -3,10 +3,12 @@
import { useEffect } from "react";
import { listEditors } from "@/lib/api";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useAppStore } from "@/components/state-provider";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings";
export function useEditors() {
const store = useAppStoreApi();
const folderOpeningAvailable = useAppStore((state) => state.editors.folderOpeningAvailable);
const editors = useAppStore((state) => state.editors.items);
const loaded = useAppStore((state) => state.editors.loaded);
const loading = useAppStore((state) => state.editors.loading);
@@ -22,19 +24,25 @@ export function useEditors() {
}, []);
useEffect(() => {
if (loaded || loading) return;
const current = store.getState().editors;
if ((current.loaded && current.folderOpeningAvailable !== undefined) || current.loading) return;
setEditorsLoading(true);
listEditors({ cache: "no-store" })
.catch(async () => {
// Keep one shared discovery in flight and retry transient failures once.
await new Promise((resolve) => setTimeout(resolve, 1000));
return listEditors({ cache: "no-store" });
})
.then((response) => {
setEditors(response.editors ?? []);
setEditors(response.editors ?? [], response.folder_opening_available === true);
})
.catch(() => {
setEditors([]);
setEditors([], false);
})
.finally(() => {
setEditorsLoading(false);
});
}, [loaded, loading, setEditors, setEditorsLoading]);
}, [store, loaded, loading, folderOpeningAvailable, setEditors, setEditorsLoading]);
return { editors, loaded, loading };
return { editors, loaded, loading, folderOpeningAvailable: folderOpeningAvailable === true };
}
@@ -0,0 +1,152 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOpenSessionFolder } from "./use-open-session-folder";
const { openSessionFolder, toast, availability } = vi.hoisted(() => ({
openSessionFolder: vi.fn(),
toast: vi.fn(),
availability: { value: true },
}));
vi.mock("@/hooks/domains/settings/use-editors", () => ({
useEditors: () => ({ folderOpeningAvailable: availability.value }),
}));
vi.mock("@/lib/api", () => ({ openSessionFolder }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast }) }));
beforeEach(() => {
vi.clearAllMocks();
availability.value = true;
openSessionFolder.mockResolvedValue({ success: true });
});
// @covers AC-TASKS-OPEN-FOLDER-001.2, AC-TASKS-OPEN-FOLDER-001.3
describe("useOpenSessionFolder", () => {
it("does not send requests when the host opener is unavailable", async () => {
availability.value = false;
const { result } = renderHook(() => useOpenSessionFolder("session-1"));
await act(async () => {
expect(await result.current.open("wt-2")).toBeNull();
});
expect(openSessionFolder).not.toHaveBeenCalled();
expect(toast).not.toHaveBeenCalled();
});
it("sends the selected worktree without replacing request options", async () => {
const { result } = renderHook(() => useOpenSessionFolder("session-1"));
await act(async () => {
await result.current.open("wt-2");
});
expect(openSessionFolder).toHaveBeenCalledWith(
"session-1",
{ cache: "no-store" },
{ worktree_id: "wt-2" },
);
});
it("keeps the default folder request for existing callers", async () => {
const { result } = renderHook(() => useOpenSessionFolder("session-1"));
await act(async () => {
await result.current.open();
});
expect(openSessionFolder).toHaveBeenCalledWith("session-1", { cache: "no-store" }, undefined);
});
it("does nothing without a session", async () => {
const { result } = renderHook(() => useOpenSessionFolder(null));
await act(async () => {
expect(await result.current.open()).toBeNull();
});
expect(openSessionFolder).not.toHaveBeenCalled();
});
it("surfaces a localized failure and permits retry", async () => {
openSessionFolder.mockRejectedValueOnce(new Error("workspace path not found"));
const { result } = renderHook(() => useOpenSessionFolder("session-1"));
await act(async () => {
await expect(result.current.open()).resolves.toBeNull();
});
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Failed to open folder", variant: "error" }),
);
expect(result.current.isLoading).toBe(false);
await act(async () => {
expect(await result.current.open()).toEqual({ success: true });
});
});
it("prevents duplicate requests before React renders the loading state", async () => {
let finish!: (value: { success: boolean }) => void;
openSessionFolder.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(() => useOpenSessionFolder("session-1"));
let pending!: Promise<unknown>;
act(() => {
pending = result.current.open();
void result.current.open();
});
expect(openSessionFolder).toHaveBeenCalledTimes(1);
expect(result.current.isLoading).toBe(true);
await act(async () => {
finish({ success: true });
await pending;
});
expect(result.current.isLoading).toBe(false);
});
it("uses the current session after switching tasks", async () => {
const { result, rerender } = renderHook(({ session }) => useOpenSessionFolder(session), {
initialProps: { session: "session-1" },
});
rerender({ session: "session-2" });
await act(async () => {
await result.current.open("wt-new");
});
expect(openSessionFolder).toHaveBeenCalledWith(
"session-2",
{ cache: "no-store" },
{ worktree_id: "wt-new" },
);
});
});
describe("shared folder launches", () => {
it("shares pending state across surfaces while allowing other sessions", async () => {
let finish!: (value: { success: boolean }) => void;
openSessionFolder.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const { result } = renderHook(() => [
useOpenSessionFolder("session-1"),
useOpenSessionFolder("session-1"),
useOpenSessionFolder("session-2"),
]);
let pending!: Promise<unknown>;
act(() => {
pending = result.current[0].open();
});
const sharedLoading = result.current[1].isLoading;
const otherLoading = result.current[2].isLoading;
await act(async () => {
await result.current[1].open();
await result.current[2].open();
});
const count = openSessionFolder.mock.calls.length;
await act(async () => {
finish({ success: true });
await pending;
});
expect(sharedLoading).toBe(true);
expect(otherLoading).toBe(false);
expect(count).toBe(2);
expect(result.current.every((folder) => !folder.isLoading)).toBe(true);
await act(async () => {
await result.current[1].open();
});
expect(openSessionFolder).toHaveBeenCalledTimes(3);
});
});
+52 -9
View File
@@ -1,20 +1,63 @@
"use client";
import { useEditors } from "@/hooks/domains/settings/use-editors";
import { create } from "zustand";
import { openSessionFolder } from "@/lib/api";
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { t } from "@/lib/i18n";
// Native launches are shared by every folder control for a session.
const usePendingFolders = create<{ sessions: ReadonlySet<string> }>(() => ({
sessions: new Set(),
}));
function setFolderPending(sessionId: string, pending: boolean) {
usePendingFolders.setState((state) => {
const sessions = new Set(state.sessions);
if (pending) sessions.add(sessionId);
else sessions.delete(sessionId);
return { sessions };
});
}
export function useOpenSessionFolder(sessionId?: string | null) {
const request = useRequest(async () => {
if (!sessionId) {
return null;
}
const response = await openSessionFolder(sessionId, { cache: "no-store" });
return response ?? null;
const { folderOpeningAvailable } = useEditors();
const { toast } = useToast();
const isLoading = usePendingFolders((state) =>
Boolean(sessionId && state.sessions.has(sessionId)),
);
const request = useRequest(async (worktreeId?: string) => {
if (!sessionId) return null;
return (
(await openSessionFolder(
sessionId,
{ cache: "no-store" },
worktreeId ? { worktree_id: worktreeId } : undefined,
)) ?? null
);
});
return {
open: () => request.run(),
status: request.status,
isLoading: request.isLoading,
open: async (worktreeId?: string) => {
if (
!sessionId ||
!folderOpeningAvailable ||
usePendingFolders.getState().sessions.has(sessionId)
)
return null;
setFolderPending(sessionId, true);
try {
return await request.run(worktreeId);
} catch {
toast({ title: t("editors:failedToOpenFolder"), variant: "error" });
return null;
} finally {
setFolderPending(sessionId, false);
}
},
available: folderOpeningAvailable,
status: isLoading ? "loading" : request.status,
isLoading,
};
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { buildWorktreeOptions } from "@/components/task/editor-worktree-options";
import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees";
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
export function useTaskFolderAction(sessionId: string | null) {
const request = useOpenSessionFolder(sessionId);
const worktrees = useSessionWorktrees(sessionId);
const repositories = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const options = useMemo(
() => buildWorktreeOptions(worktrees, Object.values(repositories).flat()),
[worktrees, repositories],
);
const [pickerSessionId, setPickerSessionId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
useEffect(() => {
setPickerSessionId(null);
}, [sessionId]);
const pickerOpen = Boolean(request.available && sessionId && pickerSessionId === sessionId);
return {
options,
pickerOpen,
isLoading: request.isLoading,
disabled: !sessionId || !request.available || request.isLoading,
open: (trigger?: HTMLElement) => {
if (!sessionId || !request.available || request.isLoading) return;
if (options.length > 1) {
opener.current = trigger ?? (document.activeElement as HTMLElement | null);
setPickerSessionId(sessionId);
} else {
void request.open(options[0]?.worktreeId);
}
},
select: (worktreeId: string) => {
if (!pickerOpen || !options.some((option) => option.worktreeId === worktreeId)) return;
setPickerSessionId(null);
void request.open(worktreeId);
},
onOpenChange: (open: boolean) => {
if (!open) setPickerSessionId(null);
},
onCloseAutoFocus: (event: Event) => {
event.preventDefault();
if (opener.current?.isConnected) opener.current.focus();
},
};
}
@@ -41,3 +41,33 @@ describe("renameSession", () => {
await expect(renameSession("missing", "x")).rejects.toThrow("session not found");
});
});
const { fetchJson } = vi.hoisted(() => ({ fetchJson: vi.fn() }));
vi.mock("../client", () => ({ fetchJson }));
const { openSessionFolder } = await import("./session-api");
// @covers AC-TASKS-OPEN-FOLDER-001.2
it("posts the selected folder worktree while preserving request options", async () => {
const controller = new AbortController();
await openSessionFolder(
"sess-1",
{ cache: "no-store", init: { signal: controller.signal } },
{ worktree_id: "wt-2" },
);
expect(fetchJson).toHaveBeenCalledWith("/api/v1/task-sessions/sess-1/open-folder", {
cache: "no-store",
init: {
method: "POST",
signal: controller.signal,
body: JSON.stringify({ worktree_id: "wt-2" }),
},
});
});
it("preserves bodyless default folder requests", async () => {
await openSessionFolder("sess-1", { cache: "no-store" });
expect(fetchJson).toHaveBeenLastCalledWith("/api/v1/task-sessions/sess-1/open-folder", {
cache: "no-store",
init: { method: "POST" },
});
});
+10 -2
View File
@@ -167,10 +167,18 @@ export async function openSessionInEditor(
});
}
export async function openSessionFolder(sessionId: string, options?: ApiRequestOptions) {
export async function openSessionFolder(
sessionId: string,
options?: ApiRequestOptions,
payload?: { worktree_id?: string },
) {
return fetchJson<{ success: boolean }>(`/api/v1/task-sessions/${sessionId}/open-folder`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
init: {
method: "POST",
...(payload ? { body: JSON.stringify(payload) } : {}),
...(options?.init ?? {}),
},
});
}
+1 -1
View File
@@ -314,7 +314,7 @@ export type AppState = KanbanSlice & {
upsertRepositoryBranchPolicy: (policy: RepositoryBranchPolicy) => void;
removeRepositoryBranchPolicy: (repositoryId: string, policyId: string) => void;
setSettingsData: (next: Partial<SettingsDataState>) => void;
setEditors: (editors: EditorsState["items"]) => void;
setEditors: (editors: EditorsState["items"], folderOpeningAvailable?: boolean) => void;
setEditorsLoading: (loading: boolean) => void;
setPrompts: (prompts: PromptsState["items"]) => void;
setPromptsLoading: (loading: boolean) => void;
@@ -365,3 +365,16 @@ describe("setAvailableAgents capability propagation", () => {
expect(store.getState().agentProfiles.items[0]?.capability_status).toBe("probing");
});
});
describe("folder opener discovery", () => {
it("defaults unavailable and preserves capability through editor preference updates", () => {
const store = makeStore();
expect(store.getState().editors.folderOpeningAvailable).not.toBe(true);
store.getState().setEditors([], true);
expect(store.getState().editors.folderOpeningAvailable).toBe(true);
store.getState().setEditors([]);
expect(store.getState().editors.folderOpeningAvailable).toBe(true);
store.getState().setEditors([], false);
expect(store.getState().editors.folderOpeningAvailable).toBe(false);
});
});
@@ -267,9 +267,12 @@ function createCoreActions(
set((draft) => {
draft.agentProfiles.items = profiles;
}),
setEditors: (editors) =>
setEditors: (editors, folderOpeningAvailable) =>
set((draft) => {
draft.editors.items = editors;
if (folderOpeningAvailable !== undefined) {
draft.editors.folderOpeningAvailable = folderOpeningAvailable;
}
draft.editors.loaded = true;
}),
setEditorsLoading: (loading) =>
+2 -1
View File
@@ -363,6 +363,7 @@ export type AgentUpdateJobsState = {
};
export type EditorsState = {
folderOpeningAvailable?: boolean;
items: EditorOption[];
loaded: boolean;
loading: boolean;
@@ -538,7 +539,7 @@ export type SettingsSliceActions = {
upsertAgentUpdateJob: (job: AgentUpdateJob) => void;
appendAgentUpdateOutput: (agentName: string, jobId: string, chunk: string) => void;
clearAgentUpdateJob: (agentName: string) => void;
setEditors: (editors: EditorsState["items"]) => void;
setEditors: (editors: EditorsState["items"], folderOpeningAvailable?: boolean) => void;
setEditorsLoading: (loading: boolean) => void;
setPrompts: (prompts: PromptsState["items"]) => void;
setPromptsLoading: (loading: boolean) => void;
+1
View File
@@ -759,6 +759,7 @@ export type EditorOption = {
};
export type EditorsResponse = {
folder_opening_available?: boolean;
editors: EditorOption[];
};
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "Underline (Cmd+U)",
"failedToOpenEditor": "Failed to open editor",
"downloadFile": "Download file",
"symlink": "Symlink"
"symlink": "Symlink",
"failedToOpenFolder": "Failed to open folder",
"openFolderOnHost": "Open folder on the machine running Kandev",
"chooseFolder": "Choose a folder",
"openingFolder": "Opening folder…"
}
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
"downloadFile": "Ďōŵńĺōàď ƒĩĺē",
"symlink": "Śŷḿĺĩńķ"
"symlink": "Śŷḿĺĩńķ",
"failedToOpenFolder": "Ƒàĩĺēď ţō ōƥēń ƒōĺďēŕ",
"openFolderOnHost": "Ōƥēń ƒōĺďēŕ ōń ţĥē ḿàćĥĩńē ŕũńńĩńĝ Ķàńďēv",
"chooseFolder": "Ćĥōōśē à ƒōĺďēŕ",
"openingFolder": "Ōƥēńĩńĝ ƒōĺďēŕ…"
}
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "Sublinhado (Cmd+U)",
"failedToOpenEditor": "Falha ao abrir o editor",
"downloadFile": "Transferir ficheiro",
"symlink": "Ligação simbólica"
"symlink": "Ligação simbólica",
"failedToOpenFolder": "Não foi possível abrir a pasta",
"openFolderOnHost": "Abrir a pasta no computador onde o Kandev está a ser executado",
"chooseFolder": "Escolher uma pasta",
"openingFolder": "A abrir a pasta…"
}
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "下划线(Cmd+U)",
"failedToOpenEditor": "打开编辑器失败",
"downloadFile": "下载文件",
"symlink": "符号链接"
"symlink": "符号链接",
"failedToOpenFolder": "无法打开文件夹",
"openFolderOnHost": "在运行 Kandev 的计算机上打开文件夹",
"chooseFolder": "选择文件夹",
"openingFolder": "正在打开文件夹…"
}
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案",
"symlink": "符號連結"
"symlink": "符號連結",
"failedToOpenFolder": "無法開啟資料夾",
"openFolderOnHost": "在執行 Kandev 的計算機上開啟資料夾",
"chooseFolder": "選擇資料夾",
"openingFolder": "正在開啟資料夾…"
}
+5 -1
View File
@@ -90,5 +90,9 @@
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案",
"symlink": "符號連結"
"symlink": "符號連結",
"failedToOpenFolder": "無法開啟資料夾",
"openFolderOnHost": "在執行 Kandev 的計算機上開啟資料夾",
"chooseFolder": "選擇資料夾",
"openingFolder": "正在開啟資料夾…"
}
+174
View File
@@ -0,0 +1,174 @@
---
created: 2026-09-14
status: implemented
requirements:
- REQ-TASKS-OPEN-FOLDER-001
system_design:
- ../../specs/tasks/system-design/open-task-folder.md
legacy_specs: []
---
# Implementation plan: Open task folder
## Overview
One sequential vertical work order adds the shortcut using an already implemented
folder endpoint. Implementation estimate: 1-2 hours including focused UI tests.
## Scope
In scope: desktop shortcut, multi-worktree selection, shared request/error handling,
phone Files action parity, localization and public usage documentation.
Out of scope: new native/remote filesystem integrations and archived-task changes.
## Technical approach
Follow [system design](../../specs/tasks/system-design/open-task-folder.md).
Reuse `Service.OpenFolder` and its existing `worktree_id` request contract.
Keep the session API options argument compatible with current callers.
## ASCII UI preview
### UI-01: Desktop task tools, selected session
```text
[Layout] [Open in IDE | v] [Folder]
|
+ Choose folder dialog (only when multiple)
repo-a branch-a
repo-b branch-b
```
### UI-02: Phone Files panel, workspace menu expanded
```text
[Files ...]
+------------------------------+
| Add repositories |
| Open workspace folder |
+------------------------------+
-> [Choose a folder] (multiple only)
[repo-a / branch-a]
[repo-b / branch-b]
```
Structural requirements: desktop adjacency and independent enablement; phone visible
menu entry with 44px targets and one internal scroll owner. Spacing/icons are
illustrative. No session: disabled shortcut. Opening: spinner and disabled action.
Failure: localized toast, control becomes retryable. AC-001.1 through AC-001.4
refer to the full AC-TASKS-OPEN-FOLDER-001 identifiers in the requirement.
## Tests
`hooks/use-open-session-folder.test.ts`: no session, selected worktree payload,
legacy no-payload invocation, failed request and retry (AC-001.2, .3, .5).
`components/task/open-task-folder-button.test.tsx`: editor independence, disabled
and busy states, single-worktree opening, multi-worktree selection, changed session
and picker dismissal (AC-001.1 through .3).
Existing `editor-worktree-options.test.ts` and Go editor service tests cover labels
and server-side session/worktree path selection.
## E2E tests
New `e2e/tests/task/open-task-folder.spec.ts` on the configured desktop project:
assert adjacency, click-to-request, selection payload, and failure recovery.
New `e2e/tests/task/mobile-open-task-folder.spec.ts` on `mobile-chrome`:
Files menu, repository picker, correct request, touch geometry and no overflow.
Together these cover AC-001.1 through .5 at the browser boundary; native Finder
visibility requires the separate macOS smoke check.
## Work orders
- [x] [Task 01: Add folder shortcut](task-01-folder-shortcut.md)
## Verification commands
```bash
(cd apps/web && pnpm exec vitest run hooks/use-open-session-folder.test.ts lib/api/domains/session-api.test.ts components/task/open-task-folder-button.test.tsx components/task/editor-worktree-options.test.ts components/task/file-browser-toolbar.test.tsx components/task/file-browser-responsive.test.tsx components/task/task-top-bar.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run tests/task/open-task-folder.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-open-task-folder.spec.ts tests/task/mobile-add-workspace-sources.spec.ts)
(cd apps/backend && go test ./internal/editors/...)
node scripts/validate-public-docs.mjs
node --test scripts/validate-public-docs.test.mjs
```
## Verification results
Implemented REQ-TASKS-OPEN-FOLDER-001 and all five acceptance criteria.
- RED: request tests demonstrated dropped worktree IDs, unhandled failures and duplicate launches; the new button tests demonstrated the absent action.
- Focused frontend verification: seven suites passed 49 tests; after the menu-row extraction and cancellation coverage, the two affected suites passed 15 tests. There are 50 distinct passing focused tests. Final typecheck, targeted ESLint and i18n checks passed.
- Existing Go editor packages passed using `GOCACHE=/tmp/kandev-open-folder-go-cache` (the default cache is read-only in this environment).
- Full backend and production Vite E2E builds passed. Local sockets required sandbox escalation. Desktop folder tests passed 2/2; the final phone folder test passed 1/1 after waiting for menu/picker animation completion before geometry checks.
- Public-doc validators, specification catalog/lint and whitespace checks passed. Public documentation: `docs/public/developer-tools.md`.
### Verification exception
The existing `mobile-add-workspace-sources.spec.ts` times out while measuring the
separate Add repository menu, after its folder-opening step succeeds. The same
failure reproduces against an isolated unchanged frontend exported from HEAD
`509a1bb9b` (with only the native-opening HTTP call stubbed in the test to avoid a
host GUI launch). This is a baseline source-menu failure, not a folder regression;
no unrelated production code or source-menu assertions were changed.
### Native and visual evidence
Desktop screenshots were inspected: the folder icon is immediately beside the IDE
control and the repository picker matches the planned hierarchy. The final phone
picker screenshot was inspected: both repository rows are contained,
44px touch targets are available, the inset drawer clears the safe area, and there
is no page overflow. Desktop screenshots are retained under
`/tmp/open-folder-desktop-verified/`; phone evidence is under
`/tmp/open-folder-mobile-verified/`. Baseline failure evidence is in
`/tmp/open-folder-e2e-baseline.log` and `/tmp/open-folder-baseline-results/`.
The screenshot-retention desktop rerun reported both tests passing, but its parent
wrapper exited 143; the earlier desktop run exited 0 with both tests passing.
Native Finder visibility cannot be checked on this Linux host; the existing backend
macOS command path is unchanged. The initial implementation was left uncommitted. The user requested the availability correction and a branch push on 2026-09-17.
## Risks
Native launch success does not guarantee a visible window on a headless host.
Remote browser clients operate on the Kandev host, not their own filesystem.
Public usage documentation is updated in `docs/public/developer-tools.md`. No ADR is needed for reusing the existing contract.
### Availability correction validation (2026-09-17)
- Backend editor packages pass, including missing/installed executable detection and unavailable-request rejection.
- Eight focused frontend suites pass (65 tests); final affected hook/button suites pass (15 tests).
- Desktop browser suite passes 3/3 and phone suite passes 2/2, covering both available and unavailable host commands. UI tests stub capability/native opening; backend tests exercise executable discovery.
- Typecheck, changed-file ESLint, i18n, specification validators, backend build, Vite build and whitespace checks pass. The stale E2E plugin fixture was rebuilt before browser verification.
- Native Finder verification remains for the user's macOS machine. No main instance was modified.
## PR review remediation
Merge the current main toolbar grouping/panel toggle and locale additions while
retaining the folder shortcut. Preserve host capability in editor boot state and
refetch it when already-loaded editor items lack capability information. This
covers settings-first navigation without enabling unavailable openers. Regression
coverage includes boot state with installed/missing commands, loaded-state
fallback discovery, known true/false boot capabilities, and failed discovery.
Local remediation validation passes: backendapp and editor Go suites, 43 focused
frontend tests plus the five new hydration/retry regressions, typecheck, targeted lint,
i18n, harness/spec checks, backend and web builds, and all five folder browser
tests (three desktop, two phone). The adjacent source-attachment browser test
still times out measuring its repository menu, matching the previously recorded
baseline failure. Current-head remote CI and review completion remain pending
until this remediation is pushed. Native Finder verification remains external.
CodeRabbit summary suggestions are addressed with precise path-fallback and
capability documentation, plus one delayed retry for transient discovery failure.
Repeated failures remain disabled without an uncontrolled retry loop.
Follow-up Codex concurrency findings: discovery claims the live store loading
state before fetching, and all folder controls share pending state per session.
Deferred-response regressions cover simultaneous discovery consumers, shared
folder-control disabling, duplicate suppression, release, and session independence.
CI remediation: the desktop source-attachment suite also assumes an installed
folder opener. It now explicitly stubs capability and native opening, matching
the phone fixture and preserving real backend discovery tests. Both previously
failing browser cases pass locally (2/2); targeted lint and typecheck pass.
@@ -0,0 +1,203 @@
---
id: "01-folder-shortcut"
title: "Add task folder shortcut"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-OPEN-FOLDER-001
acceptance_criteria:
- AC-TASKS-OPEN-FOLDER-001.1
- AC-TASKS-OPEN-FOLDER-001.2
- AC-TASKS-OPEN-FOLDER-001.3
- AC-TASKS-OPEN-FOLDER-001.4
- AC-TASKS-OPEN-FOLDER-001.5
system_design:
- ../../specs/tasks/system-design/open-task-folder.md
---
# Task 01: Add task folder shortcut
## Summary
Expose native folder opening beside the IDE action, with explicit worktree selection
and the corresponding phone Files-menu flow. Reuse existing backend behavior.
## In scope
Shortcut, picker, compatible client payload, shared error handling, localization,
focused tests and public usage documentation.
## Out of scope
New OS integration, remote mounting, editor preferences and archived-task tool changes.
## Acceptance
- Desktop shortcut opens the intended session folder independently of editor settings.
- Desktop and phone select the intended worktree and recover visibly from request failures.
- Targeted checks pass, with native macOS smoke evidence or its explicit environment limitation.
## ASCII UI preview
See [combined preview](plan.md#ascii-ui-preview).
### UI-01: Desktop task tools, selected session
```text
[Layout] [Open in IDE | v] [Folder]
|
+ Choose folder dialog (only when multiple)
repo-a branch-a
repo-b branch-b
```
### UI-02: Phone Files panel, workspace menu expanded
```text
[Files ...]
+------------------------------+
| Add repositories |
| Open workspace folder |
+------------------------------+
-> [Choose a folder] (multiple only)
[repo-a / branch-a]
[repo-b / branch-b]
```
Structural requirements: desktop adjacency and independent enablement; phone visible
menu entry with 44px targets and one internal scroll owner. Spacing/icons are
illustrative. No session: disabled shortcut. Opening: spinner and disabled action.
Failure: localized toast, control becomes retryable. AC-001.1 through AC-001.4
refer to the full AC-TASKS-OPEN-FOLDER-001 identifiers in the requirement.
## Verification
Use repository TDD: add failing behavior tests before production edits. Install
workspace dependencies from `apps/` if this worktree lacks them. Run:
```bash
(cd apps/web && pnpm exec vitest run hooks/use-open-session-folder.test.ts lib/api/domains/session-api.test.ts components/task/open-task-folder-button.test.tsx components/task/editor-worktree-options.test.ts components/task/file-browser-toolbar.test.tsx components/task/file-browser-responsive.test.tsx components/task/task-top-bar.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run tests/task/open-task-folder.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-open-task-folder.spec.ts tests/task/mobile-add-workspace-sources.spec.ts)
(cd apps/backend && go test ./internal/editors/...)
node scripts/validate-public-docs.mjs
node --test scripts/validate-public-docs.test.mjs
```
On macOS: open a single-repository task, activate Folder, verify Finder shows the
worktree; repeat selecting the second repository of a multi-repository task.
## Files likely touched
- `apps/web/components/task/task-top-bar.tsx`, new `open-task-folder-button.tsx` and test.
- `apps/web/hooks/use-open-session-folder.ts` and new test; `apps/web/lib/api/domains/session-api.ts`.
- `apps/web/components/task/file-browser.tsx`, `file-browser-data.ts`, and a shared folder picker.
- `apps/web/e2e/tests/task/open-task-folder.spec.ts`, `mobile-open-task-folder.spec.ts`.
- `apps/web/src/locales/` as needed and `docs/public/developer-tools.md`.
## Dependencies
None. Existing folder endpoint and mobile Files workspace menu are already shipped.
## Risks
Preserve client options compatibility, prevent stale-session selection, and catch
request failures for every existing hook caller. Do not use native GUI launching in CI.
## Parallelism
`sequential`
## Inputs
[Requirements](../../specs/tasks/requirements/open-task-folder.md),
[design](../../specs/tasks/system-design/open-task-folder.md), existing editor button,
`useOpenSessionInEditor`, and `mobile-add-workspace-sources.spec.ts`.
## Results
Implemented REQ-TASKS-OPEN-FOLDER-001 and all five acceptance criteria.
- RED: request tests demonstrated dropped worktree IDs, unhandled failures and duplicate launches; the new button tests demonstrated the absent action.
- Focused frontend verification: seven suites passed 49 tests; after the menu-row extraction and cancellation coverage, the two affected suites passed 15 tests. There are 50 distinct passing focused tests. Final typecheck, targeted ESLint and i18n checks passed.
- Existing Go editor packages passed using `GOCACHE=/tmp/kandev-open-folder-go-cache` (the default cache is read-only in this environment).
- Full backend and production Vite E2E builds passed. Local sockets required sandbox escalation. Desktop folder tests passed 2/2; the final phone folder test passed 1/1 after waiting for menu/picker animation completion before geometry checks.
- Public-doc validators, specification catalog/lint and whitespace checks passed. Public documentation: `docs/public/developer-tools.md`.
### Verification exception
The existing `mobile-add-workspace-sources.spec.ts` times out while measuring the
separate Add repository menu, after its folder-opening step succeeds. The same
failure reproduces against an isolated unchanged frontend exported from HEAD
`509a1bb9b` (with only the native-opening HTTP call stubbed in the test to avoid a
host GUI launch). This is a baseline source-menu failure, not a folder regression;
no unrelated production code or source-menu assertions were changed.
### Native and visual evidence
Desktop screenshots were inspected: the folder icon is immediately beside the IDE
control and the repository picker matches the planned hierarchy. The final phone
picker screenshot was inspected: both repository rows are contained,
44px touch targets are available, the inset drawer clears the safe area, and there
is no page overflow. Desktop screenshots are retained under
`/tmp/open-folder-desktop-verified/`; phone evidence is under
`/tmp/open-folder-mobile-verified/`. Baseline failure evidence is in
`/tmp/open-folder-e2e-baseline.log` and `/tmp/open-folder-baseline-results/`.
The screenshot-retention desktop rerun reported both tests passing, but its parent
wrapper exited 143; the earlier desktop run exited 0 with both tests passing.
Native Finder visibility cannot be checked on this Linux host; the existing backend
macOS command path is unchanged. The initial implementation was left uncommitted. The user requested the availability correction and a branch push on 2026-09-17.
## Host opener availability correction
User testing on 2026-09-17 identified that missing host commands must disable the
folder action before a repository picker appears. The shared editor discovery
response now reports executable availability; desktop, mobile, and file editor
menus use a fail-closed capability with request and backend guards. The original
UI composition and touch sizing remain unchanged. RED: toolbar regression failed
because the missing-opener button was enabled; backend detection test failed to
compile before its implementation. Added installed/missing/unsupported command,
request suppression, store retention, and desktop/phone unavailable-action checks.
### Availability correction validation (2026-09-17)
- Backend editor packages pass, including missing/installed executable detection and unavailable-request rejection.
- Eight focused frontend suites pass (65 tests); final affected hook/button suites pass (15 tests).
- Desktop browser suite passes 3/3 and phone suite passes 2/2, covering both available and unavailable host commands. UI tests stub capability/native opening; backend tests exercise executable discovery.
- Typecheck, changed-file ESLint, i18n, specification validators, backend build, Vite build and whitespace checks pass. The stale E2E plugin fixture was rebuilt before browser verification.
- Native Finder verification remains for the user's macOS machine. No main instance was modified.
## PR review remediation
Merge the current main toolbar grouping/panel toggle and locale additions while
retaining the folder shortcut. Preserve host capability in editor boot state and
refetch it when already-loaded editor items lack capability information. This
covers settings-first navigation without enabling unavailable openers. Regression
coverage includes boot state with installed/missing commands, loaded-state
fallback discovery, known true/false boot capabilities, and failed discovery.
Local remediation validation passes: backendapp and editor Go suites, 43 focused
frontend tests plus the five new hydration/retry regressions, typecheck, targeted lint,
i18n, harness/spec checks, backend and web builds, and all five folder browser
tests (three desktop, two phone). The adjacent source-attachment browser test
still times out measuring its repository menu, matching the previously recorded
baseline failure. Current-head remote CI and review completion remain pending
until this remediation is pushed. Native Finder verification remains external.
CodeRabbit summary suggestions are addressed with precise path-fallback and
capability documentation, plus one delayed retry for transient discovery failure.
Repeated failures remain disabled without an uncontrolled retry loop.
Follow-up Codex concurrency findings: discovery claims the live store loading
state before fetching, and all folder controls share pending state per session.
Deferred-response regressions cover simultaneous discovery consumers, shared
folder-control disabling, duplicate suppression, release, and session independence.
CI remediation: the desktop source-attachment suite also assumes an installed
folder opener. It now explicitly stubs capability and native opening, matching
the phone fixture and preserving real backend discovery tests. Both previously
failing browser cases pass locally (2/2); targeted lint and typecheck pass.
+8
View File
@@ -205,6 +205,14 @@ re-picks their engine and language: preferences are not carried over.
## Files and editor integrations
To open a task folder in your file manager, select **Open folder** beside the IDE control in the task toolbar. On a phone, use **Files → Workspace actions → Open workspace folder**. For tasks with several worktrees, choose the repository and branch to open.
This opens Finder on macOS, the default file manager on Linux, or Explorer on Windows on the machine running Kandev. A browser connected to a remote Kandev instance does not open that folder on your own device. The host needs a desktop session and an available file manager.
The folder action is disabled when the host folder-opening command is unavailable
(`open` on macOS, `xdg-open` on Linux, or `explorer` on Windows), or while its
availability is unknown. The repository picker is also unavailable in that case.
> **Security:** Embedded VS Code runs code-server with `--auth none` inside the task environment. Use it only with a trusted executor and network boundary.
<details>
@@ -0,0 +1,39 @@
---
status: active
system: tasks
created: 2026-09-14
owners:
- kandev
---
# Open task folder requirements
## Overview
Expose the existing native folder-opening capability beside the task's IDE control.
Tasks owns this shortcut because its target is the selected task session's workspace.
The existing workspace-actions contract remains in
[Attach workspace sources](attach-workspace-sources.md).
## Requirements
### REQ-TASKS-OPEN-FOLDER-001: Task folder shortcut
**Intent:** Open task files in the host file manager without finding the file-panel menu.
#### Acceptance criteria
- **AC-TASKS-OPEN-FOLDER-001.1:** For an unarchived task with a selected session, desktop task tools shall expose an accessible folder action immediately beside Open in IDE, independent of editor configuration. With one worktree, activation shall open that worktree; with no worktrees, existing repository-root fallback shall apply.
- **AC-TASKS-OPEN-FOLDER-001.2:** With multiple session worktrees, the action shall offer repository/branch choices and open only the selected worktree. An invalid selection shall fail without opening a different directory.
- **AC-TASKS-OPEN-FOLDER-001.3:** Without a selected session or an installed host folder-opening executable, the shortcut and Files-menu action shall be disabled and shall not open a picker. Unknown or failed capability discovery shall also disable opening. While opening, it shall prevent repeat activation and show progress. Failed requests shall show a localized error and permit retry. Opening shall not launch an agent or modify task/editor preferences.
- **AC-TASKS-OPEN-FOLDER-001.4:** Phone users shall retain a visible Files-menu folder action with the same selection and error behavior, reachable through touch targets of at least 44px, without horizontal page overflow. Desktop controls shall retain 28px sizing. Labels and errors shall be localized.
- **AC-TASKS-OPEN-FOLDER-001.5:** Opening shall use the existing file-manager integration on the Kandev host: Finder on macOS, the default file manager on Linux, and Explorer on Windows. The action shall not imply it opens a remote host path on the browser device. Missing workspace paths shall produce a visible failure.
## Out of scope
Remote filesystem mounting, opening on a different browser device, new native integrations,
new editor settings, arbitrary file/path selection, and changing archived-task tool visibility.
## Implementation plans
- [Open task folder](../../../plans/open-task-folder/plan.md)
@@ -0,0 +1,102 @@
---
status: current
system: tasks
requirements:
- REQ-TASKS-OPEN-FOLDER-001
---
# Open task folder system design
## Purpose and boundaries
Add a discoverable session shortcut using the existing editors service folder operation.
This extends task workspace access; it introduces no persistence or new OS integration.
See [requirements](../requirements/open-task-folder.md) and
[workspace actions](attach-workspace-sources.md).
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| REQ-TASKS-OPEN-FOLDER-001 | Components, Selection and transport, Presentation and failures |
## Components
`apps/web/components/task/task-top-bar.tsx` renders `EditorsMenu` in
`TopbarToolsGroup`. Add an independent folder control immediately after `EditorsMenu`,
inside the existing unarchived-task branch. Do not tie it to enabled editors.
Use `open-task-folder-button.tsx` and shared `useTaskFolderAction` for selection
and opener focus. The existing `useOpenSessionFolder` owns native-launch requests,
immediate duplicate-request suppression, loading state, and localized errors. Reuse `buildWorktreeOptions` and `useSessionWorktrees`
for repository/branch labels rather than duplicating target resolution.
The phone Files workspace-actions menu already offers Open workspace folder.
Reuse that entry and its responsive menu treatment; route multiple-worktree choices
through `TaskFolderPicker`, using `Dialog` on desktop and `MobilePickerSheet` for
the short phone or coarse-pointer choice. The Files menu closes before opening
the picker and passes its persistent trigger for focus restoration.
Inspect `file-browser.tsx`, `file-browser-data.ts`, and the existing
`mobile-add-workspace-sources.spec.ts` as the current entry-point precedent.
## Selection and transport
Extend `openSessionFolder` in `lib/api/domains/session-api.ts` compatibly: retain
its existing second `ApiRequestOptions` argument and add an optional third payload
`{ worktree_id?: string }`. Extend the hook's `open` method to accept an optional
worktree ID. Existing no-argument callers retain their behavior.
The existing POST `/api/v1/task-sessions/:id/open-folder` already accepts
`OpenFolderRequest.WorktreeID`. `Service.OpenFolder` resolves it through
`resolveSessionPath`: a non-empty worktree ID must belong to the session or fail
with `ErrWorkspaceNotFound`. Only an empty ID permits the first-worktree and then
repository-local-path fallbacks. The browser sends identifiers, never a raw path
or shell command. Multiple choices must be explicit at the new shortcut and phone
entry point. Clear stale picker selection on session changes; closing a picker
must not issue a request.
Keep the existing backend host boundary. `OpenFolder` dispatches `open`,
`xdg-open`, or `explorer` with the resolved path as an argument. The backend also reports opener availability and rejects unavailable commands. Successful HTTP completion means the launch
request was accepted; it does not prove a native window became visible.
Remote/headless host limitations remain those of the existing action.
## Presentation and failures
Reuse translated folder copy when possible, adding a localized host-location hint
where needed. The control has an accessible name, keyboard activation, tooltip,
loading indicator, and disabled no-session/loading/unavailable states. Pending
folder launches are shared per session across all controls and clear only when
the initiating request settles; another session remains independent. Catch rejected requests
and use the existing localized toast pattern in `useOpenSessionInEditor` so callers
do not leave unhandled promise rejections. Return null after a surfaced error.
On phones, a visible Files menu opens a short temporary choice surface. The shared
mobile picker supplies one scrolling body, safe-area spacing, dismissal and focus
return. Its worktree rows have at least 44px hit areas. Keep desktop 28px control
geometry; do not inherit unconditional touch sizing into `TopbarToolsGroup`.
No layout/editor preferences or task runtime state change.
## Verification boundary
Frontend unit tests prove payload selection, stale-session handling, missing-session
behavior, failure recovery, and editor independence. Playwright proves the actual
button/menu flows and request payload on desktop and phone. Stub the native-opening
HTTP response in UI tests rather than launching file managers on CI. Existing Go
editor tests guard session/worktree resolution. A macOS smoke check proves Finder
opens the selected folder; record it separately if no macOS host is available.
## Host availability
The existing GET `/api/v1/editors` response includes `folder_opening_available`.
The editors service uses `exec.LookPath` for the platform command (`open`,
`xdg-open`, or `explorer`); unsupported platforms report false. The same command
mapping is used for opening, and the POST operation rejects unavailable openers.
The editors boot payload includes `folderOpeningAvailable` alongside its loaded
items. The shared editors store retains the capability; loaded editor items with
an unknown capability still trigger discovery. Each effect reads the live store
before claiming discovery so simultaneous consumers share one request/retry chain. Transient failure gets one delayed
retry while controls remain disabled; repeated failure settles to false. Unknown,
failed, or false discovery disables folder-opening controls in the task toolbar,
Files menu, and `file-actions-dropdown.tsx`, plus `useOpenSessionFolder`. Editor
entries retain their existing `editors` and `useOpenSessionInEditor` behavior.
No desktop dialog or mobile bottom sheet opens while unavailable. This checks
executable installation, like IDE discovery; launch-time failures still show errors.