diff --git a/apps/web/components/task/editor-actions-dropdown.tsx b/apps/web/components/task/editor-actions-dropdown.tsx new file mode 100644 index 0000000000..e23c56a294 --- /dev/null +++ b/apps/web/components/task/editor-actions-dropdown.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useRef, type ReactNode } from "react"; +import { IconChevronDown, IconFolderOpen, IconLoader2 } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useTaskFolderAction } from "@/hooks/use-task-folder-action"; +import { TaskFolderPicker } from "./task-folder-picker"; + +export function EditorActionsDropdown({ + sessionId, + children, +}: { + sessionId: string | null; + children: ReactNode; +}) { + const { t } = useTranslation(); + const action = useTaskFolderAction(sessionId); + const triggerRef = useRef(null); + const selectedSessionRef = useRef(null); + + return ( + <> + + + + + { + const selectedSession = selectedSessionRef.current; + selectedSessionRef.current = null; + if (!selectedSession || selectedSession !== sessionId) return; + event.preventDefault(); + triggerRef.current?.focus(); + action.open(triggerRef.current ?? undefined); + }} + > + {children} + + { + selectedSessionRef.current = sessionId; + }} + > + {action.isLoading ? ( + + ) : ( + + )} + {t("editors:openFolder")} + + + + + {action.isLoading ? t("editors:openingFolder") : ""} + + + + ); +} diff --git a/apps/web/components/task/open-task-folder-button.test.tsx b/apps/web/components/task/editors-menu.test.tsx similarity index 55% rename from apps/web/components/task/open-task-folder-button.test.tsx rename to apps/web/components/task/editors-menu.test.tsx index 884a41527f..fe11c6cdbe 100644 --- a/apps/web/components/task/open-task-folder-button.test.tsx +++ b/apps/web/components/task/editors-menu.test.tsx @@ -1,16 +1,25 @@ 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"; +import { EditorsMenu } from "./editors-menu"; +import type { EditorOption } from "@/lib/types/http"; const fixtures = vi.hoisted(() => ({ folderOpeningAvailable: true, + editors: [] as EditorOption[], + openEditor: vi.fn(), 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 }), + useEditors: () => ({ + editors: fixtures.editors, + folderOpeningAvailable: fixtures.folderOpeningAvailable, + }), +})); +vi.mock("@/hooks/use-open-session-in-editor", () => ({ + useOpenSessionInEditor: () => ({ open: fixtures.openEditor, isLoading: false }), })); vi.mock("@/lib/api", () => ({ openSessionFolder: fixtures.openSessionFolder })); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: fixtures.toast }) })); @@ -19,18 +28,22 @@ vi.mock("@/hooks/domains/session/use-session-worktrees", () => ({ })); vi.mock("@/components/state-provider", () => ({ useAppStore: (select: (state: unknown) => unknown) => - select({ repositories: { itemsByWorkspaceId: {} } }), + select({ + repositories: { itemsByWorkspaceId: {} }, + userSettings: { defaultEditorId: "editor-1" }, + }), })); vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile: false, isFinePointer: true }), })); const OPEN_FOLDER_LABEL = "Open folder"; +const EDITOR_MENU_TRIGGER_TEST_ID = "editors-menu-list"; function view(sessionId: string | null) { return ( - + ); } @@ -38,16 +51,30 @@ function view(sessionId: string | null) { beforeEach(() => { vi.clearAllMocks(); fixtures.worktrees = []; + fixtures.editors = []; 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 +async function openMenu() { + fireEvent.keyDown(screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID), { key: "ArrowDown" }); + return screen.findByRole("menu"); +} +async function chooseFolder() { + await openMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL })); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); +} + describe("task folder action", () => { it("opens without any editor configuration", async () => { render(view("s1")); - fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL })); + expect((screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID) as HTMLButtonElement).disabled).toBe( + false, + ); + await chooseFolder(); await waitFor(() => expect(fixtures.openSessionFolder).toHaveBeenCalledWith( "s1", @@ -58,14 +85,14 @@ describe("task folder action", () => { }); it("disables opening without a session", () => { render(view(null)); - expect( - (screen.getByRole("button", { name: OPEN_FOLDER_LABEL }) as HTMLButtonElement).disabled, - ).toBe(true); + expect((screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID) 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 chooseFolder(); await waitFor(() => expect(fixtures.openSessionFolder).toHaveBeenCalledWith( "s1", @@ -80,9 +107,9 @@ describe("task folder action", () => { { id: "wt-2", repositoryId: "r2", path: "/repo-two", branch: "feature" }, ]; render(view("s1")); - fireEvent.click(screen.getByRole("button", { name: OPEN_FOLDER_LABEL })); + await chooseFolder(); expect(fixtures.openSessionFolder).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: /repo-two/ })); + fireEvent.click(await screen.findByRole("button", { name: /repo-two/ })); await waitFor(() => expect(fixtures.openSessionFolder).toHaveBeenCalledWith( "s1", @@ -97,8 +124,8 @@ describe("task folder action", () => { { 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(); + await chooseFolder(); + expect(await screen.findByRole("dialog")).toBeTruthy(); rerender(view("s2")); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(fixtures.openSessionFolder).not.toHaveBeenCalled(); @@ -111,8 +138,8 @@ describe("task folder action", () => { { 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 chooseFolder(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(fixtures.openSessionFolder).not.toHaveBeenCalled(); }); @@ -124,26 +151,74 @@ describe("task folder action", () => { }), ); render(view("s1")); - const button = screen.getByRole("button", { name: OPEN_FOLDER_LABEL }); - fireEvent.click(button); - expect((button as HTMLButtonElement).disabled).toBe(true); + await chooseFolder(); + await openMenu(); + expect( + screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }).getAttribute("aria-disabled"), + ).toBe("true"); await act(async () => { finish({ success: true }); }); - expect((button as HTMLButtonElement).disabled).toBe(false); + expect( + screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }).getAttribute("aria-disabled"), + ).not.toBe("true"); }); }); -it("blocks the picker and requests when the host opener is missing", () => { +it("blocks the picker and requests when the host opener is missing", async () => { 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); + await openMenu(); + const button = screen.getByRole("menuitem", { name: OPEN_FOLDER_LABEL }); + expect(button.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(button); expect(screen.queryByRole("dialog")).toBeNull(); expect(fixtures.openSessionFolder).not.toHaveBeenCalled(); }); + +it("reports folder errors and permits retry without launching an editor", async () => { + fixtures.openSessionFolder.mockRejectedValueOnce(new Error("host failure")); + render(view("s1")); + await chooseFolder(); + await waitFor(() => + expect(fixtures.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to open folder", variant: "error" }), + ), + ); + await chooseFolder(); + await waitFor(() => expect(fixtures.openSessionFolder).toHaveBeenCalledTimes(2)); + expect(fixtures.openEditor).not.toHaveBeenCalled(); +}); + +it("keeps editor launching available when the folder opener is unavailable", async () => { + fixtures.folderOpeningAvailable = false; + fixtures.editors = [ + { + id: "editor-1", + type: "editor", + name: "Test editor", + kind: "custom", + installed: true, + enabled: true, + }, + ]; + render(view("s1")); + fireEvent.click(screen.getByTestId("editors-menu-open")); + expect(fixtures.openEditor).toHaveBeenCalledWith({ editorId: "editor-1", worktreeId: undefined }); + await openMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Test editor" })); + expect(fixtures.openEditor).toHaveBeenCalledTimes(2); + expect(fixtures.openSessionFolder).not.toHaveBeenCalled(); +}); + +// @covers AC-TASKS-OPEN-FOLDER-001.1 +it("announces both editor and folder actions on the dropdown trigger", () => { + render(view("s1")); + expect(screen.getByRole("button", { name: "Editor and folder actions" })).toBe( + screen.getByTestId(EDITOR_MENU_TRIGGER_TEST_ID), + ); +}); diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx index 811ded5ed6..b728e24a45 100644 --- a/apps/web/components/task/editors-menu.tsx +++ b/apps/web/components/task/editors-menu.tsx @@ -1,7 +1,8 @@ "use client"; import { useMemo } from "react"; -import { IconChevronDown, IconCode, IconLoader2 } from "@tabler/icons-react"; +import { EditorActionsDropdown } from "./editor-actions-dropdown"; +import { IconCode, IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { DropdownMenu, @@ -202,33 +203,20 @@ export function EditorsMenu({ activeSessionId, embeddedVscodeSupported }: Editor worktreeOptions={worktreeOptions} onOpen={(worktreeId) => openWith(resolvedEditorId, worktreeId)} /> - - - - - - {enabledEditors.length === 0 ? ( - {t("task:noEditorsAvailable")} - ) : ( - enabledEditors.map((editor: EditorOption) => ( - - )) - )} - - + + {enabledEditors.length === 0 ? ( + {t("task:noEditorsAvailable")} + ) : ( + enabledEditors.map((editor: EditorOption) => ( + + )) + )} + ); } diff --git a/apps/web/components/task/open-task-folder-button.tsx b/apps/web/components/task/open-task-folder-button.tsx deleted file mode 100644 index eece018436..0000000000 --- a/apps/web/components/task/open-task-folder-button.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"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-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index f9e38f6be7..cb1257740d 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -8,7 +8,6 @@ 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"; @@ -357,7 +356,6 @@ function TopbarToolsGroup({ activeSessionId={activeSessionId ?? null} embeddedVscodeSupported={embeddedVscodeSupported ?? false} /> - )} {showDebugToggle && ( diff --git a/apps/web/e2e/tests/task/open-task-folder.spec.ts b/apps/web/e2e/tests/task/open-task-folder.spec.ts index 84f0b819bb..be4a1ea13c 100644 --- a/apps/web/e2e/tests/task/open-task-folder.spec.ts +++ b/apps/web/e2e/tests/task/open-task-folder.spec.ts @@ -7,7 +7,7 @@ import { 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 ({ +test("editor dropdown opens the folder without configured editors and recovers from errors", async ({ testPage, apiClient, seedData, @@ -24,7 +24,9 @@ test("task tools open the folder beside the IDE action and recover from errors", executor_profile_id: seedData.worktreeExecutorProfileId, }, ); - await mockFolderAvailability(testPage, true); + await testPage.route("**/api/v1/editors", (route) => + route.fulfill({ json: { editors: [], folder_opening_available: true } }), + ); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); @@ -39,22 +41,28 @@ test("task tools open the folder beside the IDE action and recover from errors", json: attempts === 1 ? { error: "failed to open folder" } : { success: true }, }); }); - const folder = testPage.getByTestId("open-task-folder"); + await expect(testPage.getByTestId("open-task-folder")).toHaveCount(0); + await expect(testPage.getByTestId("editors-menu-open")).toBeDisabled(); + const trigger = testPage.getByTestId("editors-menu-list"); + await expect(trigger).toHaveAccessibleName("Editor and folder actions"); + await expect(trigger).toBeEnabled(); + expect((await trigger.boundingBox())!.height).toBeCloseTo(28, 0); + await trigger.click(); + const folder = testPage.getByRole("menuitem", { name: "Open folder", exact: true }); 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 testPage.screenshot({ + path: test.info().outputPath("desktop-folder-menu.png"), + animations: "disabled", + }); await folder.click(); await expect(testPage.getByText("Failed to open folder", { exact: true })).toBeVisible(); + await trigger.click(); 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 trigger.click(); await expect(folder).toBeEnabled(); }); @@ -99,17 +107,23 @@ test("desktop folder picker opens only the selected repository", async ({ payloads.push(route.request().postDataJSON()); await route.fulfill({ json: { success: true } }); }); - const trigger = testPage.getByTestId("open-task-folder"); + const trigger = testPage.getByTestId("editors-menu-list"); await trigger.focus(); await testPage.keyboard.press("Enter"); + await testPage.getByRole("menuitem", { name: "Open folder", exact: true }).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 testPage.screenshot({ + path: test.info().outputPath("desktop-folder-picker.png"), + animations: "disabled", + }); 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 testPage.getByRole("menuitem", { name: "Open folder", exact: true }).click(); await picker.getByRole("button", { name: /Folder second repo/ }).click(); await expect.poll(() => payloads).toEqual([{ worktree_id: worktreeId }]); await expect(picker).not.toBeVisible(); @@ -142,7 +156,8 @@ test("missing host folder opener disables the action before any picker opens", a const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle(); - await expect(testPage.getByTestId("open-task-folder")).toBeDisabled(); + await testPage.getByTestId("editors-menu-list").click(); + await expect(testPage.getByRole("menuitem", { name: "Open folder", exact: true })).toBeDisabled(); await expect(testPage.getByRole("dialog", { name: "Choose a folder" })).not.toBeVisible(); expect(requests).toBe(0); }); diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index a19f31c03b..cea9b256e8 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "Use Kandev-managed Git credentials for advanced checkout options.", "toolPayloadRemovedAt": "Tool details removed on {{date}}.", "toolPayloadRemoved": "Tool details removed.", - "openTaskSwitcherFor": "Open task switcher: {{title}}" + "openTaskSwitcherFor": "Open task switcher: {{title}}", + "editorActions": "Editor and folder actions" } diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index b99022e264..b1c935bb35 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "高度なチェックアウトオプションには、Kandev 管理の Git 認証情報を使って。", "toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。", "toolPayloadRemoved": "ツール詳細は削除済み。", - "openTaskSwitcherFor": "タスク切替を開く: {{title}}" + "openTaskSwitcherFor": "タスク切替を開く: {{title}}", + "editorActions": "エディターとフォルダーの操作" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 4d67ca164b..faf42e5a44 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "Ũśē Ķàńďēv-ḿàńàĝēď Ĝĩţ ćŕēďēńţĩàĺś ƒōŕ àďvàńćēď ćĥēćķōũţ ōƥţĩōńś.", "toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.", "toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď.", - "openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}" + "openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}", + "editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index d389bccd9d..194e6c3d8f 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "Utilize credenciais Git geridas pelo Kandev para as opções avançadas de obtenção.", "toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.", "toolPayloadRemoved": "Detalhes da ferramenta removidos.", - "openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}" + "openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}", + "editorActions": "Ações do editor e da pasta" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 9aed805616..dbb2d1a1a7 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "请使用由 Kandev 管理的 Git 凭据启用高级检出选项。", "toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。", "toolPayloadRemoved": "工具详细数据已删除。", - "openTaskSwitcherFor": "打开任务切换器:{{title}}" + "openTaskSwitcherFor": "打开任务切换器:{{title}}", + "editorActions": "编辑器和文件夹操作" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 8482925c34..6f4ddfcabc 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資料啓用高級檢出選項。", "toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。", "toolPayloadRemoved": "工具詳細數據已刪除。", - "openTaskSwitcherFor": "開啟任務切換器:{{title}}" + "openTaskSwitcherFor": "開啟任務切換器:{{title}}", + "editorActions": "編輯器和資料夾操作" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index ed4b19c7d1..e256e2829f 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2373,5 +2373,6 @@ "checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資訊啟用高階檢出選項。", "toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。", "toolPayloadRemoved": "工具詳細資料已刪除。", - "openTaskSwitcherFor": "開啟任務切換器:{{title}}" + "openTaskSwitcherFor": "開啟任務切換器:{{title}}", + "editorActions": "編輯器和資料夾操作" } diff --git a/docs/plans/editor-dropdown-folder/plan.md b/docs/plans/editor-dropdown-folder/plan.md new file mode 100644 index 0000000000..bf00022905 --- /dev/null +++ b/docs/plans/editor-dropdown-folder/plan.md @@ -0,0 +1,181 @@ +--- +created: 2026-09-22 +status: implemented +requirements: + - REQ-TASKS-OPEN-FOLDER-001 +system_design: + - ../../specs/tasks/system-design/open-task-folder.md +legacy_specs: [] +--- + +# Implementation plan: Editor dropdown folder action + +## Overview + +One sequential work order relocates the standalone button added in PR #3784 +(commit `32d640f26`) into the usual editor dropdown. Estimate: 45-90 minutes +including focused browser verification. Tasks owns this capability because the +selected task session determines the target workspace. + +Confirmed intent: remove the added standalone control and expose folder opening +inside the editor dropdown. Keep existing native opening, host availability, +worktree selection, and Files-menu access. This is a targeted UI relocation, not +a wholesale revert of PR #3784. No unresolved product decisions. + +## Scope + +In scope: dropdown composition and focus, removal of unused button, migration of +its regression tests, desktop browser flow, phone regression and public usage copy. +Out of scope: backend changes, editor preferences, archived-task visibility, +remote mounting, and redesigning phone task navigation. + +## Technical approach + +Follow the [design](../../specs/tasks/system-design/open-task-folder.md). +Reuse the existing folder action and responsive picker. Decouple dropdown +availability from enabled-editor count while preserving primary editor behavior. +Extract a focused presentation component only if needed for component limits. +Update `docs/public/developer-tools.md` with the new menu path during implementation; +leave published instructions matching shipped behavior during this design turn. +No ADR: the existing ownership, transport and native-launch boundaries remain. + +## ASCII UI preview + +### UI-01: Desktop editor dropdown, expanded + +Before: `[Open in editor | v] [Folder]` + +```text +[Open in editor | v] + +------------------------+ + | Existing editor entries| + |------------------------| + | Open folder | + +------------------------+ + -> Choose a folder (multiple worktrees only) +``` + +### UI-02: Phone Files workspace actions, expanded + +```text +[Files ...] + [Add repositories ] + [Open workspace folder ] + -> [Choose a folder ] + [repo-a / branch-a ] + [repo-b / branch-b ] +``` + +Structural requirements: no standalone toolbar folder button; folder row follows +editor choices, independently enabled. No editors: disabled editor primary action +and no-editors row, usable dropdown and available folder action. No session: +dropdown disabled. Unknown/unavailable opener or pending request: folder row disabled. +Opening has a localized live status; errors produce a retryable localized toast. +Desktop controls remain 28px. Phone retains its visible Files trigger and existing +inset menu/picker, 44px targets, one internal scroller and safe-area spacing; no +horizontal page overflow. This temporary choice uses the shipped +`MobilePickerSheet` exemplar. Menu closes before picker opens; dismissal restores +the persistent trigger. Icons and spacing are illustrative. +Maps to AC-TASKS-OPEN-FOLDER-001.1 through .4 and desktop/mobile folder E2E suites. + +## Tests + +Migrate `open-task-folder-button.test.tsx` coverage into `editors-menu.test.tsx` +before removing the old suite. Add failing tests for placement/removal, zero +editors, keyboard activation, host unavailability, busy state, errors/retry, +single/multiple worktrees, cancellation/focus restoration and session changes. +Preserve editor launching/default behavior. Use the hook suite for duplicate +suppression and payload failures (AC .1-.3, .5). Update all three desktop folder +browser scenarios to enter through the dropdown, add zero-editor coverage, +and assert absence of the separate control. Retain phone folder scenarios for +selection and unavailable hosts, with touch geometry and no-overflow checks +(AC .4). Stub native launch HTTP responses; do not open the host GUI in CI. +Inspect desktop and phone screenshots against UI-01/UI-02. + +## Work orders + +- [x] [Task 01: Relocate folder action](task-01-relocate-folder-action.md) + +## Verification commands + +From repository root; install workspace dependencies from `apps/` if missing. +The managed E2E runner builds the backend, Vite assets and plugin fixtures; +separate build commands are unnecessary. Ensure Go is on PATH and set +PLAYWRIGHT_BROWSERS_PATH to a writable installed browser cache when required. + +```bash +(cd apps/web && pnpm exec vitest run components/task/editors-menu.test.tsx components/task/editors-menu-availability.test.ts components/task/editor-worktree-options.test.ts components/task/task-top-bar.test.tsx hooks/use-open-session-folder.test.ts components/task/file-browser-toolbar.test.tsx) +(cd apps/web && NODE_OPTIONS=--max-old-space-size=4096 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) +node scripts/validate-public-docs.mjs +node --test scripts/validate-public-docs.test.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Verification results + +Implemented on 2026-09-23. + +- RED: the migrated test failed because the editor dropdown was disabled with zero + editors; the remaining folder-menu regressions failed because its folder entry + did not exist. The error/retry regression also failed before implementation. +- GREEN: `pnpm exec vitest run` with the six suites listed above passed 49 tests. + All eight original folder-button behaviors are retained in the migrated suite, + plus error/retry and independent editor-launch coverage. +- `NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck` passed. The default + 2 GiB heap exhausted memory; rerunning with 4 GiB resolved that environment limit. +- Changed-file ESLint passed for both editor components, the migrated test, + task topbar and desktop E2E spec. +- Managed E2E built the Go runtime/helpers, production Vite assets and plugin + fixture. Desktop folder suite passed 3/3; unchanged phone folder suite passed + 2/2. Final runs reused those fresh artifacts with `--no-build`, Go on PATH, + and `PLAYWRIGHT_BROWSERS_PATH=/tmp/kandev-folder-browsers`. +- `node scripts/validate-public-docs.mjs` passed (47 pages), and its test file + passed 62 tests. Spec catalog validation (299 decisions, 1108 specifications), + full spec lint and diff whitespace checks passed. + +### Localization baseline exception + +`pnpm run i18n:check` fails on 32 missing Japanese keys: 22 `executors:sshReachability*` +and 10 `task:launchWarning*` entries. `git diff --quiet HEAD -- apps/web/src/locales` +confirms all catalogs are unchanged. This change reuses existing localized copy. +The remaining five i18n checks were run directly and passed: Trans indices, +inline plurals, module-scope translations, em dashes, and non-JSX copy. +This unrelated catalog failure is retained as an explicit validation exception. + +### Browser evidence and limits + +Screenshots retained in `/tmp/editor-folder-evidence/`: desktop folder menu, +desktop repository picker and mobile repository picker. Phone evidence shows +an inset sheet, contained 44px rows and no horizontal overflow. Desktop tests +verify the removed button, 28px dropdown trigger, menu reachability without +editors, selected-worktree payload, keyboard use, cancellation focus and retry. +Native opening is HTTP-stubbed; these tests do not assert Finder/Explorer window +visibility. No backend native-launch behavior changed. + +## Risks + +The editor-count guard can accidentally hide folder opening. Dropdown focus +restoration can dismiss a newly opened picker unless opening waits for menu close. +The original package records an unrelated source-attachment E2E baseline failure; +this package targets the folder suites. Native window visibility remains outside +HTTP-stubbed browser evidence. + +## PR review remediation (2026-09-24) + +Claude's inline finding and summary suggestion identified the editor-only +accessible name on a menu that also opens folders. The trigger now announces +"Editor and folder actions" through `task:editorActions` in all six locales and +the pseudo locale; Traditional Chinese values use the repository converter. +The new focused regression failed before the change. + +Post-fix checks: `pnpm exec vitest run components/task/editors-menu.test.tsx` +passes 11 tests; changed-file ESLint passes; a fresh managed production build and +`pnpm e2e:run --host tests/task/open-task-folder.spec.ts` pass all 3 browser tests, +including the accessible-name assertion. The local full i18n check still reports +the previously recorded Japanese keys, which are already fixed on current main; +none of its findings concern the new label. diff --git a/docs/plans/editor-dropdown-folder/task-01-relocate-folder-action.md b/docs/plans/editor-dropdown-folder/task-01-relocate-folder-action.md new file mode 100644 index 0000000000..e5812770cc --- /dev/null +++ b/docs/plans/editor-dropdown-folder/task-01-relocate-folder-action.md @@ -0,0 +1,186 @@ +--- +id: "01-relocate-folder-action" +title: "Relocate folder action into editor dropdown" +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: Relocate folder action + +## Summary + +Replace the separate folder button with a folder item in the existing editor +menu. Reuse native opening and the responsive repository picker. + +## In scope + +Menu wiring/focus, removal of unused button, migrated and expanded regression +coverage, public usage copy and targeted verification. + +## Out of scope + +Backend/API changes, new native integrations, editor preferences and mobile +navigation redesign. + +## Acceptance + +- Folder opening is reachable through the editor dropdown with zero configured editors; no separate toolbar folder button remains. +- Existing worktree, unavailable/busy, failure/retry, cancellation and editor behavior pass focused regression tests, including phone Files access. +- Browser evidence matches the previews and public documentation names the new entry point. + +## ASCII UI preview + +See [combined preview](plan.md#ascii-ui-preview). + +### UI-01: Desktop editor dropdown, expanded + +Before: `[Open in editor | v] [Folder]` + +```text +[Open in editor | v] + +------------------------+ + | Existing editor entries| + |------------------------| + | Open folder | + +------------------------+ + -> Choose a folder (multiple worktrees only) +``` + +### UI-02: Phone Files workspace actions, expanded + +```text +[Files ...] + [Add repositories ] + [Open workspace folder ] + -> [Choose a folder ] + [repo-a / branch-a ] + [repo-b / branch-b ] +``` + +Structural requirements: no standalone toolbar folder button; folder row follows +editor choices, independently enabled. No editors: disabled editor primary action +and no-editors row, usable dropdown and available folder action. No session: +dropdown disabled. Unknown/unavailable opener or pending request: folder row disabled. +Opening has a localized live status; errors produce a retryable localized toast. +Desktop controls remain 28px. Phone retains its visible Files trigger and existing +inset menu/picker, 44px targets, one internal scroller and safe-area spacing; no +horizontal page overflow. This temporary choice uses the shipped +`MobilePickerSheet` exemplar. Menu closes before picker opens; dismissal restores +the persistent trigger. Icons and spacing are illustrative. +Maps to AC-TASKS-OPEN-FOLDER-001.1 through .4 and desktop/mobile folder E2E suites. + +## Verification + +Use TDD: migrate/add failing regression tests before changing production files. +Install dependencies from `apps/` if missing. The managed E2E runner builds +the backend, Vite assets and plugin fixtures. Ensure Go is on PATH and point +PLAYWRIGHT_BROWSERS_PATH at an installed browser cache. Run from repository root: + +```bash +(cd apps/web && pnpm exec vitest run components/task/editors-menu.test.tsx components/task/editors-menu-availability.test.ts components/task/editor-worktree-options.test.ts components/task/task-top-bar.test.tsx hooks/use-open-session-folder.test.ts components/task/file-browser-toolbar.test.tsx) +(cd apps/web && NODE_OPTIONS=--max-old-space-size=4096 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) +node scripts/validate-public-docs.mjs +node --test scripts/validate-public-docs.test.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task/editors-menu.tsx`, new `editor-actions-dropdown.tsx`, and `editors-menu.test.tsx`. +- `apps/web/components/task/task-top-bar.tsx` and its test. +- Remove `apps/web/components/task/open-task-folder-button.tsx` and migrate its test coverage before deleting its test file. +- `apps/web/e2e/tests/task/open-task-folder.spec.ts`; retain `mobile-open-task-folder.spec.ts`, updating only if regression evidence requires it. +- `docs/public/developer-tools.md`; locale catalogs only if existing localized copy cannot be reused. + +## Dependencies + +None. Shared `useTaskFolderAction`, `TaskFolderPicker`, and folder transport are shipped. + +## Risks + +Avoid focusing removed menu items or gating folder access on editor configuration. +Preserve independent editor/folder loading and per-session request suppression. + +## Parallelism + +`sequential` + +## Inputs + +[Requirements](../../specs/tasks/requirements/open-task-folder.md), +[design](../../specs/tasks/system-design/open-task-folder.md), +`WorkspaceActionsMenu` close-to-picker pattern in `file-browser-toolbar.tsx`, +and existing folder button/unit and desktop/phone E2E tests. + +## Results + +Implemented on 2026-09-23. + +- RED: the migrated test failed because the editor dropdown was disabled with zero + editors; the remaining folder-menu regressions failed because its folder entry + did not exist. The error/retry regression also failed before implementation. +- GREEN: `pnpm exec vitest run` with the six suites listed above passed 49 tests. + All eight original folder-button behaviors are retained in the migrated suite, + plus error/retry and independent editor-launch coverage. +- `NODE_OPTIONS=--max-old-space-size=4096 pnpm run typecheck` passed. The default + 2 GiB heap exhausted memory; rerunning with 4 GiB resolved that environment limit. +- Changed-file ESLint passed for both editor components, the migrated test, + task topbar and desktop E2E spec. +- Managed E2E built the Go runtime/helpers, production Vite assets and plugin + fixture. Desktop folder suite passed 3/3; unchanged phone folder suite passed + 2/2. Final runs reused those fresh artifacts with `--no-build`, Go on PATH, + and `PLAYWRIGHT_BROWSERS_PATH=/tmp/kandev-folder-browsers`. +- `node scripts/validate-public-docs.mjs` passed (47 pages), and its test file + passed 62 tests. Spec catalog validation (299 decisions, 1108 specifications), + full spec lint and diff whitespace checks passed. + +### Localization baseline exception + +`pnpm run i18n:check` fails on 32 missing Japanese keys: 22 `executors:sshReachability*` +and 10 `task:launchWarning*` entries. `git diff --quiet HEAD -- apps/web/src/locales` +confirms all catalogs are unchanged. This change reuses existing localized copy. +The remaining five i18n checks were run directly and passed: Trans indices, +inline plurals, module-scope translations, em dashes, and non-JSX copy. +This unrelated catalog failure is retained as an explicit validation exception. + +### Browser evidence and limits + +Screenshots retained in `/tmp/editor-folder-evidence/`: desktop folder menu, +desktop repository picker and mobile repository picker. Phone evidence shows +an inset sheet, contained 44px rows and no horizontal overflow. Desktop tests +verify the removed button, 28px dropdown trigger, menu reachability without +editors, selected-worktree payload, keyboard use, cancellation focus and retry. +Native opening is HTTP-stubbed; these tests do not assert Finder/Explorer window +visibility. No backend native-launch behavior changed. + +## PR review remediation (2026-09-24) + +Claude's inline finding and summary suggestion identified the editor-only +accessible name on a menu that also opens folders. The trigger now announces +"Editor and folder actions" through `task:editorActions` in all six locales and +the pseudo locale; Traditional Chinese values use the repository converter. +The new focused regression failed before the change. + +Post-fix checks: `pnpm exec vitest run components/task/editors-menu.test.tsx` +passes 11 tests; changed-file ESLint passes; a fresh managed production build and +`pnpm e2e:run --host tests/task/open-task-folder.spec.ts` pass all 3 browser tests, +including the accessible-name assertion. The local full i18n check still reports +the previously recorded Japanese keys, which are already fixed on current main; +none of its findings concern the new label. diff --git a/docs/plans/open-task-folder/plan.md b/docs/plans/open-task-folder/plan.md index 64affa9ef3..e3e387cbab 100644 --- a/docs/plans/open-task-folder/plan.md +++ b/docs/plans/open-task-folder/plan.md @@ -172,3 +172,9 @@ 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. + +## Follow-up relocation (2026-09-22) + +The [editor dropdown package](../editor-dropdown-folder/plan.md) supersedes this +package's standalone desktop button placement. This completed record and its test +results describe PR #3784; they are not validation of the pending relocation. diff --git a/docs/plans/open-task-folder/task-01-folder-shortcut.md b/docs/plans/open-task-folder/task-01-folder-shortcut.md index d35cc6a4ab..548792a98f 100644 --- a/docs/plans/open-task-folder/task-01-folder-shortcut.md +++ b/docs/plans/open-task-folder/task-01-folder-shortcut.md @@ -201,3 +201,9 @@ 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. + +## Follow-up relocation (2026-09-22) + +The [editor dropdown package](../editor-dropdown-folder/plan.md) supersedes this +package's standalone desktop button placement. This completed record and its test +results describe PR #3784; they are not validation of the pending relocation. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index ca071f5da2..d29f3cb664 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -205,7 +205,7 @@ 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. +To open a task folder in your file manager, open the **Open in editor** dropdown in the task toolbar and select **Open folder**. 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. diff --git a/docs/specs/tasks/requirements/open-task-folder.md b/docs/specs/tasks/requirements/open-task-folder.md index 7d8c928f13..96ae529208 100644 --- a/docs/specs/tasks/requirements/open-task-folder.md +++ b/docs/specs/tasks/requirements/open-task-folder.md @@ -10,7 +10,7 @@ owners: ## Overview -Expose the existing native folder-opening capability beside the task's IDE control. +Expose the existing native folder-opening capability inside the task's Open in editor dropdown. 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). @@ -23,7 +23,7 @@ The existing workspace-actions contract remains in #### 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.1:** For an unarchived task with a selected session, desktop task tools shall expose an accessible Open folder action inside the Open in editor dropdown, independent of editor configuration. There shall be no separate task-toolbar folder button. The dropdown shall remain reachable with no configured editors; its primary editor action shall retain its existing enablement. 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. @@ -36,4 +36,5 @@ new editor settings, arbitrary file/path selection, and changing archived-task t ## Implementation plans -- [Open task folder](../../../plans/open-task-folder/plan.md) +- [Editor dropdown folder action](../../../plans/editor-dropdown-folder/plan.md) (dropdown relocation) +- [Open task folder](../../../plans/open-task-folder/plan.md) (original delivery) diff --git a/docs/specs/tasks/system-design/open-task-folder.md b/docs/specs/tasks/system-design/open-task-folder.md index cf15640d5c..38fa3069d4 100644 --- a/docs/specs/tasks/system-design/open-task-folder.md +++ b/docs/specs/tasks/system-design/open-task-folder.md @@ -9,7 +9,7 @@ requirements: ## Purpose and boundaries -Add a discoverable session shortcut using the existing editors service folder operation. +Consolidate the session folder action into the editor dropdown 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). @@ -22,13 +22,28 @@ See [requirements](../requirements/open-task-folder.md) and ## 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. +`apps/web/components/task/task-top-bar.tsx` keeps `EditorsMenu` in +`TopbarToolsGroup`, inside the existing unarchived-task branch. The standalone +folder button is removed. +`apps/web/components/task/editors-menu.tsx` delegates the dropdown shell to +`EditorActionsDropdown` in `editor-actions-dropdown.tsx`, which renders a separated +Open folder row after the editor entries. Reuse `useTaskFolderAction` and `TaskFolderPicker`; retain +`useOpenSessionFolder` for requests, per-session duplicate suppression, loading, +and localized errors. Folder opening never changes the default editor. + +The dropdown trigger requires a session, not a configured editor. With zero +editors, retain the disabled no-editors row and primary editor button while +allowing access to the folder row. Disable the folder row using the shared +folder action's capability/loading state; it must not disable editor entries. +The dropdown trigger uses the localized `task:editorActions` name, explicitly +announcing both editor and folder actions. + +Close the dropdown before opening the folder picker. Follow +`WorkspaceActionsMenu` in `file-browser-toolbar.tsx`: queue selection in a ref, +activate from `onCloseAutoFocus`, and pass the persistent dropdown trigger to +`action.open`. Cancel restores that trigger, never an unmounted menu item. +Mount the picker outside dropdown content. Reuse `buildWorktreeOptions` and +`useSessionWorktrees` for labels and selected-session 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 @@ -62,7 +77,7 @@ 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, +where needed. The dropdown trigger and folder row have accessible names and keyboard activation, 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 @@ -95,8 +110,12 @@ 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, +failed, or false discovery disables the folder row in the editor dropdown, 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. + +## Implementation plan + +[Editor dropdown folder action](../../../plans/editor-dropdown-folder/plan.md) records the implemented relocation. The original plan preserves historical validation evidence.