mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
feat(inbox): add operator search and keyboard controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
3ab7d52f00
25 changed files with 1340 additions and 114 deletions
|
|
@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
|
||||
import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||
|
|
@ -56,6 +56,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -148,31 +149,91 @@ describe("InboxIssueMetaLeading", () => {
|
|||
container.remove();
|
||||
});
|
||||
|
||||
it("neutralizes selected status and live accents", () => {
|
||||
it("keeps status and live accents visible", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
||||
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
|
||||
});
|
||||
|
||||
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
|
||||
const statusIcon = container.querySelector('span[class*="border-blue-600"]');
|
||||
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
|
||||
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
||||
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
||||
);
|
||||
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
|
||||
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
|
||||
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
||||
|
||||
expect(statusIcon).not.toBeNull();
|
||||
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||
expect(statusIcon?.className).not.toContain("!border-muted-foreground");
|
||||
expect(statusIcon?.className).not.toContain("!text-muted-foreground");
|
||||
expect(liveBadge).not.toBeNull();
|
||||
expect(liveBadge?.className).toContain("bg-muted");
|
||||
expect(liveBadge?.className).toContain("bg-blue-500/10");
|
||||
expect(liveBadgeLabel).not.toBeNull();
|
||||
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
|
||||
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
|
||||
expect(liveBadgeLabel?.className).toContain("text-blue-600");
|
||||
expect(liveDot).not.toBeNull();
|
||||
expect(pulseRing).toBeNull();
|
||||
expect(pulseRing).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxIssueTrailingColumns", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders an empty tags cell when an issue has no labels", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<InboxIssueTrailingColumns
|
||||
issue={createIssue({ labels: [], labelIds: [] })}
|
||||
columns={["labels"]}
|
||||
projectName={null}
|
||||
projectColor={null}
|
||||
workspaceName={null}
|
||||
assigneeName={null}
|
||||
currentUserId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<InboxIssueTrailingColumns
|
||||
issue={createIssue()}
|
||||
columns={["workspace"]}
|
||||
projectName={null}
|
||||
projectColor={null}
|
||||
workspaceName={null}
|
||||
assigneeName={null}
|
||||
currentUserId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -4,15 +4,24 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { ApiError } from "../api/client";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
|
|
@ -21,11 +30,31 @@ import { SwipeToArchive } from "../components/SwipeToArchive";
|
|||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -40,19 +69,29 @@ import {
|
|||
X,
|
||||
RotateCcw,
|
||||
UserPlus,
|
||||
Columns3,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
InboxApprovalFilter,
|
||||
type InboxIssueColumn,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
|
|
@ -100,58 +139,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
|||
|
||||
|
||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
|
||||
|
||||
function getSelectedUnreadButtonClass(selected: boolean): string {
|
||||
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20";
|
||||
}
|
||||
|
||||
function getSelectedUnreadDotClass(selected: boolean): string {
|
||||
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400";
|
||||
}
|
||||
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
|
||||
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||
status: "Status",
|
||||
id: "ID",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
workspace: "Workspace",
|
||||
labels: "Tags",
|
||||
updated: "Last updated",
|
||||
};
|
||||
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||
status: "Issue state chip on the left edge.",
|
||||
id: "Ticket identifier like PAP-1009.",
|
||||
assignee: "Assigned agent or board user.",
|
||||
project: "Linked project pill with its color.",
|
||||
workspace: "Execution or project workspace used for the issue.",
|
||||
labels: "Issue labels and tags.",
|
||||
updated: "Latest visible activity time.",
|
||||
};
|
||||
|
||||
export function InboxIssueMetaLeading({
|
||||
issue,
|
||||
selected,
|
||||
isLive,
|
||||
showStatus = true,
|
||||
showIdentifier = true,
|
||||
}: {
|
||||
issue: Issue;
|
||||
selected: boolean;
|
||||
isLive: boolean;
|
||||
showStatus?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
className={selected ? selectedInboxAccentClass : undefined}
|
||||
/>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{showStatus ? (
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
) : null}
|
||||
{showIdentifier ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
) : null}
|
||||
{isLive && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||
selected ? "bg-muted" : "bg-blue-500/10",
|
||||
"bg-blue-500/10",
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
{!selected ? (
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
) : null}
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
selected ? "bg-muted-foreground/70" : "bg-blue-500",
|
||||
"bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-[11px] font-medium sm:inline",
|
||||
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
|
||||
"text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
Live
|
||||
|
|
@ -162,6 +212,150 @@ export function InboxIssueMetaLeading({
|
|||
);
|
||||
}
|
||||
|
||||
function issueActivityText(issue: Issue): string {
|
||||
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||
}
|
||||
|
||||
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||
return columns
|
||||
.map((column) => {
|
||||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||
return "minmax(6rem, 7rem)";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function InboxIssueTrailingColumns({
|
||||
issue,
|
||||
columns,
|
||||
projectName,
|
||||
projectColor,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
currentUserId,
|
||||
}: {
|
||||
issue: Issue;
|
||||
columns: InboxIssueColumn[];
|
||||
projectName: string | null;
|
||||
projectColor: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
if (column === "assignee") {
|
||||
if (issue.assigneeAgentId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (issue.assigneeUserId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
Unassigned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "project") {
|
||||
if (projectName) {
|
||||
const accentColor = projectColor ?? "#64748b";
|
||||
return (
|
||||
<span
|
||||
key={column}
|
||||
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
||||
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
<span className="truncate">{projectName}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
No project
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (column === "labels") {
|
||||
if ((issue.labels ?? []).length > 0) {
|
||||
return (
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||
style={{
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 2 ? (
|
||||
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (column === "workspace") {
|
||||
if (!workspaceName) {
|
||||
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{workspaceName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||
{activityText}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FailedRunInboxRow({
|
||||
run,
|
||||
issueById,
|
||||
|
|
@ -211,13 +405,13 @@ export function FailedRunInboxRow({
|
|||
onClick={onMarkRead}
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
"hover:bg-blue-500/20",
|
||||
)}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span className={cn(
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
"bg-blue-600 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -367,13 +561,13 @@ function ApprovalInboxRow({
|
|||
onClick={onMarkRead}
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
"hover:bg-blue-500/20",
|
||||
)}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span className={cn(
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
"bg-blue-600 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -506,13 +700,13 @@ function JoinRequestInboxRow({
|
|||
onClick={onMarkRead}
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
"hover:bg-blue-500/20",
|
||||
)}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span className={cn(
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
"bg-blue-600 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -597,8 +791,19 @@ export function Inbox() {
|
|||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const keyboardShortcutsEnabled = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
}).data?.keyboardShortcuts === true;
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
|
||||
|
|
@ -618,12 +823,31 @@ export function Inbox() {
|
|||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const { data: executionWorkspaces = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||
: ["execution-workspaces", "__disabled__"],
|
||||
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Inbox" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
|
@ -631,6 +855,7 @@ export function Inbox() {
|
|||
useEffect(() => {
|
||||
saveLastInboxTab(tab);
|
||||
setSelectedIndex(-1);
|
||||
setSearchQuery("");
|
||||
}, [tab]);
|
||||
|
||||
const {
|
||||
|
|
@ -731,6 +956,59 @@ export function Inbox() {
|
|||
for (const issue of issues ?? []) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
const projectById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; color: string | null }>();
|
||||
for (const project of projects ?? []) {
|
||||
map.set(project.id, { name: project.name, color: project.color });
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
const projectWorkspaceById = useMemo(() => {
|
||||
const map = new Map<string, { name: string }>();
|
||||
for (const project of projects ?? []) {
|
||||
for (const workspace of project.workspaces ?? []) {
|
||||
map.set(workspace.id, { name: workspace.name });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const project of projects ?? []) {
|
||||
const defaultWorkspaceId =
|
||||
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
?? project.primaryWorkspace?.id
|
||||
?? null;
|
||||
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
|
||||
}
|
||||
return map;
|
||||
}, [projects]);
|
||||
const executionWorkspaceById = useMemo(() => {
|
||||
const map = new Map<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>();
|
||||
for (const workspace of executionWorkspaces) {
|
||||
map.set(workspace.id, {
|
||||
name: workspace.name,
|
||||
mode: workspace.mode,
|
||||
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [executionWorkspaces]);
|
||||
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||
const availableIssueColumns = useMemo(
|
||||
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||
[isolatedWorkspacesEnabled],
|
||||
);
|
||||
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||
const visibleTrailingIssueColumns = useMemo(
|
||||
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||
);
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const failedRuns = useMemo(
|
||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
||||
|
|
@ -784,10 +1062,81 @@ export function Inbox() {
|
|||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
|
||||
);
|
||||
|
||||
const filteredWorkItems = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return workItemsToRender;
|
||||
return workItemsToRender.filter((item) => {
|
||||
if (item.kind === "issue") {
|
||||
const issue = item.issue;
|
||||
if (issue.title.toLowerCase().includes(q)) return true;
|
||||
if (issue.identifier?.toLowerCase().includes(q)) return true;
|
||||
if (issue.description?.toLowerCase().includes(q)) return true;
|
||||
if (isolatedWorkspacesEnabled) {
|
||||
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
if (workspaceName?.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (item.kind === "approval") {
|
||||
const a = item.approval;
|
||||
const label = approvalLabel(a.type, a.payload as Record<string, unknown> | null);
|
||||
if (label.toLowerCase().includes(q)) return true;
|
||||
if (a.type.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
}
|
||||
if (item.kind === "failed_run") {
|
||||
const run = item.run;
|
||||
const name = agentById.get(run.agentId);
|
||||
if (name?.toLowerCase().includes(q)) return true;
|
||||
const msg = runFailureMessage(run);
|
||||
if (msg.toLowerCase().includes(q)) return true;
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
if (issueId) {
|
||||
const issue = issueById.get(issueId);
|
||||
if (issue?.title.toLowerCase().includes(q)) return true;
|
||||
if (issue?.identifier?.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (item.kind === "join_request") {
|
||||
const jr = item.joinRequest;
|
||||
if (jr.agentName?.toLowerCase().includes(q)) return true;
|
||||
if (jr.capabilities?.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [
|
||||
workItemsToRender,
|
||||
searchQuery,
|
||||
agentById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
executionWorkspaceById,
|
||||
issueById,
|
||||
isolatedWorkspacesEnabled,
|
||||
projectWorkspaceById,
|
||||
]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agentById.get(id) ?? null;
|
||||
};
|
||||
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||
const normalized = normalizeInboxIssueColumns(next);
|
||||
setVisibleIssueColumns(normalized);
|
||||
saveInboxIssueColumns(normalized);
|
||||
}, []);
|
||||
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setIssueColumns([...visibleIssueColumns, column]);
|
||||
return;
|
||||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
|
|
@ -858,7 +1207,7 @@ export function Inbox() {
|
|||
payload,
|
||||
});
|
||||
if (!("id" in result)) {
|
||||
throw new Error("Retry was skipped because the agent is not currently invokable.");
|
||||
throw new Error(result.message ?? "Retry was skipped.");
|
||||
}
|
||||
return { newRun: result, originalRun: run };
|
||||
},
|
||||
|
|
@ -881,6 +1230,7 @@ export function Inbox() {
|
|||
});
|
||||
|
||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
|
||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -1017,12 +1367,12 @@ export function Inbox() {
|
|||
|
||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||
useEffect(() => {
|
||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
||||
}, [workItemsToRender.length]);
|
||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
|
||||
}, [filteredWorkItems.length]);
|
||||
|
||||
// Use refs for keyboard handler to avoid stale closures
|
||||
const kbStateRef = useRef({
|
||||
workItems: workItemsToRender,
|
||||
workItems: filteredWorkItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivingIssueIds,
|
||||
|
|
@ -1031,7 +1381,7 @@ export function Inbox() {
|
|||
readItems,
|
||||
});
|
||||
kbStateRef.current = {
|
||||
workItems: workItemsToRender,
|
||||
workItems: filteredWorkItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivingIssueIds,
|
||||
|
|
@ -1061,6 +1411,8 @@ export function Inbox() {
|
|||
|
||||
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
||||
useEffect(() => {
|
||||
if (!keyboardShortcutsEnabled) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
|
|
@ -1068,9 +1420,8 @@ export function Inbox() {
|
|||
const target = e.target;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
|
||||
target.isContentEditable ||
|
||||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
|
||||
isKeyboardShortcutTextInputTarget(target) ||
|
||||
hasBlockingShortcutDialog(document) ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey
|
||||
|
|
@ -1148,7 +1499,8 @@ export function Inbox() {
|
|||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
|
||||
} else if (item.kind === "approval") {
|
||||
act.navigate(`/approvals/${item.approval.id}`);
|
||||
} else if (item.kind === "failed_run") {
|
||||
|
|
@ -1162,7 +1514,7 @@ export function Inbox() {
|
|||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [getWorkItemKey, issueLinkState]);
|
||||
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
|
|
@ -1184,7 +1536,7 @@ export function Inbox() {
|
|||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissed.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||
const showWorkItemsSection = filteredWorkItems.length > 0;
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
|
|
@ -1214,7 +1566,6 @@ export function Inbox() {
|
|||
const unreadIssueIds = markAllReadIssues
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
|
@ -1236,17 +1587,104 @@ export function Inbox() {
|
|||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||
Show / hide columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Desktop issue rows
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
Choose which inbox columns stay visible
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{availableIssueColumns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column}
|
||||
checked={visibleIssueColumnSet.has(column)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
|
||||
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{inboxIssueColumnLabels[column]}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{inboxIssueColumnDescriptions[column]}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
className="rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
Reset defaults
|
||||
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{canMarkAllRead && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => setShowMarkAllReadConfirm(true)}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||
</Button>
|
||||
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mark all as read?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowMarkAllReadConfirm(false);
|
||||
markAllReadMutation.mutate(unreadIssueIds);
|
||||
}}
|
||||
>
|
||||
Mark all as read
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1297,9 +1735,11 @@ export function Inbox() {
|
|||
|
||||
{allLoaded && visibleSections.length === 0 && (
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
icon={searchQuery.trim() ? Search : InboxIcon}
|
||||
message={
|
||||
tab === "mine"
|
||||
searchQuery.trim()
|
||||
? "No inbox items match your search."
|
||||
: tab === "mine"
|
||||
? "Inbox zero."
|
||||
: tab === "unread"
|
||||
? "No new inbox items."
|
||||
|
|
@ -1315,7 +1755,7 @@ export function Inbox() {
|
|||
{showSeparatorBefore("work_items") && <Separator />}
|
||||
<div>
|
||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.flatMap((item, index) => {
|
||||
{filteredWorkItems.flatMap((item, index) => {
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
|
|
@ -1331,14 +1771,14 @@ export function Inbox() {
|
|||
index > 0 &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
workItemsToRender[index - 1].timestamp >= todayCutoff;
|
||||
filteredWorkItems[index - 1].timestamp >= todayCutoff;
|
||||
const elements: ReactNode[] = [];
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Earlier
|
||||
<div className="flex-1 border-t border-zinc-600" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Today
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
|
|
@ -1458,6 +1898,7 @@ export function Inbox() {
|
|||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
const isArchiving = archivingIssueIds.has(issue.id);
|
||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const row = (
|
||||
<IssueRow
|
||||
key={`issue:${issue.id}`}
|
||||
|
|
@ -1472,15 +1913,12 @@ export function Inbox() {
|
|||
desktopMetaLeading={
|
||||
<InboxIssueMetaLeading
|
||||
issue={issue}
|
||||
selected={isSelected}
|
||||
isLive={liveIssueIds.has(issue.id)}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
/>
|
||||
}
|
||||
mobileMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||
unreadState={
|
||||
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
||||
}
|
||||
|
|
@ -1491,10 +1929,22 @@ export function Inbox() {
|
|||
: undefined
|
||||
}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
trailingMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
<InboxIssueTrailingColumns
|
||||
issue={issue}
|
||||
columns={visibleTrailingIssueColumns}
|
||||
projectName={issueProject?.name ?? null}
|
||||
projectColor={issueProject?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
|
|
@ -51,6 +52,7 @@ export function InstanceGeneralSettings() {
|
|||
}
|
||||
|
||||
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
||||
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||
|
||||
return (
|
||||
|
|
@ -106,6 +108,36 @@ export function InstanceGeneralSettings() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
|
||||
toggling panels. This is off by default.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle keyboard shortcuts"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
keyboardShortcuts ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue