merge master into pap-1078-qol-fixes

Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 13:14:20 -05:00
commit fb3b57ab1f
59 changed files with 16794 additions and 375 deletions

View file

@ -2937,7 +2937,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
payload: resumePayload,
}, run.companyId);
if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable.");
throw new Error(result.message ?? "Resume request was skipped.");
}
return result;
},
@ -2969,7 +2969,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
payload: retryPayload,
}, run.companyId);
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 result;
},

View file

@ -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">) => (
@ -148,31 +148,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();

View file

@ -4,15 +4,25 @@ 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 { useGeneralSettings } from "../context/GeneralSettingsContext";
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 +31,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 +70,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 +140,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 +213,150 @@ export function InboxIssueMetaLeading({
);
}
function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(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.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 +406,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 +562,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 +701,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 +792,16 @@ export function Inbox() {
const location = useLocation();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { keyboardShortcutsEnabled } = useGeneralSettings();
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 +821,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 +853,7 @@ export function Inbox() {
useEffect(() => {
saveLastInboxTab(tab);
setSelectedIndex(-1);
setSearchQuery("");
}, [tab]);
const {
@ -731,6 +954,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 +1060,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 +1205,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 +1228,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 +1365,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 +1379,7 @@ export function Inbox() {
readItems,
});
kbStateRef.current = {
workItems: workItemsToRender,
workItems: filteredWorkItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
@ -1061,6 +1409,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 +1418,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 +1497,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 +1512,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 +1534,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 +1564,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 +1585,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 +1733,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 +1753,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,13 +1769,13 @@ 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">
<div className="flex-1 border-t border-zinc-600" />
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Earlier
</span>
</div>,
@ -1458,6 +1896,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 +1911,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 +1927,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
}
/>
);

View file

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

View file

@ -31,7 +31,8 @@ import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
import { Copy, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
import { IssuesQuicklook } from "../components/IssuesQuicklook";
/* ── Top-level tab types ── */
@ -256,152 +257,144 @@ function ProjectWorkspacesContent({
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
const visibleIssues = summary.issues.slice(0, 3);
const visibleIssues = summary.issues.slice(0, 5);
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
const workspaceHref =
summary.kind === "project_workspace"
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
: `/execution-workspaces/${summary.workspaceId}`;
const hasRunningServices = summary.runningServiceCount > 0;
const truncatePath = (path: string) => {
const parts = path.split("/").filter(Boolean);
if (parts.length <= 3) return path;
return `…/${parts.slice(-2).join("/")}`;
};
return (
<div
key={summary.key}
className="border-b border-border px-4 py-3 last:border-b-0"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
<div className="min-w-0">
<Link
to={workspaceHref}
className="block truncate text-sm font-medium hover:underline"
>
{summary.workspaceName}
</Link>
{/* Header row: name + actions */}
<div className="flex items-center gap-3">
<Link
to={workspaceHref}
className="min-w-0 shrink truncate text-sm font-medium hover:underline"
>
{summary.workspaceName}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{summary.serviceCount > 0 ? (
<span className={`inline-flex items-center gap-1 ${hasRunningServices ? "text-emerald-500" : ""}`}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${hasRunningServices ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
{summary.runningServiceCount}/{summary.serviceCount}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.runningServiceCount}/{summary.serviceCount} services running
</span>
{summary.executionWorkspaceStatus ? (
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.executionWorkspaceStatus}
</span>
) : null}
</div>
{summary.primaryServiceUrl ? (
<a
href={summary.primaryServiceUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
>
{summary.primaryServiceUrl}
</a>
) : null}
{summary.cwd ? (
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate font-mono leading-tight" title={summary.cwd}>
{summary.cwd}
</span>
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
</div>
{summary.executionWorkspaceStatus && summary.executionWorkspaceStatus !== "active" ? (
<span className="text-[11px] text-muted-foreground">{summary.executionWorkspaceStatus}</span>
) : null}
</div>
<div className="min-w-0">
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Issues ({summary.issues.length})
</div>
<div className="flex flex-wrap gap-2">
{visibleIssues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-left text-xs leading-none transition-colors hover:bg-accent"
>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate leading-tight">{issue.title}</span>
</Link>
))}
{hiddenIssueCount > 0 ? (
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
... and {hiddenIssueCount} more
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col items-start gap-2 md:items-end">
<Link
to={workspaceHref}
className="text-xs font-medium text-foreground hover:underline"
>
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
</Link>
<div className="flex flex-wrap gap-2">
<div className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">{timeAgo(summary.lastUpdatedAt)}</span>
{summary.hasRuntimeConfig ? (
<Button
variant="outline"
variant="ghost"
size="sm"
disabled={
controlWorkspaceRuntime.isPending
|| !summary.hasRuntimeConfig
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
}
className="h-7 gap-1.5 px-2 text-xs"
disabled={controlWorkspaceRuntime.isPending}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "start",
action: hasRunningServices ? "stop" : "start",
})
}
>
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
{runtimeActionKey === `${summary.key}:start` || runtimeActionKey === `${summary.key}:stop` ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : hasRunningServices ? (
<Square className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
{hasRunningServices ? "Stop" : "Start"}
</Button>
<Button
variant="outline"
size="sm"
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "stop",
})
}
>
Stop
</Button>
</div>
) : null}
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setClosingWorkspace({
id: summary.executionWorkspaceId!,
name: summary.workspaceName,
status: summary.executionWorkspaceStatus!,
})}
>
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close"}
</Button>
) : null}
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
{timeAgo(summary.lastUpdatedAt)}
</div>
</div>
</div>
{/* Metadata lines: branch, folder */}
<div className="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
{summary.branchName ? (
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="font-mono">{summary.branchName}</span>
</div>
) : null}
{summary.cwd ? (
<div className="flex items-center gap-1.5">
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate font-mono" title={summary.cwd}>
{truncatePath(summary.cwd)}
</span>
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
<Copy className="h-3 w-3" />
</CopyText>
</div>
) : null}
{summary.primaryServiceUrl ? (
<div className="flex items-center gap-1.5">
<a
href={summary.primaryServiceUrl}
target="_blank"
rel="noreferrer"
className="font-mono hover:text-foreground hover:underline"
>
{summary.primaryServiceUrl}
</a>
</div>
) : null}
</div>
{/* Issues */}
{summary.issues.length > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium text-muted-foreground/70">Issues</span>
{visibleIssues.map((issue) => (
<IssuesQuicklook key={issue.id} issue={issue}>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="font-mono hover:text-foreground hover:underline"
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
</IssuesQuicklook>
))}
{hiddenIssueCount > 0 ? (
<Link to={workspaceHref} className="hover:text-foreground hover:underline">
+{hiddenIssueCount} more
</Link>
) : null}
</div>
) : null}
</div>
);
};
@ -488,6 +481,7 @@ export function ProjectDetail() {
const experimentalSettingsQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const {
slots: pluginDetailSlots,

View file

@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { LiveRunWidget } from "../components/LiveRunWidget";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@ -31,6 +32,12 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
import { RunButton } from "../components/AgentActionButtons";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
@ -48,7 +55,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import type { RoutineTrigger } from "@paperclipai/shared";
import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
@ -216,7 +223,7 @@ function TriggerEditor({
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
>
<Save className="mr-1.5 h-3.5 w-3.5" />
Save
Save trigger
</Button>
<Button
variant="ghost"
@ -247,13 +254,23 @@ export function RoutineDetail() {
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
const [newTrigger, setNewTrigger] = useState({
kind: "schedule",
cronExpression: "0 10 * * *",
signingMode: "bearer",
replayWindowSec: "300",
});
const [editDraft, setEditDraft] = useState({
const [editDraft, setEditDraft] = useState<{
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}>({
title: "",
description: "",
projectId: "",
@ -261,6 +278,7 @@ export function RoutineDetail() {
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
@ -309,6 +327,11 @@ export function RoutineDetail() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const routineDefaults = useMemo(
() =>
@ -321,6 +344,7 @@ export function RoutineDetail() {
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: routine.variables,
}
: null,
[routine],
@ -334,7 +358,8 @@ export function RoutineDetail() {
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
editDraft.priority !== routineDefaults.priority ||
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy ||
JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)
);
}, [editDraft, routineDefaults]);
@ -409,9 +434,20 @@ export function RoutineDetail() {
});
const runRoutine = useMutation({
mutationFn: () => routinesApi.run(routineId!),
mutationFn: (data?: RoutineRunDialogSubmitData) =>
routinesApi.run(routineId!, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
: {}),
...(data?.executionWorkspaceSettings !== undefined
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
: {}),
}),
onSuccess: async () => {
pushToast({ title: "Routine run started", tone: "success" });
setRunVariablesOpen(false);
setActiveTab("runs");
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
@ -476,6 +512,12 @@ export function RoutineDetail() {
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
});
} else {
pushToast({
title: "Trigger added",
body: "The routine schedule was saved.",
tone: "success",
});
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
@ -495,6 +537,11 @@ export function RoutineDetail() {
const updateTrigger = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
onSuccess: async () => {
pushToast({
title: "Trigger saved",
body: "The routine cadence update was saved.",
tone: "success",
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
@ -513,6 +560,10 @@ export function RoutineDetail() {
const deleteTrigger = useMutation({
mutationFn: (id: string) => routinesApi.deleteTrigger(id),
onSuccess: async () => {
pushToast({
title: "Trigger deleted",
tone: "success",
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
@ -600,6 +651,12 @@ export function RoutineDetail() {
}
const automationEnabled = routine.status === "active";
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null;
const needsRunConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project: selectedProject,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused";
const automationLabelClassName = routine.status === "archived"
@ -643,7 +700,16 @@ export function RoutineDetail() {
}}
/>
<div className="flex shrink-0 items-center gap-3 pt-1">
<RunButton onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending} />
<RunButton
onClick={() => {
if (needsRunConfiguration) {
setRunVariablesOpen(true);
return;
}
runRoutine.mutate({});
}}
disabled={runRoutine.isPending}
/>
<button
type="button"
role="switch"
@ -797,6 +863,12 @@ export function RoutineDetail() {
}
}}
/>
<RoutineVariablesHint />
<RoutineVariablesEditor
description={editDraft.description}
value={editDraft.variables}
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
/>
{/* Advanced delivery settings */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
@ -1016,6 +1088,16 @@ export function RoutineDetail() {
)}
</TabsContent>
</Tabs>
<RoutineRunVariablesDialog
open={runVariablesOpen}
onOpenChange={setRunVariablesOpen}
companyId={routine.companyId}
project={selectedProject}
variables={routine.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => runRoutine.mutate(data)}
/>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@ -15,6 +16,12 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -33,6 +40,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
@ -74,9 +82,19 @@ export function Routines() {
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState({
const [draft, setDraft] = useState<{
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}>({
title: "",
description: "",
projectId: "",
@ -84,6 +102,7 @@ export function Routines() {
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
useEffect(() => {
@ -105,6 +124,11 @@ export function Routines() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
@ -125,6 +149,7 @@ export function Routines() {
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
});
setComposerOpen(false);
setAdvancedOpen(false);
@ -162,11 +187,21 @@ export function Routines() {
});
const runRoutine = useMutation({
mutationFn: (id: string) => routinesApi.run(id),
onMutate: (id) => {
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
: {}),
...(data?.executionWorkspaceSettings !== undefined
? { executionWorkspaceSettings: data.executionWorkspaceSettings }
: {}),
}),
onMutate: ({ id }) => {
setRunningRoutineId(id);
},
onSuccess: async (_, id) => {
onSuccess: async (_, { id }) => {
setRunDialogRoutine(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
@ -214,9 +249,24 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const needsConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
if (needsConfiguration) {
setRunDialogRoutine(routine);
return;
}
runRoutine.mutate({ id: routine.id, data: {} });
}
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
@ -414,6 +464,14 @@ export function Routines() {
}
}}
/>
<div className="mt-3 space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
description={draft.description}
value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
/>
</div>
</div>
<div className="border-t border-border/60 px-5 py-3">
@ -623,7 +681,7 @@ export function Routines() {
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => runRoutine.mutate(routine.id)}
onClick={() => handleRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
@ -661,6 +719,21 @@ export function Routines() {
</div>
)}
</div>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
if (!next) setRunDialogRoutine(null);
}}
companyId={selectedCompanyId}
project={runDialogProject}
variables={runDialogRoutine?.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => {
if (!runDialogRoutine) return;
runRoutine.mutate({ id: runDialogRoutine.id, data });
}}
/>
</div>
);
}