From 32d640f264c19adbcc7ac7406b0c870d62d5c5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:46:09 +0100 Subject: [PATCH] feat: open task folders with host availability checks (#3784) --- .../backendapp/boot_state_editors_test.go | 47 ++++ .../internal/backendapp/boot_state_routes.go | 7 +- .../internal/editors/controller/controller.go | 2 +- apps/backend/internal/editors/dto/dto.go | 3 +- .../internal/editors/handlers/handlers.go | 2 +- .../editors/handlers/handlers_test.go | 1 + .../internal/editors/service/folder.go | 32 +++ .../internal/editors/service/folder_test.go | 43 ++++ .../internal/editors/service/service.go | 16 +- .../editors/file-actions-dropdown.tsx | 17 +- apps/web/components/task/file-browser-data.ts | 6 +- .../components/task/file-browser-header.tsx | 8 +- .../task/file-browser-responsive.test.tsx | 2 + .../task/file-browser-toolbar.test.tsx | 5 +- .../components/task/file-browser-toolbar.tsx | 67 +++++- apps/web/components/task/file-browser.tsx | 13 +- .../task/open-task-folder-button.test.tsx | 149 +++++++++++++ .../task/open-task-folder-button.tsx | 46 ++++ .../components/task/task-folder-picker.tsx | 78 +++++++ apps/web/components/task/task-top-bar.tsx | 2 + apps/web/e2e/helpers/api-client.ts | 7 +- apps/web/e2e/helpers/open-task-folder.ts | 47 ++++ .../tests/task/add-workspace-sources.spec.ts | 8 + .../task/mobile-add-workspace-sources.spec.ts | 5 + .../task/mobile-open-task-folder.spec.ts | 121 +++++++++++ .../e2e/tests/task/open-task-folder.spec.ts | 148 +++++++++++++ .../domains/settings/use-editors.test.ts | 96 +++++++++ .../web/hooks/domains/settings/use-editors.ts | 20 +- .../web/hooks/use-open-session-folder.test.ts | 152 +++++++++++++ apps/web/hooks/use-open-session-folder.ts | 61 +++++- apps/web/hooks/use-task-folder-action.ts | 51 +++++ apps/web/lib/api/domains/session-api.test.ts | 30 +++ apps/web/lib/api/domains/session-api.ts | 12 +- apps/web/lib/state/app-state-types.ts | 2 +- .../slices/settings/settings-slice.test.ts | 13 ++ .../state/slices/settings/settings-slice.ts | 5 +- apps/web/lib/state/slices/settings/types.ts | 3 +- apps/web/lib/types/http.ts | 1 + apps/web/src/locales/en/editors.json | 6 +- apps/web/src/locales/pseudo/editors.json | 6 +- apps/web/src/locales/pt-pt/editors.json | 6 +- apps/web/src/locales/zh-cn/editors.json | 6 +- apps/web/src/locales/zh-hk/editors.json | 6 +- apps/web/src/locales/zh-tw/editors.json | 6 +- docs/plans/open-task-folder/plan.md | 174 +++++++++++++++ .../task-01-folder-shortcut.md | 203 ++++++++++++++++++ docs/public/developer-tools.md | 8 + .../tasks/requirements/open-task-folder.md | 39 ++++ .../tasks/system-design/open-task-folder.md | 102 +++++++++ 49 files changed, 1826 insertions(+), 64 deletions(-) create mode 100644 apps/backend/internal/backendapp/boot_state_editors_test.go create mode 100644 apps/backend/internal/editors/service/folder.go create mode 100644 apps/backend/internal/editors/service/folder_test.go create mode 100644 apps/web/components/task/open-task-folder-button.test.tsx create mode 100644 apps/web/components/task/open-task-folder-button.tsx create mode 100644 apps/web/components/task/task-folder-picker.tsx create mode 100644 apps/web/e2e/helpers/open-task-folder.ts create mode 100644 apps/web/e2e/tests/task/mobile-open-task-folder.spec.ts create mode 100644 apps/web/e2e/tests/task/open-task-folder.spec.ts create mode 100644 apps/web/hooks/domains/settings/use-editors.test.ts create mode 100644 apps/web/hooks/use-open-session-folder.test.ts create mode 100644 apps/web/hooks/use-task-folder-action.ts create mode 100644 docs/plans/open-task-folder/plan.md create mode 100644 docs/plans/open-task-folder/task-01-folder-shortcut.md create mode 100644 docs/specs/tasks/requirements/open-task-folder.md create mode 100644 docs/specs/tasks/system-design/open-task-folder.md diff --git a/apps/backend/internal/backendapp/boot_state_editors_test.go b/apps/backend/internal/backendapp/boot_state_editors_test.go new file mode 100644 index 0000000000..07c39402e2 --- /dev/null +++ b/apps/backend/internal/backendapp/boot_state_editors_test.go @@ -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) + } + }) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index ec50b2709a..fc364f945e 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -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, } } diff --git a/apps/backend/internal/editors/controller/controller.go b/apps/backend/internal/editors/controller/controller.go index f886739e11..f851762a23 100644 --- a/apps/backend/internal/editors/controller/controller.go +++ b/apps/backend/internal/editors/controller/controller.go @@ -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) { diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go index 2de747bb05..4b03ec292e 100644 --- a/apps/backend/internal/editors/dto/dto.go +++ b/apps/backend/internal/editors/dto/dto.go @@ -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 { diff --git a/apps/backend/internal/editors/handlers/handlers.go b/apps/backend/internal/editors/handlers/handlers.go index c3326ecb2d..e82a033d13 100644 --- a/apps/backend/internal/editors/handlers/handlers.go +++ b/apps/backend/internal/editors/handlers/handlers.go @@ -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 diff --git a/apps/backend/internal/editors/handlers/handlers_test.go b/apps/backend/internal/editors/handlers/handlers_test.go index 40b0c376a3..79d6d3dbf8 100644 --- a/apps/backend/internal/editors/handlers/handlers_test.go +++ b/apps/backend/internal/editors/handlers/handlers_test.go @@ -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}, } diff --git a/apps/backend/internal/editors/service/folder.go b/apps/backend/internal/editors/service/folder.go new file mode 100644 index 0000000000..e3e49837f0 --- /dev/null +++ b/apps/backend/internal/editors/service/folder.go @@ -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 +} diff --git a/apps/backend/internal/editors/service/folder_test.go b/apps/backend/internal/editors/service/folder_test.go new file mode 100644 index 0000000000..1650baaaa6 --- /dev/null +++ b/apps/backend/internal/editors/service/folder_test.go @@ -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") + } +} diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index 55bf6d43d9..ba2c7d8e5d 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -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) diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx index e72ec22bdd..f268397240 100644 --- a/apps/web/components/editors/file-actions-dropdown.tsx +++ b/apps/web/components/editors/file-actions-dropdown.tsx @@ -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) { {t("editors:copyPath")} - + {t("editors:openFolder")} diff --git a/apps/web/components/task/file-browser-data.ts b/apps/web/components/task/file-browser-data.ts index 4290879c7e..ce06c5fd56 100644 --- a/apps/web/components/task/file-browser-data.ts +++ b/apps/web/components/task/file-browser-data.ts @@ -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, diff --git a/apps/web/components/task/file-browser-header.tsx b/apps/web/components/task/file-browser-header.tsx index 4370b4010c..ccc7995401 100644 --- a/apps/web/components/task/file-browser-header.tsx +++ b/apps/web/components/task/file-browser-header.tsx @@ -14,7 +14,9 @@ export type FileBrowserHeaderProps = { expandedPathsSize: number; onCopyPath: (value: string) => void | Promise; 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} diff --git a/apps/web/components/task/file-browser-responsive.test.tsx b/apps/web/components/task/file-browser-responsive.test.tsx index f0242c2a0a..60954a4dff 100644 --- a/apps/web/components/task/file-browser-responsive.test.tsx +++ b/apps/web/components/task/file-browser-responsive.test.tsx @@ -26,6 +26,8 @@ vi.mock("@/components/state-provider", () => ({ selector({ sessionWorktreesBySessionId: { itemsBySessionId: {} }, workspaceFilesRefresh: { bySessionId: {} }, + worktrees: { items: {} }, + repositories: { itemsByWorkspaceId: {} }, }), useAppStoreApi: () => ({ getState: () => ({ workspaceRestoration: { byEnvironmentId: {} } }), diff --git a/apps/web/components/task/file-browser-toolbar.test.tsx b/apps/web/components/task/file-browser-toolbar.test.tsx index fa5da6fb4f..f4566a18f8 100644 --- a/apps/web/components/task/file-browser-toolbar.test.tsx +++ b/apps/web/components/task/file-browser-toolbar.test.tsx @@ -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(); }); diff --git a/apps/web/components/task/file-browser-toolbar.tsx b/apps/web/components/task/file-browser-toolbar.tsx index 13ff12edab..309dee5ac7 100644 --- a/apps/web/components/task/file-browser-toolbar.tsx +++ b/apps/web/components/task/file-browser-toolbar.tsx @@ -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 ( + + {isOpening ? ( + + ) : ( + + )} + {t("task:openWorkspaceFolder")} + + ); +} + 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(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({ )} - - - {t("task:openWorkspaceFolder")} - + { + openFolderAfterCloseRef.current = true; + }} + /> ); @@ -304,6 +349,8 @@ export function FileBrowserToolbar({ onCopyPath, onStartCreate, onOpenFolder, + isOpeningFolder, + isFolderDisabled, onStartSearch, onCollapseAll, showCreateButton, @@ -346,6 +393,8 @@ export function FileBrowserToolbar({ diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 21754fdcbd..8993cc4556 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -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(null); const containerRef = useRef(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} /> + + {folderAction.isLoading ? t("editors:openingFolder") : ""} + + {elements} diff --git a/apps/web/components/task/open-task-folder-button.test.tsx b/apps/web/components/task/open-task-folder-button.test.tsx new file mode 100644 index 0000000000..884a41527f --- /dev/null +++ b/apps/web/components/task/open-task-folder-button.test.tsx @@ -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 ( + + + + ); +} + +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(); +}); diff --git a/apps/web/components/task/open-task-folder-button.tsx b/apps/web/components/task/open-task-folder-button.tsx new file mode 100644 index 0000000000..eece018436 --- /dev/null +++ b/apps/web/components/task/open-task-folder-button.tsx @@ -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 ( + <> + + + + + + + + {sessionId ? t("editors:openFolderOnHost") : t("task:selectASessionToOpenIts")} + + + + {action.isLoading ? t("editors:openingFolder") : ""} + + + + ); +} diff --git a/apps/web/components/task/task-folder-picker.tsx b/apps/web/components/task/task-folder-picker.tsx new file mode 100644 index 0000000000..545e51c320 --- /dev/null +++ b/apps/web/components/task/task-folder-picker.tsx @@ -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 }) { + const { t } = useTranslation(); + const { isMobile, isFinePointer } = useResponsiveBreakpoint(); + const touch = isMobile || !isFinePointer; + const choices = ( +
+ {action.options.map((option) => ( + + ))} +
+ ); + const title = t("editors:chooseFolder"); + const description = t("editors:openFolderOnHost"); + if (touch) { + return ( + + {choices} + + ); + } + return ( + + + + {title} + {description} + +
+ {choices} +
+ +
+
+ ); +} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index cb1257740d..f9e38f6be7 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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} /> + )} {showDebugToggle && ( diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 4903ad7fbf..53ce2afece 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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; }>; diff --git a/apps/web/e2e/helpers/open-task-folder.ts b/apps/web/e2e/helpers/open-task-folder.ts new file mode 100644 index 0000000000..61dedc9822 --- /dev/null +++ b/apps/web/e2e/helpers/open-task-folder.ts @@ -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 }, + }); + }); +} diff --git a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts index 6cb148983f..2634083ff7 100644 --- a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts +++ b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts index ddc01f8c26..57db3ded94 100644 --- a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts +++ b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts @@ -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) => diff --git a/apps/web/e2e/tests/task/mobile-open-task-folder.spec.ts b/apps/web/e2e/tests/task/mobile-open-task-folder.spec.ts new file mode 100644 index 0000000000..1543939092 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-open-task-folder.spec.ts @@ -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); +}); diff --git a/apps/web/e2e/tests/task/open-task-folder.spec.ts b/apps/web/e2e/tests/task/open-task-folder.spec.ts new file mode 100644 index 0000000000..84f0b819bb --- /dev/null +++ b/apps/web/e2e/tests/task/open-task-folder.spec.ts @@ -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); +}); diff --git a/apps/web/hooks/domains/settings/use-editors.test.ts b/apps/web/hooks/domains/settings/use-editors.test.ts new file mode 100644 index 0000000000..c3a410197a --- /dev/null +++ b/apps/web/hooks/domains/settings/use-editors.test.ts @@ -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; +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); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-editors.ts b/apps/web/hooks/domains/settings/use-editors.ts index 060041a8c3..c680909473 100644 --- a/apps/web/hooks/domains/settings/use-editors.ts +++ b/apps/web/hooks/domains/settings/use-editors.ts @@ -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 }; } diff --git a/apps/web/hooks/use-open-session-folder.test.ts b/apps/web/hooks/use-open-session-folder.test.ts new file mode 100644 index 0000000000..9760512846 --- /dev/null +++ b/apps/web/hooks/use-open-session-folder.test.ts @@ -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; + 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; + 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); + }); +}); diff --git a/apps/web/hooks/use-open-session-folder.ts b/apps/web/hooks/use-open-session-folder.ts index 2cc080a1e6..789b244ced 100644 --- a/apps/web/hooks/use-open-session-folder.ts +++ b/apps/web/hooks/use-open-session-folder.ts @@ -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 }>(() => ({ + 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, }; } diff --git a/apps/web/hooks/use-task-folder-action.ts b/apps/web/hooks/use-task-folder-action.ts new file mode 100644 index 0000000000..883188d123 --- /dev/null +++ b/apps/web/hooks/use-task-folder-action.ts @@ -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(null); + const opener = useRef(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(); + }, + }; +} diff --git a/apps/web/lib/api/domains/session-api.test.ts b/apps/web/lib/api/domains/session-api.test.ts index 1110d510cc..bf656866f8 100644 --- a/apps/web/lib/api/domains/session-api.test.ts +++ b/apps/web/lib/api/domains/session-api.test.ts @@ -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" }, + }); +}); diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts index 92d64ba234..6b8d2a9297 100644 --- a/apps/web/lib/api/domains/session-api.ts +++ b/apps/web/lib/api/domains/session-api.ts @@ -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 ?? {}), + }, }); } diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts index f62e086369..302ed0d9f5 100644 --- a/apps/web/lib/state/app-state-types.ts +++ b/apps/web/lib/state/app-state-types.ts @@ -314,7 +314,7 @@ export type AppState = KanbanSlice & { upsertRepositoryBranchPolicy: (policy: RepositoryBranchPolicy) => void; removeRepositoryBranchPolicy: (repositoryId: string, policyId: string) => void; setSettingsData: (next: Partial) => 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; diff --git a/apps/web/lib/state/slices/settings/settings-slice.test.ts b/apps/web/lib/state/slices/settings/settings-slice.test.ts index f0c9042a51..d4d66fb8b1 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.test.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index c7b5fa2772..2dc5810223 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -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) => diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index c2cf224154..851b0db8ac 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -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; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ea57ea02b4..7a3099f62a 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -759,6 +759,7 @@ export type EditorOption = { }; export type EditorsResponse = { + folder_opening_available?: boolean; editors: EditorOption[]; }; diff --git a/apps/web/src/locales/en/editors.json b/apps/web/src/locales/en/editors.json index 6babf09c0f..3f6797fcb0 100644 --- a/apps/web/src/locales/en/editors.json +++ b/apps/web/src/locales/en/editors.json @@ -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…" } diff --git a/apps/web/src/locales/pseudo/editors.json b/apps/web/src/locales/pseudo/editors.json index e83310de79..a401a400d2 100644 --- a/apps/web/src/locales/pseudo/editors.json +++ b/apps/web/src/locales/pseudo/editors.json @@ -90,5 +90,9 @@ "underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)", "failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ", "downloadFile": "Ďōŵńĺōàď ƒĩĺē", - "symlink": "Śŷḿĺĩńķ" + "symlink": "Śŷḿĺĩńķ", + "failedToOpenFolder": "Ƒàĩĺēď ţō ōƥēń ƒōĺďēŕ", + "openFolderOnHost": "Ōƥēń ƒōĺďēŕ ōń ţĥē ḿàćĥĩńē ŕũńńĩńĝ Ķàńďēv", + "chooseFolder": "Ćĥōōśē à ƒōĺďēŕ", + "openingFolder": "Ōƥēńĩńĝ ƒōĺďēŕ…" } diff --git a/apps/web/src/locales/pt-pt/editors.json b/apps/web/src/locales/pt-pt/editors.json index d7c1f25ed1..8c6016bf07 100644 --- a/apps/web/src/locales/pt-pt/editors.json +++ b/apps/web/src/locales/pt-pt/editors.json @@ -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…" } diff --git a/apps/web/src/locales/zh-cn/editors.json b/apps/web/src/locales/zh-cn/editors.json index e587b35012..a7b2a6607f 100644 --- a/apps/web/src/locales/zh-cn/editors.json +++ b/apps/web/src/locales/zh-cn/editors.json @@ -90,5 +90,9 @@ "underlineCmdU": "下划线(Cmd+U)", "failedToOpenEditor": "打开编辑器失败", "downloadFile": "下载文件", - "symlink": "符号链接" + "symlink": "符号链接", + "failedToOpenFolder": "无法打开文件夹", + "openFolderOnHost": "在运行 Kandev 的计算机上打开文件夹", + "chooseFolder": "选择文件夹", + "openingFolder": "正在打开文件夹…" } diff --git a/apps/web/src/locales/zh-hk/editors.json b/apps/web/src/locales/zh-hk/editors.json index 1ec5218728..4fb5fc706d 100644 --- a/apps/web/src/locales/zh-hk/editors.json +++ b/apps/web/src/locales/zh-hk/editors.json @@ -90,5 +90,9 @@ "underlineCmdU": "下劃線(Cmd+U)", "failedToOpenEditor": "開啟編輯器失敗", "downloadFile": "下載檔案", - "symlink": "符號連結" + "symlink": "符號連結", + "failedToOpenFolder": "無法開啟資料夾", + "openFolderOnHost": "在執行 Kandev 的計算機上開啟資料夾", + "chooseFolder": "選擇資料夾", + "openingFolder": "正在開啟資料夾…" } diff --git a/apps/web/src/locales/zh-tw/editors.json b/apps/web/src/locales/zh-tw/editors.json index 20af98e832..d98493c7c2 100644 --- a/apps/web/src/locales/zh-tw/editors.json +++ b/apps/web/src/locales/zh-tw/editors.json @@ -90,5 +90,9 @@ "underlineCmdU": "下劃線(Cmd+U)", "failedToOpenEditor": "開啟編輯器失敗", "downloadFile": "下載檔案", - "symlink": "符號連結" + "symlink": "符號連結", + "failedToOpenFolder": "無法開啟資料夾", + "openFolderOnHost": "在執行 Kandev 的計算機上開啟資料夾", + "chooseFolder": "選擇資料夾", + "openingFolder": "正在開啟資料夾…" } diff --git a/docs/plans/open-task-folder/plan.md b/docs/plans/open-task-folder/plan.md new file mode 100644 index 0000000000..64affa9ef3 --- /dev/null +++ b/docs/plans/open-task-folder/plan.md @@ -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. diff --git a/docs/plans/open-task-folder/task-01-folder-shortcut.md b/docs/plans/open-task-folder/task-01-folder-shortcut.md new file mode 100644 index 0000000000..d35cc6a4ab --- /dev/null +++ b/docs/plans/open-task-folder/task-01-folder-shortcut.md @@ -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. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index d3c657708f..8b93116292 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -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.
diff --git a/docs/specs/tasks/requirements/open-task-folder.md b/docs/specs/tasks/requirements/open-task-folder.md new file mode 100644 index 0000000000..7d8c928f13 --- /dev/null +++ b/docs/specs/tasks/requirements/open-task-folder.md @@ -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) diff --git a/docs/specs/tasks/system-design/open-task-folder.md b/docs/specs/tasks/system-design/open-task-folder.md new file mode 100644 index 0000000000..cf15640d5c --- /dev/null +++ b/docs/specs/tasks/system-design/open-task-folder.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.