Compare commits

...

1 Commits

Author SHA1 Message Date
Lambda
11da9b07da feat(issues): add 'Open in new tab' to the issue actions menu (MUL-2933)
Desktop-only kebab/context-menu item that opens the issue in a new tab
via the existing navigation.openInNewTab adapter primitive. Gated on
isDesktopShell() && navigation.openInNewTab so it never renders on web/
mobile, and hidden when the target path equals the current path so the
issue's own detail kebab doesn't offer a self-referential no-op (list
rows, on a different path, still show it).

Adds the open_in_new_tab string across en/ja/ko/zh-Hans.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 18:02:22 +08:00
8 changed files with 185 additions and 15 deletions

View File

@@ -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<typeof vi.fn>;
pathname: string;
searchParams: URLSearchParams;
back: ReturnType<typeof vi.fn>;
replace: ReturnType<typeof vi.fn>;
openInNewTab?: ReturnType<typeof vi.fn>;
} = {
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(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
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(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
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(

View File

@@ -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<typeof vi.fn>;
pathname: string;
searchParams: URLSearchParams;
back: ReturnType<typeof vi.fn>;
replace: ReturnType<typeof vi.fn>;
getShareableUrl: (p: string) => string;
openInNewTab?: ReturnType<typeof vi.fn>;
} = {
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 });

View File

@@ -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)}
</P.Item>
{canOpenInNewTab && (
<P.Item onClick={openInNewTab}>
<ExternalLink className="h-3.5 w-3.5" />
{t(($) => $.actions.open_in_new_tab)}
</P.Item>
)}
<P.Item onClick={copyLink}>
<Link2 className="h-3.5 w-3.5" />
{t(($) => $.actions.copy_link)}

View File

@@ -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<UpdateIssueRequest>) => void;
togglePin: () => void;
copyLink: () => Promise<void>;
/** 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,

View File

@@ -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",

View File

@@ -373,6 +373,7 @@
"unassigned": "未割り当て",
"pin_to_sidebar": "サイドバーにピン留め",
"unpin_from_sidebar": "サイドバーのピン留めを解除",
"open_in_new_tab": "新しいタブで開く",
"copy_link": "リンクをコピー",
"copy_workdir_path": "ローカルの作業ディレクトリのパスをコピー",
"more": "その他",

View File

@@ -389,6 +389,7 @@
"unassigned": "담당자 없음",
"pin_to_sidebar": "사이드바에 고정",
"unpin_from_sidebar": "사이드바 고정 해제",
"open_in_new_tab": "새 탭에서 열기",
"copy_link": "링크 복사",
"copy_workdir_path": "로컬 작업 디렉터리 경로 복사",
"more": "더 보기",

View File

@@ -378,6 +378,7 @@
"unassigned": "未分配",
"pin_to_sidebar": "固定到侧边栏",
"unpin_from_sidebar": "从侧边栏取消固定",
"open_in_new_tab": "在新标签页打开",
"copy_link": "复制链接",
"copy_workdir_path": "复制本地 workdir 路径",
"more": "更多",