diff --git a/packages/views/issues/actions/__tests__/issue-actions-menu.test.tsx b/packages/views/issues/actions/__tests__/issue-actions-menu.test.tsx index 345a98bc3..a920826bb 100644 --- a/packages/views/issues/actions/__tests__/issue-actions-menu.test.tsx +++ b/packages/views/issues/actions/__tests__/issue-actions-menu.test.tsx @@ -86,14 +86,25 @@ vi.mock("@multica/core/paths", async () => { }; }); +// Mutable so individual tests can simulate the desktop shell (current path + +// the desktop-only `openInNewTab` adapter method) for the "Open in new tab" item. +const navMock: { + push: ReturnType; + pathname: string; + searchParams: URLSearchParams; + back: ReturnType; + replace: ReturnType; + openInNewTab?: ReturnType; +} = { + push: vi.fn(), + pathname: "/test/issues/issue-1", + searchParams: new URLSearchParams(), + back: vi.fn(), + replace: vi.fn(), + openInNewTab: undefined, +}; vi.mock("../../../navigation", () => ({ - useNavigation: () => ({ - push: vi.fn(), - pathname: "/test/issues/issue-1", - searchParams: new URLSearchParams(), - back: vi.fn(), - replace: vi.fn(), - }), + useNavigation: () => navMock, })); vi.mock("sonner", () => ({ @@ -142,8 +153,19 @@ function wrap(ui: React.ReactNode) { beforeEach(() => { mockOpenModal.mockReset(); + navMock.push.mockReset(); + navMock.pathname = "/test/issues/issue-1"; + navMock.openInNewTab = undefined; + delete (window as unknown as { desktopAPI?: unknown }).desktopAPI; }); +/** Stub the preload bridge so `isDesktopShell()` returns true. */ +function enterDesktopShell() { + (window as unknown as { desktopAPI?: unknown }).desktopAPI = { + pickDirectory: vi.fn(), + }; +} + describe("IssueActionsDropdown", () => { it("renders the top-level items when the trigger is clicked", async () => { render( @@ -165,12 +187,55 @@ describe("IssueActionsDropdown", () => { expect(screen.getByText("Copy link")).toBeInTheDocument(); expect(screen.getByText("More")).toBeInTheDocument(); expect(screen.getByText("Delete issue")).toBeInTheDocument(); + // "Open in new tab" is desktop-only; absent on web / outside the shell. + expect(screen.queryByText("Open in new tab")).not.toBeInTheDocument(); // Relationship actions are hidden inside the "More" submenu by default. expect(screen.queryByText("Create sub-issue")).not.toBeInTheDocument(); expect(screen.queryByText("Set parent issue...")).not.toBeInTheDocument(); expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument(); }); + it("shows 'Open in new tab' on desktop from a different path and opens a foreground tab", async () => { + enterDesktopShell(); + navMock.openInNewTab = vi.fn(); + navMock.pathname = "/test/issues"; // list view, not this issue's own tab + render( + wrap( + Menu} + />, + ), + ); + + fireEvent.click(screen.getByTestId("trigger")); + fireEvent.click(await screen.findByText("Open in new tab")); + + expect(navMock.openInNewTab).toHaveBeenCalledWith( + "/test/issues/issue-1", + undefined, + { activate: true }, + ); + }); + + it("hides 'Open in new tab' on the issue's own detail tab (target === current path)", async () => { + enterDesktopShell(); + navMock.openInNewTab = vi.fn(); + navMock.pathname = "/test/issues/issue-1"; + render( + wrap( + Menu} + />, + ), + ); + + fireEvent.click(screen.getByTestId("trigger")); + await screen.findByText("Copy link"); + expect(screen.queryByText("Open in new tab")).not.toBeInTheDocument(); + }); + it("clicking the Assignee item opens the shared AssigneePicker popover", async () => { render( wrap( diff --git a/packages/views/issues/actions/__tests__/use-issue-actions.test.tsx b/packages/views/issues/actions/__tests__/use-issue-actions.test.tsx index 7a5d62a37..516a9827c 100644 --- a/packages/views/issues/actions/__tests__/use-issue-actions.test.tsx +++ b/packages/views/issues/actions/__tests__/use-issue-actions.test.tsx @@ -58,15 +58,27 @@ vi.mock("@multica/core/paths", async () => { }; }); +// Mutable so individual tests can flip the current path and toggle the +// desktop-only `openInNewTab` capability (absent on the web adapter). +const navMock: { + push: ReturnType; + pathname: string; + searchParams: URLSearchParams; + back: ReturnType; + replace: ReturnType; + getShareableUrl: (p: string) => string; + openInNewTab?: ReturnType; +} = { + push: vi.fn(), + pathname: "/test/issues/issue-1", + searchParams: new URLSearchParams(), + back: vi.fn(), + replace: vi.fn(), + getShareableUrl: (p: string) => `https://app.multica.com${p}`, + openInNewTab: undefined, +}; vi.mock("../../../navigation", () => ({ - useNavigation: () => ({ - push: vi.fn(), - pathname: "/test/issues/issue-1", - searchParams: new URLSearchParams(), - back: vi.fn(), - replace: vi.fn(), - getShareableUrl: (p: string) => `https://app.multica.com${p}`, - }), + useNavigation: () => navMock, })); vi.mock("sonner", () => ({ @@ -111,12 +123,23 @@ beforeEach(() => { mockDeletePinMutate.mockReset(); pinListRef.value = []; localStorage.clear(); + navMock.push.mockReset(); + navMock.pathname = "/test/issues/issue-1"; + navMock.openInNewTab = undefined; + delete (window as unknown as { desktopAPI?: unknown }).desktopAPI; Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, }); }); +/** Stub the preload bridge so `isDesktopShell()` returns true. */ +function enterDesktopShell() { + (window as unknown as { desktopAPI?: unknown }).desktopAPI = { + pickDirectory: vi.fn(), + }; +} + describe("useIssueActions", () => { it("updateField dispatches useUpdateIssue.mutate with the correct payload", () => { const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); @@ -241,6 +264,48 @@ describe("useIssueActions", () => { expect(mockCreatePinMutate).not.toHaveBeenCalled(); }); + it("canOpenInNewTab is false on web (no desktop shell, no adapter method)", () => { + const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); + expect(result.current.canOpenInNewTab).toBe(false); + }); + + it("canOpenInNewTab is false on desktop when the issue is already the active tab", () => { + enterDesktopShell(); + navMock.openInNewTab = vi.fn(); + navMock.pathname = "/test/issues/issue-1"; // === paths.issueDetail("issue-1") + const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); + expect(result.current.canOpenInNewTab).toBe(false); + }); + + it("canOpenInNewTab is true on desktop from a different path (e.g. a list row)", () => { + enterDesktopShell(); + navMock.openInNewTab = vi.fn(); + navMock.pathname = "/test/issues"; + const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); + expect(result.current.canOpenInNewTab).toBe(true); + }); + + it("openInNewTab opens the issue path in a foreground tab", () => { + navMock.openInNewTab = vi.fn(); + const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); + + act(() => { + result.current.openInNewTab(); + }); + + expect(navMock.openInNewTab).toHaveBeenCalledWith( + "/test/issues/issue-1", + undefined, + { activate: true }, + ); + }); + + it("openInNewTab is a safe no-op when the adapter lacks the capability (web)", () => { + navMock.openInNewTab = undefined; + const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper }); + expect(() => act(() => result.current.openInNewTab())).not.toThrow(); + }); + it("is a safe no-op when issue is null", () => { const { result } = renderHook(() => useIssueActions(null), { wrapper }); diff --git a/packages/views/issues/actions/issue-actions-menu-items.tsx b/packages/views/issues/actions/issue-actions-menu-items.tsx index 4d2786b55..409f106e2 100644 --- a/packages/views/issues/actions/issue-actions-menu-items.tsx +++ b/packages/views/issues/actions/issue-actions-menu-items.tsx @@ -8,6 +8,7 @@ import { ArrowUp, Calendar, CalendarClock, + ExternalLink, FolderOpen, Link2, MoreHorizontal, @@ -100,6 +101,8 @@ export function IssueActionsMenuItems({ updateField, togglePin, copyLink, + openInNewTab, + canOpenInNewTab, openCreateSubIssue, openSetParent, openAddChild, @@ -262,6 +265,12 @@ export function IssueActionsMenuItems({ )} {isPinned ? t(($) => $.actions.unpin_from_sidebar) : t(($) => $.actions.pin_to_sidebar)} + {canOpenInNewTab && ( + + + {t(($) => $.actions.open_in_new_tab)} + + )} {t(($) => $.actions.copy_link)} diff --git a/packages/views/issues/actions/use-issue-actions.ts b/packages/views/issues/actions/use-issue-actions.ts index a7404e20d..e0931f880 100644 --- a/packages/views/issues/actions/use-issue-actions.ts +++ b/packages/views/issues/actions/use-issue-actions.ts @@ -11,6 +11,7 @@ import { useModalStore } from "@multica/core/modals"; import { useUpdateIssue } from "@multica/core/issues/mutations"; import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins"; import { useNavigation } from "../../navigation"; +import { isDesktopShell } from "../../platform"; import { useT } from "../../i18n"; const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed"; @@ -20,6 +21,12 @@ export interface UseIssueActionsResult { updateField: (updates: Partial) => void; togglePin: () => void; copyLink: () => Promise; + /** Desktop-only: open this issue in a new tab (foreground). */ + openInNewTab: () => void; + /** Whether the "Open in new tab" item should render — desktop shell only, + * and hidden when the issue is already the active tab (target === current + * path), so the detail kebab never offers a no-op while list rows still do. */ + canOpenInNewTab: boolean; openCreateSubIssue: () => void; openSetParent: () => void; openAddChild: () => void; @@ -109,6 +116,24 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult { } }, [paths, issueId, navigation, t]); + const openInNewTab = useCallback(() => { + if (!issueId) return; + navigation.openInNewTab?.(paths.issueDetail(issueId), undefined, { + activate: true, + }); + }, [issueId, navigation, paths]); + + // Desktop-only. `openInNewTab` is absent on the web adapter, and we hide the + // item when the target path equals the current path so the issue's own detail + // kebab doesn't surface a self-referential no-op (openTab would just refocus + // the current tab). List-row context menus sit on a different path, so they + // keep showing it. + const canOpenInNewTab = + !!issueId && + isDesktopShell() && + !!navigation.openInNewTab && + navigation.pathname !== paths.issueDetail(issueId); + const openCreateSubIssue = useCallback(() => { if (!issueId) return; openModal("create-issue", { @@ -145,6 +170,8 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult { updateField, togglePin, copyLink, + openInNewTab, + canOpenInNewTab, openCreateSubIssue, openSetParent, openAddChild, diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index cef5ee7e9..b87526864 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -389,6 +389,7 @@ "unassigned": "Unassigned", "pin_to_sidebar": "Pin to sidebar", "unpin_from_sidebar": "Unpin from sidebar", + "open_in_new_tab": "Open in new tab", "copy_link": "Copy link", "copy_workdir_path": "Copy local workdir path", "more": "More", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index fa31c79f3..ffbd9f56f 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -373,6 +373,7 @@ "unassigned": "未割り当て", "pin_to_sidebar": "サイドバーにピン留め", "unpin_from_sidebar": "サイドバーのピン留めを解除", + "open_in_new_tab": "新しいタブで開く", "copy_link": "リンクをコピー", "copy_workdir_path": "ローカルの作業ディレクトリのパスをコピー", "more": "その他", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 6fd0997f5..cf07a9b00 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -389,6 +389,7 @@ "unassigned": "담당자 없음", "pin_to_sidebar": "사이드바에 고정", "unpin_from_sidebar": "사이드바 고정 해제", + "open_in_new_tab": "새 탭에서 열기", "copy_link": "링크 복사", "copy_workdir_path": "로컬 작업 디렉터리 경로 복사", "more": "더 보기", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 52fd0cb81..ed0707f37 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -378,6 +378,7 @@ "unassigned": "未分配", "pin_to_sidebar": "固定到侧边栏", "unpin_from_sidebar": "从侧边栏取消固定", + "open_in_new_tab": "在新标签页打开", "copy_link": "复制链接", "copy_workdir_path": "复制本地 workdir 路径", "more": "更多",