MUL-8425 feat(sidebar): right-click quick actions on sidebar issues (#9749)

* MUL-8425 feat(issues): add a sidebar variant of the issue context menu

Names the issue in a header, shows current status, priority and assignee
beside their entries, and keeps only the triage and navigation actions in
the full menu's order. Adds Copy identifier and, while an agent runs on
the issue, a confirmed Stop run entry.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* MUL-8425 feat(sidebar): open the issue menu on right-click in the Work panel

Pinned issues and issues under expanded projects and views open the
sidebar variant; the right-clicked row stays lit while the menu is open.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiayuan Zhang
2026-10-31 16:37:21 +08:00
committed by GitHub
co-authored by Cursor multica-agent
parent 4bfa916450
commit 38a65f4988
14 changed files with 574 additions and 218 deletions
@@ -58,8 +58,12 @@ vi.mock("@multica/core/workspace/queries", () => ({
}),
}));
const { ACTOR_NAMES } = vi.hoisted(() => ({
ACTOR_NAMES: { "user-1": "Test User", "agent-1": "Lambda" } as Record<string, string>,
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({ getActorName: (_t: string, _id: string) => "" }),
useActorName: () => ({ getActorName: (_t: string, id: string) => ACTOR_NAMES[id] ?? "" }),
}));
vi.mock("@multica/core/pins", () => ({
@@ -71,8 +75,11 @@ vi.mock("@multica/core/pins", () => ({
useDeletePin: () => ({ mutate: vi.fn() }),
}));
const { cancelRunMock } = vi.hoisted(() => ({ cancelRunMock: vi.fn() }));
vi.mock("@multica/core/issues/mutations", () => ({
useUpdateIssue: () => ({ mutate: vi.fn() }),
useCancelIssueRun: () => ({ mutateAsync: cancelRunMock }),
}));
vi.mock("@multica/core/paths", async () => {
@@ -120,6 +127,7 @@ const { apiMocks, copyTextMock } = vi.hoisted(() => ({
listTasksByIssue: vi.fn(),
listIssueWorkflows: vi.fn(),
listProjects: vi.fn(),
getAgentTaskSnapshot: vi.fn(),
},
copyTextMock: vi.fn(),
}));
@@ -191,6 +199,10 @@ beforeEach(() => {
apiMocks.listIssueWorkflows.mockResolvedValue({ workflows: [] });
apiMocks.listProjects.mockReset();
apiMocks.listProjects.mockResolvedValue({ projects: [] });
apiMocks.getAgentTaskSnapshot.mockReset();
apiMocks.getAgentTaskSnapshot.mockResolvedValue([]);
cancelRunMock.mockReset();
cancelRunMock.mockResolvedValue(undefined);
});
describe("IssueActionsDropdown", () => {
@@ -503,6 +515,72 @@ describe("IssueActionsContextMenu", () => {
});
});
describe("Sidebar variant (MUL-8425)", () => {
function openSidebarMenu(issue: Issue = mockIssue) {
render(
wrap(
<IssueContextMenuProvider variant="sidebar">
<IssueActionsContextMenu issue={issue}>
<div data-testid="row">Row</div>
</IssueActionsContextMenu>
</IssueContextMenuProvider>,
),
);
fireEvent.contextMenu(screen.getByTestId("row"));
}
const menuLabels = () =>
screen.getAllByRole("menuitem").map((item) => item.textContent?.trim());
it("names the issue and keeps the triage and navigation entries, with current values", async () => {
openSidebarMenu({ ...mockIssue, assignee_type: "member", assignee_id: "user-1" } as Issue);
expect(await screen.findByText("TES-1")).toBeInTheDocument();
expect(screen.getByText("Example")).toBeInTheDocument();
expect(menuLabels()).toEqual([
"StatusTodo",
"PriorityMedium",
"AssigneeTest User",
"Open in new tab",
"Pin to sidebar",
"Copy link",
"Copy identifier",
]);
expect(screen.getByTestId("row")).toHaveAttribute("data-popup-open");
// No workdir entry, so the menu has no reason to fetch the task list.
expect(listTasksByIssueMock).not.toHaveBeenCalled();
});
it("shows Unassigned when nobody owns the issue", async () => {
openSidebarMenu();
expect(await screen.findByText("Unassigned")).toBeInTheDocument();
});
it("copies the identifier", async () => {
openSidebarMenu();
fireEvent.click(await screen.findByText("Copy identifier"));
await waitFor(() => {
expect(copyTextMock).toHaveBeenCalledWith("TES-1");
expect(toastSuccessMock).toHaveBeenCalledWith("Copied TES-1");
});
});
it("stops a running agent's run after confirming", async () => {
apiMocks.getAgentTaskSnapshot.mockResolvedValue([
{ id: "task-1", issue_id: "issue-1", agent_id: "agent-1", status: "running" },
{ id: "task-2", issue_id: "issue-2", agent_id: "agent-1", status: "running" },
] as AgentTask[]);
openSidebarMenu();
fireEvent.click(await screen.findByText("Stop Lambda’s run"));
expect(cancelRunMock).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "Stop run" }));
expect(cancelRunMock).toHaveBeenCalledWith("task-1");
});
});
describe("Status in a workflow project (MUL-7420)", () => {
const workflowIssue = { ...mockIssue, project_id: "proj-1" } as Issue;
@@ -11,7 +11,9 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import type { Issue } from "@multica/core/types";
import { toast } from "sonner";
import type { AgentTask, Issue } from "@multica/core/types";
import { useCancelIssueRun } from "@multica/core/issues/mutations";
import {
ContextMenu,
ContextMenuContent,
@@ -22,6 +24,8 @@ import {
contextPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
import { TerminateTaskConfirmDialog } from "../components/terminate-task-confirm-dialog";
import { useT } from "../../i18n";
/**
* One shared context menu per surface instead of one Base UI ContextMenu
@@ -48,10 +52,15 @@ const IssueContextMenuContext = createContext<OpenIssueContextMenu | null>(
null,
);
type IssueContextMenuVariant = "full" | "sidebar";
export function IssueContextMenuProvider({
children,
variant = "full",
}: {
children: ReactNode;
/** Which menu the surface's rows open; see `IssueActionsMenuItems`. */
variant?: IssueContextMenuVariant;
}) {
const [active, setActive] = useState<ActiveMenu | null>(null);
const [open, setOpen] = useState(false);
@@ -91,6 +100,7 @@ export function IssueContextMenuProvider({
position={active.position}
open={open}
onOpenChange={handleOpenChange}
variant={variant}
/>
)}
</IssueContextMenuContext.Provider>
@@ -102,12 +112,15 @@ function IssueContextMenuSingleton({
position,
open,
onOpenChange,
variant,
}: ActiveMenu & {
open: boolean;
onOpenChange: (v: boolean) => void;
variant: IssueContextMenuVariant;
}) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [stopTask, setStopTask] = useState<AgentTask | null>(null);
// Point-sized virtual anchor at the right-click position — replaces the
// cursor anchor Base UI's own trigger would have registered.
@@ -127,15 +140,27 @@ function IssueContextMenuSingleton({
return (
<>
<ContextMenu open={open} onOpenChange={(v) => onOpenChange(v)}>
<ContextMenuContent anchor={anchor}>
{/* A fixed width keeps the sidebar menu from resizing with the
header title, which is clamped to two lines instead. */}
<ContextMenuContent anchor={anchor} className={variant === "sidebar" ? "w-62" : undefined}>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
variant={variant}
onStopRun={setStopTask}
/>
</ContextMenuContent>
</ContextMenu>
{stopTask && (
<StopRunConfirmDialog
issueId={issue.id}
task={stopTask}
onClose={() => setStopTask(null)}
/>
)}
{/* Mount the picker only once the user actually opens it, anchored at
the right-click position so it opens where the context menu just
was instead of jumping to the row's top-left corner. */}
@@ -166,6 +191,35 @@ function IssueContextMenuSingleton({
);
}
function StopRunConfirmDialog({
issueId,
task,
onClose,
}: {
issueId: string;
task: AgentTask;
onClose: () => void;
}) {
const { t } = useT("issues");
const cancelRun = useCancelIssueRun(issueId);
return (
<TerminateTaskConfirmDialog
open
onOpenChange={(v) => {
if (!v) onClose();
}}
onConfirm={() => {
// The dialog closes (and unmounts this component) before the request
// settles, so per-call mutate callbacks would never fire.
void cancelRun.mutateAsync(task.id).catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : t(($) => $.execution_log.cancel_failed));
});
}}
showRunningNote
/>
);
}
interface IssueActionsContextMenuProps {
issue: Issue;
/** A single React element the menu trigger behavior is grafted onto. */
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -12,32 +12,38 @@ import {
CircleEqual,
ExternalLink,
FolderOpen,
Hash,
Link2,
Network,
Pin,
PinOff,
Pencil,
Plus,
Square,
Trash2,
Unlink,
UserMinus,
} from "@multica/ui/icons";
import type { Issue } from "@multica/core/types";
import type { AgentTask, Issue } from "@multica/core/types";
import { resolveWorkdirCopyTarget } from "@multica/core/issues";
import { todayDateOnly, addDaysDateOnly } from "@multica/core/issues/date";
import { api } from "@multica/core/api";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import {
PRIORITY_DISPLAY_ORDER,
PRIORITY_CONFIG,
} from "@multica/core/issues/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useActorName } from "@multica/core/workspace/hooks";
import { useIssueStatuses } from "@multica/core/issue-statuses/hooks";
import { stepHandsOff, workflowAllowsStatus, workflowStep } from "@multica/core/issue-workflows";
import { useModalStore } from "@multica/core/modals";
import { useIssueStatusOptions } from "../utils/status-options";
import { useStatusLabel } from "../utils/status-label";
import { issueKeys } from "@multica/core/issues/queries";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { selectIssueTasks } from "../surface/activity";
import {
DropdownMenuItem,
DropdownMenuSub,
@@ -101,6 +107,14 @@ interface IssueActionsMenuItemsProps {
* to the list the user came from and only falls back to this path when
* there is no in-app history. List surfaces leave it unset and stay put. */
onDeletedFallbackPath?: string;
/** `sidebar` is the trimmed menu for sidebar rows, where only the title is
* visible: it names the issue in a header, shows current values beside
* Status / Priority / Assignee, and keeps only triage and navigation
* entries — in the full menu's order, so the shared entries sit where
* users already expect them. */
variant?: "full" | "sidebar";
/** Sidebar variant: asks the parent to confirm stopping a running task. */
onStopRun?: (task: AgentTask) => void;
}
export function IssueActionsMenuItems({
@@ -109,9 +123,13 @@ export function IssueActionsMenuItems({
primitives: P,
onOpenAssignee,
onDeletedFallbackPath,
variant = "full",
onStopRun,
}: IssueActionsMenuItemsProps) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const sidebar = variant === "sidebar";
const statusLabel = useStatusLabel(wsId);
// Only the statuses the issue's workflow allows; entering a step that hands
// the issue off confirms first, as in the status picker. (MUL-7420)
const { options: statusOptions, workflow } = useIssueStatusOptions(wsId, issue.project_id, issue.status);
@@ -151,6 +169,7 @@ export function IssueActionsMenuItems({
queryKey: issueKeys.tasks(issue.id),
queryFn: () => api.listTasksByIssue(issue.id),
staleTime: 30_000,
enabled: !sidebar,
});
const workdirCopyTarget = useMemo(
() => resolveWorkdirCopyTarget(tasks),
@@ -186,24 +205,46 @@ export function IssueActionsMenuItems({
});
}, [workdirCopyTarget, t]);
const copyIdentifier = () => {
void copyText(issue.identifier).then((ok) => {
if (ok) {
toast.success(t(($) => $.actions.identifier_copied, { identifier: issue.identifier }));
}
});
};
return (
<>
{/* Rename leads the menu: it is the only entry that changes what the
issue *is* rather than a property of it, and on list/board rows it is
the only way to reach the title at all. */}
<P.Item onClick={openRename}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.actions.rename_issue)}
</P.Item>
{/* Top level rather than under Relations: on a long issue the
sub-issues section's own "+" is scrolled far out of reach, and this
menu is pinned in the header. */}
<P.Item onClick={openCreateSubIssue}>
<Plus className="h-3.5 w-3.5" />
{t(($) => $.actions.create_sub_issue)}
</P.Item>
{sidebar ? (
<>
<div className="px-2 pt-1 pb-1.5">
<div className="text-caption text-muted-foreground">{issue.identifier}</div>
<div className="line-clamp-2 text-body font-medium break-words text-pretty">
{issue.title}
</div>
</div>
<P.Separator />
</>
) : (
<>
{/* Rename leads the menu: it is the only entry that changes what the
issue *is* rather than a property of it, and on list/board rows
it is the only way to reach the title at all. */}
<P.Item onClick={openRename}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.actions.rename_issue)}
</P.Item>
{/* Top level rather than under Relations: on a long issue the
sub-issues section's own "+" is scrolled far out of reach, and
this menu is pinned in the header. */}
<P.Item onClick={openCreateSubIssue}>
<Plus className="h-3.5 w-3.5" />
{t(($) => $.actions.create_sub_issue)}
</P.Item>
<P.Separator />
<P.Separator />
</>
)}
{/* Status */}
<P.Sub>
@@ -215,7 +256,10 @@ export function IssueActionsMenuItems({
icon={iconOf(issue.status)}
className="h-3.5 w-3.5"
/>
{t(($) => $.actions.status)}
<ItemLabel
label={t(($) => $.actions.status)}
value={sidebar ? statusLabel(issue.status) : undefined}
/>
</P.SubTrigger>
<P.SubContent>
{/* The same set as the status picker: every entry point that can
@@ -246,7 +290,10 @@ export function IssueActionsMenuItems({
<P.Sub>
<P.SubTrigger>
<PriorityIcon priority={issue.priority} />
{t(($) => $.actions.priority)}
<ItemLabel
label={t(($) => $.actions.priority)}
value={sidebar ? t(($) => $.priority[issue.priority]) : undefined}
/>
</P.SubTrigger>
<P.SubContent>
{PRIORITY_DISPLAY_ORDER.map((p) => (
@@ -272,62 +319,69 @@ export function IssueActionsMenuItems({
3-dot menus. */}
<P.Item onClick={onOpenAssignee}>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
<ItemLabel
label={t(($) => $.actions.assignee)}
value={sidebar ? <AssigneeName issue={issue} /> : undefined}
/>
</P.Item>
{/* Start date */}
<P.Sub>
<P.SubTrigger>
<CalendarClock className="h-3.5 w-3.5" />
{t(($) => $.actions.start_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ start_date: todayDateOnly() })}>
{t(($) => $.actions.start_today)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.start_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.start_next_week)}
</P.Item>
{issue.start_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ start_date: null })}>
{t(($) => $.actions.start_clear)}
{!sidebar && (
<>
{/* Start date */}
<P.Sub>
<P.SubTrigger>
<CalendarClock className="h-3.5 w-3.5" />
{t(($) => $.actions.start_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ start_date: todayDateOnly() })}>
{t(($) => $.actions.start_today)}
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.start_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.start_next_week)}
</P.Item>
{issue.start_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ start_date: null })}>
{t(($) => $.actions.start_clear)}
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
{/* Due date */}
<P.Sub>
<P.SubTrigger>
<Calendar className="h-3.5 w-3.5" />
{t(($) => $.actions.due_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ due_date: todayDateOnly() })}>
{t(($) => $.actions.due_today)}
</P.Item>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.due_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.due_next_week)}
</P.Item>
{issue.due_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ due_date: null })}>
{t(($) => $.actions.due_clear)}
{/* Due date */}
<P.Sub>
<P.SubTrigger>
<Calendar className="h-3.5 w-3.5" />
{t(($) => $.actions.due_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ due_date: todayDateOnly() })}>
{t(($) => $.actions.due_today)}
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(1) })}>
{t(($) => $.actions.due_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ due_date: addDaysDateOnly(7) })}>
{t(($) => $.actions.due_next_week)}
</P.Item>
{issue.due_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ due_date: null })}>
{t(($) => $.actions.due_clear)}
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
</>
)}
<P.Separator />
@@ -350,68 +404,139 @@ export function IssueActionsMenuItems({
<Link2 className="h-3.5 w-3.5" />
{t(($) => $.actions.copy_link)}
</P.Item>
<P.Item onClick={handleCopyWorkdirPath}>
<FolderOpen className="h-3.5 w-3.5" />
{workdirCopyTarget?.source === "durable_project_directory"
? t(($) => $.actions.copy_project_directory_path)
: t(($) => $.actions.copy_workdir_path)}
</P.Item>
{sidebar ? (
<P.Item onClick={copyIdentifier}>
<Hash className="h-3.5 w-3.5" />
{t(($) => $.actions.copy_identifier)}
</P.Item>
) : (
<P.Item onClick={handleCopyWorkdirPath}>
<FolderOpen className="h-3.5 w-3.5" />
{workdirCopyTarget?.source === "durable_project_directory"
? t(($) => $.actions.copy_project_directory_path)
: t(($) => $.actions.copy_workdir_path)}
</P.Item>
)}
<P.Separator />
{sidebar ? (
onStopRun && <StopRunItems issueId={issue.id} primitives={P} onStopRun={onStopRun} />
) : (
<>
<P.Separator />
{/* Relationship actions live under "Relations" — a semantically explicit
label (unlike the old "More") so the first level tells you what the
submenu does. Holds parent/sub-issue links today, and will grow
(blocks, duplicates, related) as we add more relation types. */}
<P.Sub>
<P.SubTrigger>
<Network className="h-3.5 w-3.5" />
{t(($) => $.actions.relations)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={openSetParent}>
<ArrowUp className="h-3.5 w-3.5" />
{t(($) => $.actions.set_parent_issue)}
{/* Relationship actions live under "Relations" — a semantically
explicit label (unlike the old "More") so the first level tells
you what the submenu does. Holds parent/sub-issue links today,
and will grow (blocks, duplicates, related) as we add more
relation types. */}
<P.Sub>
<P.SubTrigger>
<Network className="h-3.5 w-3.5" />
{t(($) => $.actions.relations)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={openSetParent}>
<ArrowUp className="h-3.5 w-3.5" />
{t(($) => $.actions.set_parent_issue)}
</P.Item>
{issue.parent_issue_id && (
<P.Item onClick={removeParent}>
<Unlink className="h-3.5 w-3.5" />
{t(($) => $.actions.remove_parent_issue)}
</P.Item>
)}
<P.Item onClick={openAddChild}>
<ArrowDown className="h-3.5 w-3.5" />
{t(($) => $.actions.add_sub_issue)}
</P.Item>
{/* Marking a duplicate cancels the issue. (MUL-7420) */}
{workflowAllowsStatus(workflow, "cancelled") && (
<P.Item onClick={openMarkDuplicate}>
<CircleEqual className="h-3.5 w-3.5" />
{t(($) => $.actions.mark_duplicate)}
</P.Item>
)}
</P.SubContent>
</P.Sub>
{/* Manual plugin hooks. Rendered by the host rather than by the
plugin because the trigger decides identity: a `manual` call acts
as the person who picked it, so the entry has to live where the
host can prove somebody did. Renders nothing when no plugin
declares one. */}
<PluginHookMenuItems issueId={issue.id} Item={P.Item} Separator={P.Separator} />
{/* Modal surfaces open from here for the same reason: a modal is a
third party's UI taking over the screen, so it opens because a
person chose it, never on the plugin's own initiative. */}
<PluginModalMenuItems issueId={issue.id} Item={P.Item} />
<P.Separator />
<P.Item
variant="destructive"
onClick={() => openDeleteConfirm({ onDeletedFallbackPath })}
>
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.actions.delete_issue)}
</P.Item>
{issue.parent_issue_id && (
<P.Item onClick={removeParent}>
<Unlink className="h-3.5 w-3.5" />
{t(($) => $.actions.remove_parent_issue)}
</P.Item>
)}
<P.Item onClick={openAddChild}>
<ArrowDown className="h-3.5 w-3.5" />
{t(($) => $.actions.add_sub_issue)}
</P.Item>
{/* Marking a duplicate cancels the issue. (MUL-7420) */}
{workflowAllowsStatus(workflow, "cancelled") && (
<P.Item onClick={openMarkDuplicate}>
<CircleEqual className="h-3.5 w-3.5" />
{t(($) => $.actions.mark_duplicate)}
</P.Item>
)}
</P.SubContent>
</P.Sub>
</>
)}
</>
);
}
{/* Manual plugin hooks. Rendered by the host rather than by the plugin
because the trigger decides identity: a `manual` call acts as the
person who picked it, so the entry has to live where the host can
prove somebody did. Renders nothing when no plugin declares one. */}
<PluginHookMenuItems issueId={issue.id} Item={P.Item} Separator={P.Separator} />
{/* Modal surfaces open from here for the same reason: a modal is a third
party's UI taking over the screen, so it opens because a person chose
it, never on the plugin's own initiative. */}
<PluginModalMenuItems issueId={issue.id} Item={P.Item} />
/** An entry's label, with the current value trailing it when given. The label
* takes the free space so a submenu chevron stays flush right. */
function ItemLabel({ label, value }: { label: string; value?: ReactNode }) {
if (value === undefined) return <>{label}</>;
return (
<>
<span className="flex-1">{label}</span>
<span className="max-w-28 truncate text-muted-foreground">{value}</span>
</>
);
}
function AssigneeName({ issue }: { issue: Issue }) {
const { t } = useT("issues");
const { getActorName } = useActorName();
if (!issue.assignee_type || !issue.assignee_id) return <>{t(($) => $.actions.unassigned)}</>;
return <>{getActorName(issue.assignee_type, issue.assignee_id)}</>;
}
const NO_TASKS: AgentTask[] = [];
/** One entry per running task — several agents can work an issue at once. */
function StopRunItems({
issueId,
primitives: P,
onStopRun,
}: {
issueId: string;
primitives: MenuPrimitives;
onStopRun: (task: AgentTask) => void;
}) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { getActorName } = useActorName();
const selectRunning = useCallback(
(snapshot: AgentTask[]) => selectIssueTasks(snapshot, issueId).running,
[issueId],
);
const { data: running = NO_TASKS } = useQuery({
...agentTaskSnapshotOptions(wsId),
select: selectRunning,
});
if (running.length === 0) return null;
return (
<>
<P.Separator />
<P.Item
variant="destructive"
onClick={() => openDeleteConfirm({ onDeletedFallbackPath })}
>
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.actions.delete_issue)}
</P.Item>
{running.map((task) => (
<P.Item key={task.id} variant="destructive" onClick={() => onStopRun(task)}>
<Square className="h-3.5 w-3.5" />
{t(($) => $.actions.stop_agent_run, { name: getActorName("agent", task.agent_id) })}
</P.Item>
))}
</>
);
}
@@ -6,8 +6,17 @@ import { buildIssueStatusCatalog } from "@multica/core/issue-statuses/queries";
import type { PinnedItem } from "@multica/core/types";
import { useSidebarTreeStore } from "@multica/core/issues/stores/sidebar-tree-store";
import { renderWithI18n } from "../../test/i18n";
import { IssueContextMenuProvider } from "../../issues/actions";
import { PinnedSection, usePanelPins } from "./pinned-section";
vi.mock("../../issues/actions/issue-actions-menu-items", () => ({
contextPrimitives: {},
IssueActionsMenuItems: ({ issue, variant }: { issue: { title: string }; variant?: string }) => (
<div>{`${variant} menu: ${issue.title}`}</div>
),
}));
vi.mock("../../issues/actions/use-issue-actions", () => ({ useIssueActions: () => ({}) }));
const state = vi.hoisted(() => ({
pins: [] as PinnedItem[],
detail: { isPending: false, isError: false, data: null as unknown, error: null as unknown },
@@ -79,7 +88,11 @@ let activePinnedRoute = false;
function Harness({ pathname, currentIssueId = null }: { pathname: string; currentIssueId?: string | null }) {
const pins = usePanelPins("ws-1", "user-1", pathname, currentIssueId);
activePinnedRoute = pins.isActivePinnedRoute;
return <PinnedSection pins={pins} wsId="ws-1" />;
return (
<IssueContextMenuProvider variant="sidebar">
<PinnedSection pins={pins} wsId="ws-1" />
</IssueContextMenuProvider>
);
}
beforeEach(() => {
@@ -118,6 +131,21 @@ describe("PinnedSection", () => {
expect(activePinnedRoute).toBe(true);
});
it("opens the sidebar issue menu when a pinned issue is right-clicked", async () => {
state.detail = {
isPending: false,
isError: false,
data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" },
error: null,
};
renderWithI18n(<Harness pathname="/acme/issues" />);
const row = (await screen.findByText("Keep this pin")).closest("a")!;
fireEvent.contextMenu(row);
expect(await screen.findByText("sidebar menu: Keep this pin")).toBeInTheDocument();
expect(row).toHaveAttribute("data-popup-open");
});
it("previews five pins and only counts a hidden pin as active once expanded", () => {
state.pins = Array.from({ length: 6 }, (_, i) => pin(i + 1));
state.detail = {
+49 -34
View File
@@ -25,12 +25,18 @@ import { pinListOptions } from "@multica/core/pins/queries";
import { useSidebarTreeStore } from "@multica/core/issues/stores/sidebar-tree-store";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import { useWorkspacePaths, type WorkspacePaths } from "@multica/core/paths";
import type { PinnedItem } from "@multica/core/types";
import type { Issue, PinnedItem } from "@multica/core/types";
import { AppLink } from "../../navigation";
import { StatusIcon } from "../../issues/components/status-icon";
import { IssueActionsContextMenu } from "../../issues/actions";
import { useT } from "../../i18n";
import { PANEL_ROW_CLASS, PanelList, PanelSection, isPathWithin, panelRowStateClass } from "../panel-nav";
import { IssueAgentBadge, SIDEBAR_SHIMMER_CLASS, useIssueAgentState } from "./sidebar-tree";
import {
IssueAgentBadge,
SIDEBAR_SHIMMER_CLASS,
issueRowMenuOpenClass,
useIssueAgentState,
} from "./sidebar-tree";
// Stable empty default: an inline `= []` on useQuery is a new reference every
// render while the query is disabled, which re-fires the sync effect below.
@@ -184,6 +190,7 @@ function SortablePinItem({
running = false,
trailing,
onNavigate,
issue,
}: {
pin: PinnedItem;
href: string;
@@ -196,6 +203,8 @@ function SortablePinItem({
trailing?: React.ReactNode;
/** Runs on a real click (not a drag-release) before navigation. */
onNavigate?: () => void;
/** A pinned issue's row opens the issue context menu on right-click. */
issue?: Issue;
}) {
const { t } = useT("layout");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
@@ -205,6 +214,42 @@ function SortablePinItem({
if (isDragging) wasDragged.current = true;
}, [isDragging]);
const link = (
<AppLink
href={href}
newTabTitle={label}
draggable={false}
aria-current={active ? "page" : undefined}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
onNavigate?.();
}}
className={cn(
PANEL_ROW_CLASS,
panelRowStateClass(active),
issue && issueRowMenuOpenClass(active),
trailing ? "pr-2" : "pr-8",
isDragging && "pointer-events-none",
)}
>
{iconNode}
<ShimmerText active={running} className={cn("min-w-0 flex-1 truncate", SIDEBAR_SHIMMER_CLASS)}>
{label}
</ShimmerText>
{/* Holds the same right-edge slot as a tree row's avatars, and yields
it to the unpin button on hover. */}
{trailing && (
<span className="flex shrink-0 items-center group-hover/pin:invisible group-focus-within/pin:invisible">
{trailing}
</span>
)}
</AppLink>
);
return (
<li
ref={setNodeRef}
@@ -213,38 +258,7 @@ function SortablePinItem({
{...attributes}
{...listeners}
>
<AppLink
href={href}
newTabTitle={label}
draggable={false}
aria-current={active ? "page" : undefined}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
onNavigate?.();
}}
className={cn(
PANEL_ROW_CLASS,
panelRowStateClass(active),
trailing ? "pr-2" : "pr-8",
isDragging && "pointer-events-none",
)}
>
{iconNode}
<ShimmerText active={running} className={cn("min-w-0 flex-1 truncate", SIDEBAR_SHIMMER_CLASS)}>
{label}
</ShimmerText>
{/* Holds the same right-edge slot as a tree row's avatars, and yields
it to the unpin button on hover. */}
{trailing && (
<span className="flex shrink-0 items-center group-hover/pin:invisible group-focus-within/pin:invisible">
{trailing}
</span>
)}
</AppLink>
{issue ? <IssueActionsContextMenu issue={issue}>{link}</IssueActionsContextMenu> : link}
<TooltipHint label={t(($) => $.sidebar.unpin_tooltip)}>
<button
type="button"
@@ -317,6 +331,7 @@ function PinRow({
active={active}
running={!!agents?.running}
trailing={agents && <IssueAgentBadge agents={agents} />}
issue={issue}
iconNode={
<StatusIcon
status={issue.status}
@@ -7,8 +7,17 @@ import { buildIssueStatusCatalog } from "@multica/core/issue-statuses/queries";
import { issueKeys } from "@multica/core/issues/queries";
import type { AgentTask, Issue, ListIssuesParams } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
import { IssueContextMenuProvider } from "../../issues/actions";
import { SidebarIssueList } from "./sidebar-tree";
vi.mock("../../issues/actions/issue-actions-menu-items", () => ({
contextPrimitives: {},
IssueActionsMenuItems: ({ issue, variant }: { issue: Issue; variant?: string }) => (
<div>{`${variant} menu: ${issue.title}`}</div>
),
}));
vi.mock("../../issues/actions/use-issue-actions", () => ({ useIssueActions: () => ({}) }));
vi.mock("@multica/core/issue-statuses/hooks", () => ({
useIssueStatuses: () => buildIssueStatusCatalog([]),
}));
@@ -57,14 +66,16 @@ function renderList({
qc.setQueryData(agentTaskSnapshotOptions("ws-1").queryKey, tasks as AgentTask[]);
renderWithI18n(
<QueryClientProvider client={qc}>
<SidebarIssueList
id="list"
wsId="ws-1"
source={SOURCE}
params={params}
activeIssueId="issue-2"
onCollapse={onCollapse}
/>
<IssueContextMenuProvider variant="sidebar">
<SidebarIssueList
id="list"
wsId="ws-1"
source={SOURCE}
params={params}
activeIssueId="issue-2"
onCollapse={onCollapse}
/>
</IssueContextMenuProvider>
</QueryClientProvider>,
);
return { onCollapse };
@@ -119,4 +130,17 @@ describe("SidebarIssueList", () => {
fireEvent.keyDown(rows()[0]!, { key: "ArrowLeft" });
expect(onCollapse).toHaveBeenCalled();
});
it("opens the sidebar issue menu on right-click and keeps the row lit while it is open", async () => {
renderList();
const row = screen.getByRole("link", { name: "Issue 3" });
const selected = screen.getByRole("link", { name: "Issue 2" });
fireEvent.contextMenu(row);
expect(await screen.findByText("sidebar menu: Issue 3")).toBeInTheDocument();
expect(row).toHaveAttribute("data-popup-open");
expect(row.className).toContain("data-popup-open:bg-surface-hover");
// The selected row keeps its own fill.
expect(selected.className).not.toContain("data-popup-open:");
});
});
+28 -19
View File
@@ -15,6 +15,7 @@ import type { AgentTask, Issue, ListIssuesParams } from "@multica/core/types";
import { AppLink } from "../../navigation";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { StatusIcon } from "../../issues/components/status-icon";
import { IssueActionsContextMenu } from "../../issues/actions";
import { selectIssueTasks } from "../../issues/surface/activity";
import { useT } from "../../i18n";
import { PANEL_ROW_CLASS, panelRowStateClass } from "../panel-nav";
@@ -149,6 +150,12 @@ export const TreeRow = forwardRef<HTMLAnchorElement, TreeRowProps>(function Tree
const TREE_CHILD_ROW_CLASS =
"flex min-h-[26px] w-full items-center gap-2 rounded-lg px-2 py-[3px] text-left text-label transition-colors outline-none focus-visible:outline-2 focus-visible:outline-ring";
/** An issue row whose context menu is open stays lit as if hovered. The
* selected row keeps its own fill. */
export function issueRowMenuOpenClass(active: boolean) {
return active ? undefined : "data-popup-open:bg-surface-hover data-popup-open:text-foreground";
}
const EMPTY_PARAMS: ListIssuesParams = {};
/**
@@ -302,24 +309,26 @@ const SidebarIssueRow = memo(function SidebarIssueRow({
const agents = useIssueAgentState(wsId, issue.id);
return (
<AppLink
href={href}
newTabTitle={issue.title}
aria-current={active ? "page" : undefined}
onKeyDown={onKeyDown}
className={cn(TREE_CHILD_ROW_CLASS, panelRowStateClass(active))}
>
<StatusIcon
status={issue.status}
color={statusColor}
icon={statusIcon}
category={issueStatusCategory(issue) ?? undefined}
className="size-3 shrink-0"
/>
<ShimmerText active={!!agents?.running} className={cn("min-w-0 flex-1 truncate", SIDEBAR_SHIMMER_CLASS)}>
{issue.title}
</ShimmerText>
{agents && <IssueAgentBadge agents={agents} />}
</AppLink>
<IssueActionsContextMenu issue={issue}>
<AppLink
href={href}
newTabTitle={issue.title}
aria-current={active ? "page" : undefined}
onKeyDown={onKeyDown}
className={cn(TREE_CHILD_ROW_CLASS, panelRowStateClass(active), issueRowMenuOpenClass(active))}
>
<StatusIcon
status={issue.status}
color={statusColor}
icon={statusIcon}
category={issueStatusCategory(issue) ?? undefined}
className="size-3 shrink-0"
/>
<ShimmerText active={!!agents?.running} className={cn("min-w-0 flex-1 truncate", SIDEBAR_SHIMMER_CLASS)}>
{issue.title}
</ShimmerText>
{agents && <IssueAgentBadge agents={agents} />}
</AppLink>
</IssueActionsContextMenu>
);
});
+32 -27
View File
@@ -11,6 +11,7 @@ import { useSidebarTreeStore } from "@multica/core/issues/stores/sidebar-tree-st
import { projectListOptions } from "@multica/core/projects/queries";
import type { Project } from "@multica/core/types";
import { useNavigation } from "../../navigation";
import { IssueContextMenuProvider } from "../../issues/actions";
import { useT } from "../../i18n";
import { PanelBody, PanelHeader } from "../panel-nav";
import { PinnedSection, usePanelPins } from "./pinned-section";
@@ -90,35 +91,39 @@ export function WorkPanel() {
return (
<>
<PanelHeader title={t(($) => $.spaces.work)} />
<PanelBody onClickCapture={rememberClickedIssue}>
{wsId && (
<ViewsSection
wsId={wsId}
pathname={pathname}
plainRowsLit={plainRowsLit}
issueLitInTree={issueLitInTree}
activeIssueId={plainRowsLit ? currentIssueId : null}
/>
)}
{/* Outside PanelBody: clicks inside the menu's portal still bubble
through the React tree, and must not reach rememberClickedIssue. */}
<IssueContextMenuProvider variant="sidebar">
<PanelBody onClickCapture={rememberClickedIssue}>
{wsId && (
<ViewsSection
wsId={wsId}
pathname={pathname}
plainRowsLit={plainRowsLit}
issueLitInTree={issueLitInTree}
activeIssueId={plainRowsLit ? currentIssueId : null}
/>
)}
{wsId && (
<div data-pinned-section className="contents">
<PinnedSection pins={pins} wsId={wsId} />
</div>
)}
{wsId && (
<div data-pinned-section className="contents">
<PinnedSection pins={pins} wsId={wsId} />
</div>
)}
{wsId && (
<ProjectsSection
wsId={wsId}
userId={userId}
pathname={pathname}
projects={shownProjects}
totalProjects={projects.length}
plainRowsLit={plainRowsLit}
activeIssueId={plainRowsLit ? currentIssueId : null}
/>
)}
</PanelBody>
{wsId && (
<ProjectsSection
wsId={wsId}
userId={userId}
pathname={pathname}
projects={shownProjects}
totalProjects={projects.length}
plainRowsLit={plainRowsLit}
activeIssueId={plainRowsLit ? currentIssueId : null}
/>
)}
</PanelBody>
</IssueContextMenuProvider>
</>
);
}
+3
View File
@@ -965,6 +965,9 @@
"copy_link": "Copy link",
"copy_workdir_path": "Copy local workdir path",
"copy_project_directory_path": "Copy project directory path",
"copy_identifier": "Copy identifier",
"identifier_copied": "Copied {{identifier}}",
"stop_agent_run": "Stop {{name}}’s run",
"relations": "Relations",
"create_sub_issue": "Create sub-issue",
"set_parent_issue": "Set parent issue...",
+3
View File
@@ -965,6 +965,9 @@
"copy_link": "Copier le lien",
"copy_workdir_path": "Copier le chemin du répertoire de travail local",
"copy_project_directory_path": "Copier le chemin du répertoire du projet",
"copy_identifier": "Copier l'identifiant",
"identifier_copied": "{{identifier}} copié",
"stop_agent_run": "Arrêter l’exécution de {{name}}",
"relations": "Relations",
"create_sub_issue": "Créer une sous-tâche",
"set_parent_issue": "Définir la tâche parente...",
+3
View File
@@ -920,6 +920,9 @@
"copy_link": "リンクをコピー",
"copy_workdir_path": "ローカルの作業ディレクトリのパスをコピー",
"copy_project_directory_path": "プロジェクトディレクトリのパスをコピー",
"copy_identifier": "識別子をコピー",
"identifier_copied": "{{identifier}} をコピーしました",
"stop_agent_run": "{{name}} の実行を停止",
"relations": "関係",
"create_sub_issue": "サブタスクを作成",
"set_parent_issue": "親タスクを設定...",
+3
View File
@@ -920,6 +920,9 @@
"copy_link": "링크 복사",
"copy_workdir_path": "로컬 작업 디렉터리 경로 복사",
"copy_project_directory_path": "프로젝트 디렉터리 경로 복사",
"copy_identifier": "식별자 복사",
"identifier_copied": "{{identifier}}를 복사했습니다",
"stop_agent_run": "{{name}} 실행 중지",
"relations": "관계",
"create_sub_issue": "하위 태스크 만들기",
"set_parent_issue": "상위 태스크 설정...",
@@ -920,6 +920,9 @@
"copy_link": "复制链接",
"copy_workdir_path": "复制本地 workdir 路径",
"copy_project_directory_path": "复制项目目录路径",
"copy_identifier": "复制标识符",
"identifier_copied": "已复制 {{identifier}}",
"stop_agent_run": "停止 {{name}} 的运行",
"relations": "关系",
"create_sub_issue": "创建子任务",
"set_parent_issue": "设置父任务...",
@@ -920,6 +920,9 @@
"copy_link": "複製連結",
"copy_workdir_path": "複製本地 workdir 路徑",
"copy_project_directory_path": "複製專案目錄路徑",
"copy_identifier": "複製識別符號",
"identifier_copied": "已複製 {{identifier}}",
"stop_agent_run": "停止 {{name}} 的執行",
"relations": "關係",
"create_sub_issue": "建立子任務",
"set_parent_issue": "設定父任務...",