mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
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:
commit
fb3b57ab1f
59 changed files with 16794 additions and 375 deletions
|
|
@ -8,6 +8,7 @@ import type {
|
|||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupResponse,
|
||||
HeartbeatRun,
|
||||
Approval,
|
||||
AgentConfigRevision,
|
||||
|
|
@ -189,7 +190,7 @@ export const agentsApi = {
|
|||
idempotencyKey?: string | null;
|
||||
},
|
||||
companyId?: string,
|
||||
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
||||
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||
availableSkills: () =>
|
||||
|
|
|
|||
|
|
@ -113,4 +113,27 @@ describe("IssueRow", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the issue detail breadcrumb source and href in the link target", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue();
|
||||
const state = {
|
||||
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
|
||||
issueDetailSource: "inbox",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={issue} issueLinkState={state} />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
|
||||
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
|||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
||||
function shouldPresentExistingWorkspaceSelection(issue: {
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspacePreference: string | null;
|
||||
executionWorkspaceSettings: Issue["executionWorkspaceSettings"];
|
||||
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||
}) {
|
||||
const persistedMode =
|
||||
issue.currentExecutionWorkspace?.mode
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
|
|
@ -156,19 +161,44 @@ function statusBadge(status: string) {
|
|||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface IssueWorkspaceCardProps {
|
||||
issue: Issue;
|
||||
issue: Omit<
|
||||
Pick<
|
||||
Issue,
|
||||
| "companyId"
|
||||
| "projectId"
|
||||
| "projectWorkspaceId"
|
||||
| "executionWorkspaceId"
|
||||
| "executionWorkspacePreference"
|
||||
| "executionWorkspaceSettings"
|
||||
>,
|
||||
"companyId"
|
||||
> & {
|
||||
companyId: string | null;
|
||||
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||
};
|
||||
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
initialEditing?: boolean;
|
||||
livePreview?: boolean;
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||
}
|
||||
|
||||
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
|
||||
export function IssueWorkspaceCard({
|
||||
issue,
|
||||
project,
|
||||
onUpdate,
|
||||
initialEditing = false,
|
||||
livePreview = false,
|
||||
onDraftChange,
|
||||
}: IssueWorkspaceCardProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editing, setEditing] = useState(initialEditing);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||
|
|
@ -209,13 +239,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
?? workspace
|
||||
?? null;
|
||||
|
||||
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
const configuredSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(project)
|
||||
);
|
||||
const currentSelection = configuredSelection === "operator_branch" || configuredSelection === "agent_default"
|
||||
? "shared_workspace"
|
||||
: configuredSelection;
|
||||
|
||||
const [draftSelection, setDraftSelection] = useState(currentSelection);
|
||||
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
|
||||
|
|
@ -245,24 +278,33 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
|
||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSaveWorkspaceConfig) return;
|
||||
onUpdate({
|
||||
executionWorkspacePreference: draftSelection,
|
||||
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode:
|
||||
draftSelection === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
|
||||
: draftSelection,
|
||||
},
|
||||
});
|
||||
setEditing(false);
|
||||
}, [
|
||||
canSaveWorkspaceConfig,
|
||||
const buildWorkspaceDraftUpdate = useCallback(() => ({
|
||||
executionWorkspacePreference: draftSelection,
|
||||
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode:
|
||||
draftSelection === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
|
||||
: draftSelection,
|
||||
},
|
||||
}), [
|
||||
configuredReusableWorkspace?.mode,
|
||||
draftExecutionWorkspaceId,
|
||||
draftSelection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onDraftChange) return;
|
||||
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig });
|
||||
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSaveWorkspaceConfig) return;
|
||||
onUpdate(buildWorkspaceDraftUpdate());
|
||||
setEditing(false);
|
||||
}, [
|
||||
buildWorkspaceDraftUpdate,
|
||||
canSaveWorkspaceConfig,
|
||||
onUpdate,
|
||||
]);
|
||||
|
||||
|
|
@ -274,6 +316,8 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
|
||||
if (!policyEnabled || !project) return null;
|
||||
|
||||
const showEditingControls = livePreview || editing;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
{/* Header row */}
|
||||
|
|
@ -286,7 +330,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{editing ? (
|
||||
{!livePreview && editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -305,7 +349,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
Save
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
) : !livePreview ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -314,12 +358,12 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />Edit
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only info */}
|
||||
{!editing && (
|
||||
{!showEditingControls && (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{workspace?.branchName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -377,7 +421,7 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
|
|||
)}
|
||||
|
||||
{/* Editing controls */}
|
||||
{editing && (
|
||||
{showEditingControls && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
|
|
|
|||
55
ui/src/components/IssuesQuicklook.tsx
Normal file
55
ui/src/components/IssuesQuicklook.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
issue: Issue;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<Link
|
||||
to={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
|||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
|
@ -24,6 +25,7 @@ import { useTheme } from "../context/ThemeContext";
|
|||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||
import {
|
||||
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||
|
|
@ -85,6 +87,10 @@ export function Layout() {
|
|||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const keyboardShortcutsEnabled = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
}).data?.keyboardShortcuts === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (companiesLoading || onboardingTriggered.current) return;
|
||||
|
|
@ -141,6 +147,7 @@ export function Layout() {
|
|||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: keyboardShortcutsEnabled,
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
|
|
@ -259,12 +266,13 @@ export function Layout() {
|
|||
}, [location.hash, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
|
@ -436,6 +444,7 @@ export function Layout() {
|
|||
<NewGoalDialog />
|
||||
<NewAgentDialog />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
</div>
|
||||
</GeneralSettingsProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,6 +340,7 @@ export function NewIssueDialog() {
|
|||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: newIssueOpen,
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const activeProjects = useMemo(
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const linkedGoalIds = project.goalIds.length > 0
|
||||
|
|
|
|||
136
ui/src/components/RoutineRunVariablesDialog.test.tsx
Normal file
136
ui/src/components/RoutineRunVariablesDialog.test.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||
|
||||
let issueWorkspaceDraftCalls = 0;
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: {
|
||||
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./IssueWorkspaceCard", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
IssueWorkspaceCard: ({
|
||||
onDraftChange,
|
||||
}: {
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
issueWorkspaceDraftCalls += 1;
|
||||
if (issueWorkspaceDraftCalls > 20) {
|
||||
throw new Error("IssueWorkspaceCard onDraftChange looped");
|
||||
}
|
||||
onDraftChange?.({
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
}, { canSave: true });
|
||||
}, [onDraftChange]);
|
||||
|
||||
return <div data-testid="workspace-card">Workspace card</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createProject(): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "workspace-project",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Workspace project",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
archivedAt: null,
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: true,
|
||||
},
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/paperclip/project-1",
|
||||
effectiveLocalFolder: "/tmp/paperclip/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoutineRunVariablesDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
issueWorkspaceDraftCalls = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("does not loop when the workspace card reports the same draft repeatedly", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutineRunVariablesDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
companyId="company-1"
|
||||
project={createProject()}
|
||||
variables={[]}
|
||||
isPending={false}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||
expect(document.body.textContent).toContain("Run routine");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
323
ui/src/components/RoutineRunVariablesDialog.tsx
Normal file
323
ui/src/components/RoutineRunVariablesDialog.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function buildInitialValues(variables: RoutineVariable[]) {
|
||||
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
|
||||
}
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
|
||||
if (!project) return null;
|
||||
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
|
||||
?? project.workspaces?.[0]?.id
|
||||
?? null;
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForProject(project: Project | null | undefined) {
|
||||
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||
if (
|
||||
defaultMode === "isolated_workspace" ||
|
||||
defaultMode === "operator_branch" ||
|
||||
defaultMode === "adapter_default"
|
||||
) {
|
||||
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function buildInitialWorkspaceConfig(project: Project | null | undefined) {
|
||||
const defaultMode = defaultExecutionWorkspaceModeForProject(project);
|
||||
return {
|
||||
executionWorkspaceId: null as string | null,
|
||||
executionWorkspacePreference: defaultMode,
|
||||
executionWorkspaceSettings: { mode: defaultMode } as IssueExecutionWorkspaceSettings,
|
||||
projectWorkspaceId: defaultProjectWorkspaceIdForProject(project),
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceConfigEquals(
|
||||
a: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||
b: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||
) {
|
||||
return a.executionWorkspaceId === b.executionWorkspaceId
|
||||
&& a.executionWorkspacePreference === b.executionWorkspacePreference
|
||||
&& a.projectWorkspaceId === b.projectWorkspaceId
|
||||
&& JSON.stringify(a.executionWorkspaceSettings ?? null) === JSON.stringify(b.executionWorkspaceSettings ?? null);
|
||||
}
|
||||
|
||||
function applyWorkspaceDraft(
|
||||
current: ReturnType<typeof buildInitialWorkspaceConfig>,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const next = {
|
||||
...current,
|
||||
executionWorkspaceId: (data.executionWorkspaceId as string | null | undefined) ?? null,
|
||||
executionWorkspacePreference:
|
||||
(data.executionWorkspacePreference as string | null | undefined)
|
||||
?? current.executionWorkspacePreference,
|
||||
executionWorkspaceSettings:
|
||||
(data.executionWorkspaceSettings as IssueExecutionWorkspaceSettings | null | undefined)
|
||||
?? current.executionWorkspaceSettings,
|
||||
};
|
||||
return workspaceConfigEquals(current, next) ? current : next;
|
||||
}
|
||||
|
||||
function isMissingRequiredValue(value: unknown) {
|
||||
return value == null || (typeof value === "string" && value.trim().length === 0);
|
||||
}
|
||||
|
||||
function supportsRoutineRunWorkspaceSelection(
|
||||
project: Project | null | undefined,
|
||||
isolatedWorkspacesEnabled: boolean,
|
||||
) {
|
||||
return isolatedWorkspacesEnabled && Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||
}
|
||||
|
||||
export function routineRunNeedsConfiguration(input: {
|
||||
variables: RoutineVariable[];
|
||||
project: Project | null | undefined;
|
||||
isolatedWorkspacesEnabled: boolean;
|
||||
}) {
|
||||
return input.variables.length > 0
|
||||
|| supportsRoutineRunWorkspaceSelection(input.project, input.isolatedWorkspacesEnabled);
|
||||
}
|
||||
|
||||
export interface RoutineRunDialogSubmitData {
|
||||
variables?: Record<string, string | number | boolean>;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||
}
|
||||
|
||||
export function RoutineRunVariablesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
companyId,
|
||||
project,
|
||||
variables,
|
||||
isPending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companyId: string | null | undefined;
|
||||
project: Project | null | undefined;
|
||||
variables: RoutineVariable[];
|
||||
isPending: boolean;
|
||||
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project));
|
||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
|
||||
project,
|
||||
experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues(buildInitialValues(variables));
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||
setWorkspaceConfigValid(true);
|
||||
}, [open, project, variables]);
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() =>
|
||||
variables
|
||||
.filter((variable) => variable.required)
|
||||
.filter((variable) => isMissingRequiredValue(values[variable.name]))
|
||||
.map((variable) => variable.label || variable.name),
|
||||
[values, variables],
|
||||
);
|
||||
|
||||
const workspaceIssue = useMemo(() => ({
|
||||
companyId: companyId ?? null,
|
||||
projectId: project?.id ?? null,
|
||||
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
|
||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
||||
currentExecutionWorkspace: null,
|
||||
}), [
|
||||
companyId,
|
||||
project?.id,
|
||||
workspaceConfig.executionWorkspaceId,
|
||||
workspaceConfig.executionWorkspacePreference,
|
||||
workspaceConfig.executionWorkspaceSettings,
|
||||
workspaceConfig.projectWorkspaceId,
|
||||
]);
|
||||
|
||||
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid);
|
||||
|
||||
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceDraftChange = useCallback((
|
||||
data: Record<string, unknown>,
|
||||
meta: { canSave: boolean },
|
||||
) => {
|
||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run routine</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the routine variables before starting the execution issue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.name} className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
{variable.label || variable.name}
|
||||
{variable.required ? " *" : ""}
|
||||
</Label>
|
||||
{variable.type === "textarea" ? (
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
|
||||
/>
|
||||
) : variable.type === "boolean" ? (
|
||||
<Select
|
||||
value={values[variable.name] === true ? "true" : values[variable.name] === false ? "false" : "__unset__"}
|
||||
onValueChange={(next) => setValues((current) => ({
|
||||
...current,
|
||||
[variable.name]: next === "__unset__" ? "" : next === "true",
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No value</SelectItem>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : variable.type === "select" ? (
|
||||
<Select
|
||||
value={typeof values[variable.name] === "string" && values[variable.name] ? values[variable.name] as string : "__unset__"}
|
||||
onValueChange={(next) => setValues((current) => ({
|
||||
...current,
|
||||
[variable.name]: next === "__unset__" ? "" : next,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No value</SelectItem>
|
||||
{variable.options.map((option) => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={variable.type === "number" ? "number" : "text"}
|
||||
value={values[variable.name] == null ? "" : String(values[variable.name])}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{workspaceSelectionEnabled && project && companyId ? (
|
||||
<IssueWorkspaceCard
|
||||
key={`${open ? "open" : "closed"}:${project.id}`}
|
||||
issue={workspaceIssue}
|
||||
project={project}
|
||||
initialEditing
|
||||
livePreview
|
||||
onUpdate={handleWorkspaceUpdate}
|
||||
onDraftChange={handleWorkspaceDraftChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter showCloseButton={false}>
|
||||
{missingRequired.length > 0 ? (
|
||||
<p className="mr-auto text-xs text-amber-600">
|
||||
Missing: {missingRequired.join(", ")}
|
||||
</p>
|
||||
) : workspaceSelectionEnabled && !workspaceConfigValid ? (
|
||||
<p className="mr-auto text-xs text-amber-600">
|
||||
Choose an existing workspace before running.
|
||||
</p>
|
||||
) : (
|
||||
<span className="mr-auto" />
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const nextVariables: Record<string, string | number | boolean> = {};
|
||||
for (const variable of variables) {
|
||||
const rawValue = values[variable.name];
|
||||
if (isMissingRequiredValue(rawValue)) continue;
|
||||
if (variable.type === "number") {
|
||||
nextVariables[variable.name] = Number(rawValue);
|
||||
} else if (variable.type === "boolean") {
|
||||
nextVariables[variable.name] = rawValue === true;
|
||||
} else {
|
||||
nextVariables[variable.name] = String(rawValue);
|
||||
}
|
||||
}
|
||||
onSubmit({
|
||||
variables: nextVariables,
|
||||
...(workspaceSelectionEnabled
|
||||
? {
|
||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: workspaceConfig.executionWorkspaceSettings,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
disabled={isPending || !canSubmit}
|
||||
>
|
||||
{isPending ? "Running..." : "Run routine"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
232
ui/src/components/RoutineVariablesEditor.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"];
|
||||
|
||||
function serializeVariables(value: RoutineVariable[]) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseSelectOptions(value: string) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function updateVariableList(
|
||||
variables: RoutineVariable[],
|
||||
name: string,
|
||||
mutate: (variable: RoutineVariable) => RoutineVariable,
|
||||
) {
|
||||
return variables.map((variable) => (variable.name === name ? mutate(variable) : variable));
|
||||
}
|
||||
|
||||
export function RoutineVariablesEditor({
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
description: string;
|
||||
value: RoutineVariable[];
|
||||
onChange: (value: RoutineVariable[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const syncedVariables = useMemo(
|
||||
() => syncRoutineVariablesWithTemplate(description, value),
|
||||
[description, value],
|
||||
);
|
||||
const syncedSignature = serializeVariables(syncedVariables);
|
||||
const currentSignature = serializeVariables(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedSignature !== currentSignature) {
|
||||
onChange(syncedVariables);
|
||||
}
|
||||
}, [currentSignature, onChange, syncedSignature, syncedVariables]);
|
||||
|
||||
if (syncedVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
||||
</p>
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{syncedVariables.map((variable) => (
|
||||
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{`{{${variable.name}}}`}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Prompt the user for this value before each manual run.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={variable.label ?? ""}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
label: event.target.value || null,
|
||||
})))}
|
||||
placeholder={variable.name.replaceAll("_", " ")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={variable.type}
|
||||
onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
type: type as RoutineVariable["type"],
|
||||
defaultValue: type === "boolean" ? null : current.defaultValue,
|
||||
options: type === "select" ? current.options : [],
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{variableTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-xs">Default value</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={variable.required}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
required: event.target.checked,
|
||||
})))}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{variable.type === "textarea" ? (
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: event.target.value || null,
|
||||
})))}
|
||||
/>
|
||||
) : variable.type === "boolean" ? (
|
||||
<Select
|
||||
value={variable.defaultValue === true ? "true" : variable.defaultValue === false ? "false" : "__unset__"}
|
||||
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: next === "__unset__" ? null : next === "true",
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No default</SelectItem>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : variable.type === "select" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Options</Label>
|
||||
<Input
|
||||
value={variable.options.join(", ")}
|
||||
onChange={(event) => {
|
||||
const options = parseSelectOptions(event.target.value);
|
||||
onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
options,
|
||||
defaultValue:
|
||||
typeof current.defaultValue === "string" && options.includes(current.defaultValue)
|
||||
? current.defaultValue
|
||||
: null,
|
||||
})));
|
||||
}}
|
||||
placeholder="high, medium, low"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Default option</Label>
|
||||
<Select
|
||||
value={typeof variable.defaultValue === "string" ? variable.defaultValue : "__unset__"}
|
||||
onValueChange={(next) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: next === "__unset__" ? null : next,
|
||||
})))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unset__">No default</SelectItem>
|
||||
{variable.options.map((option) => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={variable.type === "number" ? "number" : "text"}
|
||||
value={variable.defaultValue == null ? "" : String(variable.defaultValue)}
|
||||
onChange={(event) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
|
||||
...current,
|
||||
defaultValue: event.target.value || null,
|
||||
})))}
|
||||
placeholder={variable.type === "number" ? "42" : "Default value"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutineVariablesHint() {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
ui/src/context/GeneralSettingsContext.tsx
Normal file
28
ui/src/context/GeneralSettingsContext.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface GeneralSettingsContextValue {
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}
|
||||
|
||||
const GeneralSettingsContext = createContext<GeneralSettingsContextValue>({
|
||||
keyboardShortcutsEnabled: false,
|
||||
});
|
||||
|
||||
export function GeneralSettingsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: GeneralSettingsContextValue;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<GeneralSettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</GeneralSettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGeneralSettings() {
|
||||
return useContext(GeneralSettingsContext);
|
||||
}
|
||||
|
|
@ -1,17 +1,25 @@
|
|||
import { useEffect } from "react";
|
||||
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
|
||||
interface ShortcutHandlers {
|
||||
enabled?: boolean;
|
||||
onNewIssue?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onTogglePanel?: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
||||
export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
onNewIssue,
|
||||
onToggleSidebar,
|
||||
onTogglePanel,
|
||||
}: ShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't fire shortcuts when typing in inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
|||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,13 +193,24 @@
|
|||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
}
|
||||
/* Light mode scrollbar on hover */
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
background: oklch(0.92 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
background: oklch(0.7 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0 0) !important;
|
||||
}
|
||||
/* Dark mode scrollbar on hover */
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
}
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
}
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0 0) !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import type {
|
||||
Approval,
|
||||
DashboardSummary,
|
||||
ExecutionWorkspace,
|
||||
HeartbeatRun,
|
||||
Issue,
|
||||
JoinRequest,
|
||||
ProjectWorkspace,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
computeInboxBadgeData,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadLastInboxTab,
|
||||
normalizeInboxIssueColumns,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
} from "./inbox";
|
||||
|
|
@ -170,6 +184,63 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
|||
};
|
||||
}
|
||||
|
||||
function makeProjectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
|
||||
return {
|
||||
id: "project-workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||
return {
|
||||
id: "execution-workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
sourceIssueId: "issue-1",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-1 branch",
|
||||
status: "active",
|
||||
cwd: "/tmp/project/worktree",
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "pap-1",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
openedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const dashboard: DashboardSummary = {
|
||||
companyId: "company-1",
|
||||
agents: {
|
||||
|
|
@ -314,6 +385,16 @@ describe("inbox helpers", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("sorts touched issues by latest external comment timestamp", () => {
|
||||
const newerIssue = makeIssue("1", true);
|
||||
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z");
|
||||
|
||||
const olderIssue = makeIssue("2", true);
|
||||
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
|
||||
it("mixes join requests into the inbox feed by most recent activity", () => {
|
||||
const issue = makeIssue("1", true);
|
||||
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
|
@ -419,6 +500,116 @@ describe("inbox helpers", () => {
|
|||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("defaults issue columns to the current inbox layout", () => {
|
||||
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
||||
});
|
||||
|
||||
it("normalizes saved issue columns to valid values in canonical order", () => {
|
||||
saveInboxIssueColumns(["labels", "updated", "status", "workspace", "labels", "assignee"]);
|
||||
|
||||
expect(loadInboxIssueColumns()).toEqual(["status", "assignee", "workspace", "labels", "updated"]);
|
||||
expect(normalizeInboxIssueColumns(["project", "workspace", "wat", "id"])).toEqual(["id", "project", "workspace"]);
|
||||
});
|
||||
|
||||
it("hides the workspace column option unless isolated workspaces are enabled", () => {
|
||||
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
|
||||
expect(getAvailableInboxIssueColumns(true)).toEqual([
|
||||
"status",
|
||||
"id",
|
||||
"assignee",
|
||||
"project",
|
||||
"workspace",
|
||||
"labels",
|
||||
"updated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows hiding every optional issue column down to the title-only view", () => {
|
||||
saveInboxIssueColumns([]);
|
||||
expect(loadInboxIssueColumns()).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows explicit workspace names but leaves the default workspace blank", () => {
|
||||
const issue = makeIssue("1", true);
|
||||
issue.projectId = "project-1";
|
||||
issue.projectWorkspaceId = "project-workspace-1";
|
||||
issue.executionWorkspaceId = "execution-workspace-1";
|
||||
|
||||
const executionWorkspace = makeExecutionWorkspace();
|
||||
const defaultWorkspace = makeProjectWorkspace();
|
||||
const secondaryWorkspace = makeProjectWorkspace({
|
||||
id: "project-workspace-2",
|
||||
name: "Secondary workspace",
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById: new Map([[executionWorkspace.id, executionWorkspace]]),
|
||||
projectWorkspaceById: new Map([
|
||||
[defaultWorkspace.id, defaultWorkspace],
|
||||
[secondaryWorkspace.id, secondaryWorkspace],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||
}),
|
||||
).toBe("PAP-1 branch");
|
||||
|
||||
issue.executionWorkspaceId = null;
|
||||
expect(
|
||||
resolveIssueWorkspaceName(issue, {
|
||||
projectWorkspaceById: new Map([
|
||||
[defaultWorkspace.id, defaultWorkspace],
|
||||
[secondaryWorkspace.id, secondaryWorkspace],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
issue.projectWorkspaceId = secondaryWorkspace.id;
|
||||
expect(
|
||||
resolveIssueWorkspaceName(issue, {
|
||||
projectWorkspaceById: new Map([
|
||||
[defaultWorkspace.id, defaultWorkspace],
|
||||
[secondaryWorkspace.id, secondaryWorkspace],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||
}),
|
||||
).toBe("Secondary workspace");
|
||||
|
||||
issue.projectWorkspaceId = null;
|
||||
expect(
|
||||
resolveIssueWorkspaceName(issue, {
|
||||
projectWorkspaceById: new Map([
|
||||
[defaultWorkspace.id, defaultWorkspace],
|
||||
[secondaryWorkspace.id, secondaryWorkspace],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
issue.executionWorkspaceId = "execution-workspace-shared-default";
|
||||
issue.projectWorkspaceId = defaultWorkspace.id;
|
||||
expect(
|
||||
resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById: new Map([[
|
||||
issue.executionWorkspaceId,
|
||||
makeExecutionWorkspace({
|
||||
id: issue.executionWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
projectWorkspaceId: defaultWorkspace.id,
|
||||
name: "PAP-1067",
|
||||
}),
|
||||
]]),
|
||||
projectWorkspaceById: new Map([
|
||||
[defaultWorkspace.id, defaultWorkspace],
|
||||
[secondaryWorkspace.id, secondaryWorkspace],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("maps legacy new-tab storage to mine", () => {
|
||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import type {
|
||||
Approval,
|
||||
DashboardSummary,
|
||||
HeartbeatRun,
|
||||
Issue,
|
||||
JoinRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
|
||||
export const RECENT_ISSUES_LIMIT = 100;
|
||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
|
|
@ -12,8 +6,12 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques
|
|||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
|
||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||
export type InboxWorkItem =
|
||||
| {
|
||||
kind: "issue";
|
||||
|
|
@ -79,6 +77,80 @@ export function saveReadInboxItems(ids: Set<string>) {
|
|||
}
|
||||
}
|
||||
|
||||
export function normalizeInboxIssueColumns(columns: Iterable<string | InboxIssueColumn>): InboxIssueColumn[] {
|
||||
const selected = new Set(columns);
|
||||
return inboxIssueColumns.filter((column) => selected.has(column));
|
||||
}
|
||||
|
||||
export function getAvailableInboxIssueColumns(enableWorkspaceColumn: boolean): InboxIssueColumn[] {
|
||||
if (enableWorkspaceColumn) return [...inboxIssueColumns];
|
||||
return inboxIssueColumns.filter((column) => column !== "workspace");
|
||||
}
|
||||
|
||||
export function loadInboxIssueColumns(): InboxIssueColumn[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_ISSUE_COLUMNS_KEY);
|
||||
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
return normalizeInboxIssueColumns(parsed);
|
||||
} catch {
|
||||
return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
INBOX_ISSUE_COLUMNS_KEY,
|
||||
JSON.stringify(normalizeInboxIssueColumns(columns)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveIssueWorkspaceName(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
{
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}: {
|
||||
executionWorkspaceById?: ReadonlyMap<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>;
|
||||
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
},
|
||||
): string | null {
|
||||
const defaultProjectWorkspaceId = issue.projectId
|
||||
? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
|
||||
: null;
|
||||
|
||||
if (issue.executionWorkspaceId) {
|
||||
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
|
||||
const linkedProjectWorkspaceId =
|
||||
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
|
||||
const isDefaultSharedExecutionWorkspace =
|
||||
executionWorkspace?.mode === "shared_workspace" && linkedProjectWorkspaceId === defaultProjectWorkspaceId;
|
||||
if (isDefaultSharedExecutionWorkspace) return null;
|
||||
|
||||
const workspaceName = executionWorkspace?.name;
|
||||
if (workspaceName) return workspaceName;
|
||||
}
|
||||
|
||||
if (issue.projectWorkspaceId) {
|
||||
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
|
||||
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name;
|
||||
if (workspaceName) return workspaceName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ describe("keyboardShortcuts helpers", () => {
|
|||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
|
|
|
|||
|
|
@ -78,35 +78,37 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
})) continue;
|
||||
|
||||
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
const nextIssues = existing?.issues ?? [];
|
||||
nextIssues.push(issue);
|
||||
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
existing?.lastUpdatedAt,
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
if (!existing) {
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
} else {
|
||||
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -115,27 +117,30 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
if (!projectWorkspace) continue;
|
||||
|
||||
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
const nextIssues = existing?.issues ?? [];
|
||||
nextIssues.push(issue);
|
||||
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
if (!existing) {
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
} else {
|
||||
existing.lastUpdatedAt = maxDate(existing.lastUpdatedAt, issue.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
for (const projectWorkspace of input.project.workspaces) {
|
||||
|
|
@ -165,8 +170,17 @@ export function buildProjectWorkspaceSummaries(input: {
|
|||
});
|
||||
}
|
||||
|
||||
return [...summaries.values()].sort((a, b) => {
|
||||
const result = [...summaries.values()];
|
||||
// Sort issues within each summary once (instead of on every insertion)
|
||||
const issueTime = (issue: Issue) => new Date(issue.updatedAt).getTime();
|
||||
for (const summary of result) {
|
||||
if (summary.issues.length > 1) {
|
||||
summary.issues.sort((a, b) => issueTime(b) - issueTime(a));
|
||||
}
|
||||
}
|
||||
result.sort((a, b) => {
|
||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue