Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-row-context-menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dashboard": minor
---

Add right-click context menus to every table row, card, and list entry with per-entry actions, mirroring each entry's "⋯" menu: sources, deployments, exclusions, policy center, shadow MCP inventory, team members, roles, remote identity provider tabs, tool lists, chat logs, project cards, and plugin cards. Menus share one action definition with the visible kebab so the two stay in sync, and sources table rows are now real links (native open-in-new-tab and copy-link).
2 changes: 1 addition & 1 deletion client/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/postprocessing": "^3.0.4",
"@speakeasy-api/moonshine": "1.43.1",
"@speakeasy-api/moonshine": "1.44.0",
"@tailwindcss/vite": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "^3.14.5",
Expand Down
42 changes: 35 additions & 7 deletions client/dashboard/src/components/card-context-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Icon } from "@speakeasy-api/moonshine";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "./ui/context-menu";
import type { Action } from "./ui/more-actions";
Expand Down Expand Up @@ -38,21 +40,47 @@ export function CardContextMenu({
<ContextMenuTrigger asChild>
<div className={cn("h-full", className)}>{children}</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[10rem]">
{actions.map((action, index) => (
<ActionContextMenuContent actions={actions} />
</ContextMenu>
);
}

/**
* The `ContextMenuContent` for a right-click menu built from an `Action[]`.
* Shared by CardContextMenu and TableRowContextMenu so every context menu in
* the app maps actions to items the same way.
*/
export function ActionContextMenuContent({
actions,
}: {
actions: Action[];
}): React.JSX.Element {
return (
<ContextMenuContent className="min-w-[10rem]">
{actions.map((action, index) => (
<React.Fragment key={index}>
{action.separatorBefore && index > 0 && <ContextMenuSeparator />}
<ContextMenuItem
key={index}
disabled={action.disabled}
variant={action.destructive ? "destructive" : "default"}
onSelect={() => action.onClick()}
>
{action.label}
{action.description ? (
<span className="flex min-w-0 flex-col">
<span>{action.label}</span>
<span className="text-muted-foreground text-xs">
{action.description}
</span>
</span>
) : (
action.label
)}
{action.icon && (
<Icon name={action.icon} className="size-3 shrink-0" />
)}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
</React.Fragment>
))}
</ContextMenuContent>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/components/ui/sheet";
import { Type } from "@/components/ui/type";
import { cn } from "@/lib/utils";
import { shadowMCPInventoryActions } from "./shadowMCPInventoryActionItems";
import type { AccessMember } from "@gram/client/models/components/accessmember.js";
import type { Role } from "@gram/client/models/components/role.js";
import type { RiskPolicy } from "@gram/client/models/components/riskpolicy.js";
Expand Down Expand Up @@ -146,11 +147,6 @@ function initialPolicyIDsForAction(
return shadowMCPPolicyIDs;
}

function openActionFromMenu(event: Event, openAction: () => void) {
event.stopPropagation();
window.setTimeout(openAction, 0);
}

export function ShadowMCPInventoryActionMenu({
disabled,
onOpenAction,
Expand All @@ -163,8 +159,7 @@ export function ShadowMCPInventoryActionMenu({
) => void;
server: ShadowMCPInventoryServer;
}): JSX.Element {
const hasRequest = server.requestCount > 0;
const hasAllowDecision = server.access === "allowed";
const actions = shadowMCPInventoryActions(server, { disabled, onOpenAction });

return (
<DropdownMenu modal={false}>
Expand All @@ -183,42 +178,17 @@ export function ShadowMCPInventoryActionMenu({
align="end"
onClick={(event) => event.stopPropagation()}
>
{hasRequest && (
{actions.map((action, index) => (
<DropdownMenuItem
key={index}
onSelect={(event) => {
openActionFromMenu(event, () => onOpenAction("review", server));
event.stopPropagation();
action.onClick();
}}
>
Review Request
{action.label}
</DropdownMenuItem>
)}
{!hasRequest && !hasAllowDecision && (
<DropdownMenuItem
onSelect={(event) => {
openActionFromMenu(event, () => onOpenAction("add", server));
}}
>
Add Allow Rule
</DropdownMenuItem>
)}
{hasAllowDecision && (
<>
<DropdownMenuItem
onSelect={(event) => {
openActionFromMenu(event, () => onOpenAction("edit", server));
}}
>
Edit Rule
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
openActionFromMenu(event, () => onOpenAction("delete", server));
}}
>
Delete Rule
</DropdownMenuItem>
</>
)}
))}
</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { formatShortDate } from "@/components/access/shadow-mcp-utils";
import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { cn } from "@/lib/utils";
import {
type ActiveInventoryAction,
Expand All @@ -30,6 +31,7 @@ import {
ShadowMCPInventoryActionSheet,
type ShadowMCPPolicy,
} from "./ShadowMCPInventoryActions";
import { shadowMCPInventoryActions } from "./shadowMCPInventoryActionItems";
import {
shadowMCPInventoryStatus,
shadowMCPInventoryStatusBadgeVariant,
Expand Down Expand Up @@ -460,6 +462,18 @@ export function ShadowMCPInventoryTable({
onRowClick={onOpenServer}
rowKey={(row) => row.canonicalServerUrl}
className="min-h-0 content-start overflow-y-auto"
renderRow={(row, rowElement) => (
<TableRowContextMenu
key={row.canonicalServerUrl}
actions={shadowMCPInventoryActions(row, {
disabled: isActionPending,
onOpenAction: (mode, selectedServer) =>
setActiveAction({ mode, server: selectedServer }),
})}
>
{rowElement}
</TableRowContextMenu>
)}
/>
</Table>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Action } from "@/components/ui/more-actions";
import type { ShadowMCPInventoryServer } from "@gram/client/models/components/shadowmcpinventoryserver.js";
import type { InventoryActionMode } from "./ShadowMCPInventoryActions";

/**
* The per-server action set, shared by the visible "⋯" dropdown and the row's
* right-click context menu so the two stay in sync. The entries are additive
* over the server's state — a server with both a pending request and an
* existing allow rule offers Review Request AND Edit/Delete Rule. Each
* onClick defers via setTimeout so the menu can close before a sheet opens.
*/
export function shadowMCPInventoryActions(
server: ShadowMCPInventoryServer,
{
disabled,
onOpenAction,
}: {
disabled: boolean;
onOpenAction: (
mode: InventoryActionMode,
server: ShadowMCPInventoryServer,
) => void;
},
): Action[] {
const hasRequest = server.requestCount > 0;
const hasAllowDecision = server.access === "allowed";

const openAction = (mode: InventoryActionMode) => () => {
window.setTimeout(() => onOpenAction(mode, server), 0);
};

const actions: Action[] = [];
if (hasRequest) {
actions.push({
label: "Review Request",
disabled,
onClick: openAction("review"),
});
}
if (!hasRequest && !hasAllowDecision) {
actions.push({
label: "Add Allow Rule",
disabled,
onClick: openAction("add"),
});
}
if (hasAllowDecision) {
actions.push(
{ label: "Edit Rule", disabled, onClick: openAction("edit") },
{
label: "Delete Rule",
destructive: true,
disabled,
onClick: openAction("delete"),
},
);
}
return actions;
}
124 changes: 64 additions & 60 deletions client/dashboard/src/components/sources/SourceTableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { DotRow } from "@/components/ui/dot-row";
import { MoreActions } from "@/components/ui/more-actions";
import { Type } from "@/components/ui/type";
Expand Down Expand Up @@ -125,71 +126,74 @@ export function SourceTableRow({
: asset.name;

return (
<DotRow
icon={iconContent}
onClick={() => routes.sources.source.goTo(sourceKind, asset.slug)}
>
{/* Name */}
<td className="px-3 py-3">
<Type
variant="subheading"
as="div"
className="group-hover:text-primary truncate text-sm transition-colors"
title={displayName}
>
{displayName}
</Type>
</td>
<TableRowContextMenu actions={actions}>
<DotRow
icon={iconContent}
href={routes.sources.source.href(sourceKind, asset.slug)}
ariaLabel={`View source ${displayName}`}
>
{/* Name */}
<td className="px-3 py-3">
<Type
variant="subheading"
as="div"
className="group-hover:text-primary truncate text-sm transition-colors"
title={displayName}
>
{displayName}
</Type>
</td>

{/* Type */}
<td className="px-3 py-3">
<Badge variant="neutral">{sourceTypeLabel}</Badge>
</td>
{/* Type */}
<td className="px-3 py-3">
<Badge variant="neutral">{sourceTypeLabel}</Badge>
</td>

{/* Tools */}
<td className="px-3 py-3">
<Type small muted>
{toolCount}
</Type>
</td>
{/* Tools */}
<td className="px-3 py-3">
<Type small muted>
{toolCount}
</Type>
</td>

{/* Created */}
<td className="px-3 py-3">
<Type small muted>
{formatDate(createdAt)}
</Type>
</td>
{/* Created */}
<td className="px-3 py-3">
<Type small muted>
{formatDate(createdAt)}
</Type>
</td>

{/* Updated */}
<td className="px-3 py-3">
<Type small muted>
{formatDate(updatedAt)}
</Type>
</td>
{/* Updated */}
<td className="px-3 py-3">
<Type small muted>
{formatDate(updatedAt)}
</Type>
</td>

{/* Health */}
<td className="px-3 py-3">
{causingFailure && (
<div className="text-destructive flex items-center gap-1.5">
<CircleAlertIcon className="size-3.5" />
<Type small className="text-destructive">
Error
</Type>
</div>
)}
</td>
{/* Health */}
<td className="px-3 py-3">
{causingFailure && (
<div className="text-destructive flex items-center gap-1.5">
<CircleAlertIcon className="size-3.5" />
<Type small className="text-destructive">
Error
</Type>
</div>
)}
</td>

{/* Actions */}
<td className="px-3 py-3">
{actions.length > 0 && (
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<MoreActions actions={actions} />
</div>
)}
</td>
</DotRow>
{/* Actions */}
<td className="px-3 py-3">
{actions.length > 0 && (
<div
className="relative z-20 flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<MoreActions actions={actions} />
</div>
)}
</td>
</DotRow>
</TableRowContextMenu>
);
}
Loading
Loading