mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
Cursor
multica-agent
parent
4bfa916450
commit
38a65f4988
@@ -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 = {
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "로컬 작업 디렉터리 경로 복사",
|
||||
"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": "設定父任務...",
|
||||
|
||||
Reference in New Issue
Block a user