Polish operator UI task controls (#5427)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Operators spend most of their day scanning skills, routines, inbox
groups, and activity cards
> - Several small UI rough edges made those surfaces harder to scan or
easier to crash on real API payloads
> - These fixes are grouped together because they are low-risk operator
quality-of-life improvements rather than separate control-plane
contracts
> - This pull request polishes skills metadata, routine run-now access,
grouped issue creation defaults, monitor activity rendering, and
activity row identity layout
> - The benefit is a smoother board workflow with fewer small
interruptions while keeping the change set compact

## What Changed

- Improves company skill source display and the used-by agent list.
- Truncates long skill source paths and adds a copy affordance.
- Adds a row-level run-now button to the routines table.
- Adds grouped issue creation defaults for inbox issue groups and aligns
grouped add buttons to the right.
- Fixes `IssueMonitorActivityCard` when `monitorNextCheckAt` arrives as
an ISO string.
- Polishes activity row actor avatar/name layout by using the shared
avatar primitive.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
ui/src/pages/Routines.test.tsx ui/src/components/IssuesList.test.tsx
ui/src/lib/inbox.test.ts
ui/src/components/IssueMonitorActivityCard.test.tsx` — 91 passed.
- The routines test emitted the pre-existing Radix warning about missing
`DialogTitle`/description in dialog content; tests still passed.
- Pairwise merge checks against the other two PR branches reported no
textual conflicts.

## Risks

- Low: changes are UI-focused and covered by targeted component/lib
tests.
- Low-to-medium: activity row layout changes could affect dense feed
scanability; the implementation uses the shared avatar component and
keeps truncation behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent, GPT-5 model family (`gpt-5`), tool-enabled
Paperclip heartbeat environment. Context window and internal reasoning
mode are not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-07 12:24:02 -05:00
committed by GitHub
parent 772fc92619
commit 6f30003421
12 changed files with 431 additions and 47 deletions
+16 -14
View File
@@ -1,5 +1,6 @@
import { Link } from "@/lib/router";
import { Identity } from "./Identity";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { deriveInitials } from "./Identity";
import { IssueReferenceActivitySummary } from "./IssueReferenceActivitySummary";
import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils";
@@ -52,19 +53,20 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
const inner = (
<div className="space-y-2">
<div className="flex gap-3">
<p className="flex-1 min-w-0 truncate">
<Identity
name={actorName}
avatarUrl={actorAvatarUrl}
size="xs"
className="align-middle"
/>
<span className="text-muted-foreground ml-1">{verb} </span>
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground ml-1"> {entityTitle}</span>}
</p>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Avatar size="xs">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<p className="min-w-0 flex-1 truncate">
<span>{actorName}</span>
<span className="text-muted-foreground"> {verb} </span>
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> {entityTitle}</span>}
</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(event.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={event} />
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ export interface IdentityProps {
className?: string;
}
function deriveInitials(name: string): string {
export function deriveInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
@@ -158,6 +158,44 @@ describe("IssueMonitorActivityCard", () => {
act(() => root.unmount());
});
it("renders without throwing when monitorNextCheckAt arrives as an ISO string", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueMonitorActivityCard
issue={createIssue({
monitorNextCheckAt: "2026-04-11T12:30:00.000Z" as unknown as Date,
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
},
})}
/>,
);
});
expect(container.textContent).toContain("Monitor scheduled");
expect(container.textContent).toContain("Next check");
expect(container.textContent).toContain("in 30m");
act(() => root.unmount());
});
it("renders nothing when the issue has no scheduled monitor", () => {
const root = createRoot(container);
@@ -5,7 +5,7 @@ import { formatDateTime } from "@/lib/utils";
function resolveScheduledMonitor(issue: Issue) {
const nextCheckAt =
issue.monitorNextCheckAt?.toISOString() ??
issue.monitorNextCheckAt ??
issue.executionPolicy?.monitor?.nextCheckAt ??
issue.executionState?.monitor?.nextCheckAt ??
null;
+65 -1
View File
@@ -4,7 +4,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import type { Issue, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssuesList } from "./IssuesList";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -400,6 +400,70 @@ describe("IssuesList", () => {
});
});
it("uses workspace group defaults when creating an issue from a grouped section", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ groupBy: "workspace", sortField: "updated", sortDir: "desc" }),
);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "execution-workspace-1",
name: "Feature Branch",
mode: "isolated_workspace",
projectWorkspaceId: "project-workspace-1",
},
]);
const issue = createIssue({
id: "issue-workspace",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
executionWorkspaceId: "execution-workspace-1",
});
const project = {
id: "project-1",
name: "Paperclip App",
color: null,
workspaces: [{ id: "project-workspace-1", name: "Primary workspace" }],
primaryWorkspace: { id: "project-workspace-1" },
executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" },
} as Project;
const { root } = renderWithQueryClient(
<IssuesList
issues={[issue]}
agents={[]}
projects={[project]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const button = container.querySelector<HTMLButtonElement>('button[aria-label="New issue in Feature Branch"]');
expect(button).not.toBeNull();
});
await act(async () => {
const button = container.querySelector<HTMLButtonElement>('button[aria-label="New issue in Feature Branch"]');
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
executionWorkspaceId: "execution-workspace-1",
executionWorkspaceMode: "reuse_existing",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
});
act(() => {
root.unmount();
});
});
it("renders the opt-in sub-issue progress summary with workflow next-up linking", async () => {
const doneIssue = createIssue({
id: "issue-done",
+46 -9
View File
@@ -740,10 +740,10 @@ export function IssuesList({
}, [projects]);
const projectWorkspaceById = useMemo(() => {
const map = new Map<string, { name: string }>();
const map = new Map<string, { name: string; projectId: string }>();
for (const project of projects ?? []) {
for (const workspace of project.workspaces ?? []) {
map.set(workspace.id, { name: workspace.name || project.name });
map.set(workspace.id, { name: workspace.name || project.name, projectId: project.id });
}
}
return map;
@@ -770,16 +770,21 @@ export function IssuesList({
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId: string | null;
}>();
for (const workspace of executionWorkspaces) {
const projectWorkspace = workspace.projectWorkspaceId
? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null
: null;
map.set(workspace.id, {
name: workspace.name,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
projectId: projectWorkspace?.projectId ?? null,
});
}
return map;
}, [executionWorkspaces]);
}, [executionWorkspaces, projectWorkspaceById]);
const issueFilterWorkspaceContext = useMemo(() => ({
executionWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
@@ -1179,7 +1184,8 @@ export function IssuesList({
};
}, [canLoadMoreIssues, hasMoreIssues, hasMoreRenderedRows, loadMoreIssueRows]);
const newIssueDefaults = useCallback((groupKey?: string) => {
const newIssueDefaults = useCallback((group?: { key: string; items: Issue[] }) => {
const groupKey = group?.key;
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
@@ -1190,6 +1196,27 @@ export function IssuesList({
else defaults.assigneeAgentId = groupKey;
}
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") {
const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey) ?? null;
const executionWorkspace = executionWorkspaceById.get(groupKey);
if (executionWorkspace) {
defaults.executionWorkspaceId = groupKey;
defaults.executionWorkspaceMode = "reuse_existing";
if (executionWorkspace.projectWorkspaceId) defaults.projectWorkspaceId = executionWorkspace.projectWorkspaceId;
const groupedProjectId = executionWorkspace.projectId
?? (executionWorkspace.projectWorkspaceId
? projectWorkspaceById.get(executionWorkspace.projectWorkspaceId)?.projectId
: null)
?? (representativeIssue?.executionWorkspaceId === groupKey ? representativeIssue.projectId : null);
if (groupedProjectId) defaults.projectId = groupedProjectId;
} else {
const projectWorkspace = projectWorkspaceById.get(groupKey);
if (projectWorkspace) {
defaults.projectWorkspaceId = groupKey;
defaults.projectId = projectWorkspace.projectId;
}
}
}
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
const parentIssue = issueById.get(groupKey);
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
@@ -1197,12 +1224,20 @@ export function IssuesList({
}
}
return defaults;
}, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]);
}, [
baseCreateIssueDefaults,
currentUserId,
executionWorkspaceById,
issueById,
projectId,
projectWorkspaceById,
viewState.groupBy,
]);
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue";
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue";
const openCreateIssueDialog = useCallback((groupKey?: string) => {
openNewIssue(newIssueDefaults(groupKey));
const openCreateIssueDialog = useCallback((group?: { key: string; items: Issue[] }) => {
openNewIssue(newIssueDefaults(group));
}, [newIssueDefaults, openNewIssue]);
const filterToWorkspace = useCallback((workspaceId: string) => {
@@ -1453,8 +1488,10 @@ export function IssuesList({
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => openCreateIssueDialog(group.key)}
className="-mr-2 text-muted-foreground"
title={`New issue in ${group.label}`}
aria-label={`New issue in ${group.label}`}
onClick={() => openCreateIssueDialog(group)}
>
<Plus className="h-3 w-3" />
</Button>
+56
View File
@@ -13,6 +13,7 @@ import type {
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxIssueGroupCreateDefaults,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
@@ -1379,6 +1380,61 @@ describe("inbox helpers", () => {
]);
});
it("builds new issue defaults from inbox project, assignee, and workspace groups", () => {
const projectIssue = makeIssue("project", true);
projectIssue.projectId = "project-1";
const executionIssue = makeIssue("exec", false);
executionIssue.projectId = "project-1";
executionIssue.projectWorkspaceId = "project-workspace-1";
executionIssue.executionWorkspaceId = "execution-workspace-1";
const agentIssue = makeIssue("agent", false);
agentIssue.assigneeAgentId = "agent-1";
const options = {
executionWorkspaceById: new Map([
[
"execution-workspace-1",
{
name: "Feature Branch",
mode: "isolated_workspace" as const,
projectWorkspaceId: "project-workspace-1",
},
],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace", projectId: "project-1" }],
]),
};
expect(buildInboxIssueGroupCreateDefaults(
"project:project-1",
"project",
[{ kind: "issue", timestamp: 1, issue: projectIssue }],
options,
)).toEqual({ projectId: "project-1" });
expect(buildInboxIssueGroupCreateDefaults(
"workspace:execution:execution-workspace-1",
"workspace",
[{ kind: "issue", timestamp: 1, issue: executionIssue }],
options,
)).toEqual({
executionWorkspaceId: "execution-workspace-1",
executionWorkspaceMode: "reuse_existing",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
});
expect(buildInboxIssueGroupCreateDefaults(
"assignee:agent:agent-1",
"assignee",
[{ kind: "issue", timestamp: 1, issue: agentIssue }],
options,
)).toEqual({ assigneeAgentId: "agent-1" });
});
it("persists inbox grouping preferences", () => {
saveInboxWorkItemGroupBy("workspace");
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
+90
View File
@@ -126,12 +126,14 @@ export type InboxKeyboardNavEntry =
export interface InboxProjectWorkspaceLookup {
name: string;
projectId?: string | null;
}
export interface InboxExecutionWorkspaceLookup {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId?: string | null;
}
export interface InboxWorkspaceGroupingOptions {
@@ -144,6 +146,15 @@ export interface InboxWorkspaceGroupingOptions {
currentUserId?: string | null;
}
export interface InboxIssueGroupCreateDefaults {
projectId?: string;
projectWorkspaceId?: string;
executionWorkspaceId?: string;
executionWorkspaceMode?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
}
const defaultInboxFilterPreferences: InboxFilterPreferences = {
allCategoryFilter: "everything",
allApprovalFilter: "all",
@@ -931,6 +942,85 @@ export function groupInboxWorkItems(
return orderedGroups;
}
function stripInboxSearchGroupPrefix(groupKey: string) {
return groupKey
.replace(/^archived-search:/, "")
.replace(/^other-search:/, "");
}
function firstIssueFromInboxWorkItems(items: InboxWorkItem[]): Issue | null {
return items.find((item): item is InboxWorkItem & { kind: "issue" } => item.kind === "issue")?.issue ?? null;
}
function projectIdForProjectWorkspace(
projectWorkspaceId: string | null | undefined,
options: InboxWorkspaceGroupingOptions,
fallbackIssue: Issue | null,
) {
if (!projectWorkspaceId) return fallbackIssue?.projectId ?? null;
return options.projectWorkspaceById?.get(projectWorkspaceId)?.projectId
?? (fallbackIssue?.projectWorkspaceId === projectWorkspaceId ? fallbackIssue.projectId : null);
}
export function buildInboxIssueGroupCreateDefaults(
groupKey: string,
groupBy: InboxWorkItemGroupBy,
items: InboxWorkItem[],
options: InboxWorkspaceGroupingOptions = {},
): InboxIssueGroupCreateDefaults | null {
const fallbackIssue = firstIssueFromInboxWorkItems(items);
if (!fallbackIssue) return null;
const key = stripInboxSearchGroupPrefix(groupKey);
if (groupBy === "project") {
if (!key.startsWith("project:")) return {};
const projectId = key.slice("project:".length);
return projectId && projectId !== "none" ? { projectId } : {};
}
if (groupBy === "assignee") {
if (key.startsWith("assignee:agent:")) {
const assigneeAgentId = key.slice("assignee:agent:".length);
return assigneeAgentId ? { assigneeAgentId } : {};
}
if (key.startsWith("assignee:user:")) {
const assigneeUserId = key.slice("assignee:user:".length);
return assigneeUserId ? { assigneeUserId } : {};
}
return {};
}
if (groupBy === "workspace") {
if (key.startsWith("workspace:execution:")) {
const executionWorkspaceId = key.slice("workspace:execution:".length);
if (!executionWorkspaceId) return {};
const executionWorkspace = options.executionWorkspaceById?.get(executionWorkspaceId) ?? null;
const projectWorkspaceId = executionWorkspace?.projectWorkspaceId
?? (fallbackIssue.executionWorkspaceId === executionWorkspaceId ? fallbackIssue.projectWorkspaceId : null);
const projectId = executionWorkspace?.projectId
?? projectIdForProjectWorkspace(projectWorkspaceId, options, fallbackIssue);
return {
executionWorkspaceId,
executionWorkspaceMode: "reuse_existing",
...(projectId ? { projectId } : {}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
};
}
if (key.startsWith("workspace:project:")) {
const projectWorkspaceId = key.slice("workspace:project:".length);
if (!projectWorkspaceId) return {};
const projectId = projectIdForProjectWorkspace(projectWorkspaceId, options, fallbackIssue);
return {
...(projectId ? { projectId } : {}),
projectWorkspaceId,
};
}
}
return {};
}
/**
* Groups parent-child issues in a flat InboxWorkItem list.
*
+33 -17
View File
@@ -20,6 +20,8 @@ import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { PageSkeleton } from "../components/PageSkeleton";
import { CopyText } from "../components/CopyText";
import { Identity } from "../components/Identity";
import {
Dialog,
DialogContent,
@@ -49,6 +51,7 @@ import {
Paperclip,
Pencil,
Plus,
Copy,
RefreshCw,
Save,
Search,
@@ -171,6 +174,12 @@ function shortRef(ref: string | null | undefined) {
return ref.slice(0, 7);
}
function middleTruncate(value: string, maxLength = 72) {
if (value.length <= maxLength) return value;
const edgeLength = Math.floor((maxLength - 3) / 2);
return `${value.slice(0, edgeLength)}...${value.slice(value.length - edgeLength)}`;
}
function formatProjectScanSummary(result: CompanySkillProjectScanResult) {
const parts = [
`${result.discovered} found`,
@@ -530,8 +539,6 @@ function SkillPane({
onSave: () => void;
savePending: boolean;
}) {
const { pushToast } = useToastActions();
if (!detail) {
if (loading) {
return <PageSkeleton variant="detail" />;
@@ -550,6 +557,7 @@ function SkillPane({
const body = file?.markdown ? stripFrontmatter(file.content) : file?.content ?? "";
const currentPin = shortRef(detail.sourceRef);
const latestPin = shortRef(updateStatus?.latestRef);
const displaySourcePath = detail.sourcePath ? middleTruncate(detail.sourcePath) : null;
const removeBlocked = usedBy.length > 0;
const removeDisabledReason = removeBlocked
? "Detach this skill from all agents before removing it."
@@ -595,20 +603,28 @@ function SkillPane({
<div className="mt-4 space-y-3 border-t border-border pt-4 text-sm">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Source</span>
<span className="flex items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
<SourceIcon className="h-3.5 w-3.5 text-muted-foreground" />
{detail.sourcePath ? (
<button
className="truncate hover:text-foreground text-muted-foreground transition-colors cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(detail.sourcePath!);
pushToast({ title: "Copied path to workspace" });
}}
>
{source.label}
</button>
{detail.sourcePath && displaySourcePath ? (
<>
<span
className="block min-w-0 max-w-[min(34rem,55vw)] truncate font-mono text-xs text-muted-foreground"
title={detail.sourcePath}
>
{displaySourcePath}
</span>
<CopyText
text={detail.sourcePath}
copiedLabel="Copied path"
ariaLabel="Copy source path"
title="Copy source path"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</CopyText>
</>
) : (
<span className="truncate">{source.label}</span>
)}
@@ -662,14 +678,14 @@ function SkillPane({
{usedBy.length === 0 ? (
<span className="text-muted-foreground">No agents attached</span>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1">
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{usedBy.map((agent) => (
<Link
key={agent.id}
to={`/agents/${agent.urlKey}/skills`}
className="text-foreground no-underline hover:underline"
className="group rounded-md border border-transparent p-2 no-underline hover:border-border hover:bg-accent/40"
>
{agent.name}
<Identity name={agent.name} size="sm" />
</Link>
))}
</div>
+39 -3
View File
@@ -18,6 +18,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { useDialogActions } from "../context/DialogContext";
import {
applyIssueFilters,
countActiveIssueFilters,
@@ -85,6 +86,7 @@ import {
Check,
ChevronRight,
Layers,
Plus,
XCircle,
X,
RotateCcw,
@@ -102,6 +104,7 @@ import {
ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxIssueGroupCreateDefaults,
buildInboxKeyboardNavEntries,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
@@ -652,6 +655,7 @@ function JoinRequestInboxRow({
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { openNewIssue } = useDialogActions();
const { isMobile } = useSidebar();
const navigate = useNavigate();
const location = useLocation();
@@ -946,10 +950,10 @@ export function Inbox() {
return map;
}, [projects]);
const projectWorkspaceById = useMemo(() => {
const map = new Map<string, { name: string }>();
const map = new Map<string, { name: string; projectId: string }>();
for (const project of projects ?? []) {
for (const workspace of project.workspaces ?? []) {
map.set(workspace.id, { name: workspace.name });
map.set(workspace.id, { name: workspace.name, projectId: project.id });
}
}
return map;
@@ -970,16 +974,21 @@ export function Inbox() {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId: string | null;
}>();
for (const workspace of executionWorkspaces) {
const projectWorkspace = workspace.projectWorkspaceId
? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null
: null;
map.set(workspace.id, {
name: workspace.name,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
projectId: projectWorkspace?.projectId ?? null,
});
}
return map;
}, [executionWorkspaces]);
}, [executionWorkspaces, projectWorkspaceById]);
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
() => ({
agentById,
@@ -1243,6 +1252,17 @@ export function Inbox() {
issueSearchSupplementResults,
nestingEnabled,
]);
const openCreateIssueForGroup = useCallback((group: InboxGroupedSection) => {
const defaults = buildInboxIssueGroupCreateDefaults(
group.key,
groupBy,
group.displayItems,
inboxWorkspaceGrouping,
);
if (!defaults) return;
openNewIssue(defaults);
}, [groupBy, inboxWorkspaceGrouping, openNewIssue]);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],
@@ -2280,6 +2300,7 @@ export function Inbox() {
if (group.label) {
const groupNavIdx = groupFlatIndex.get(group.key) ?? -1;
const isGroupSelected = groupNavIdx >= 0 && selectedIndex === groupNavIdx;
const canCreateIssueInGroup = group.displayItems.some((item) => item.kind === "issue");
elements.push(
<div
key={`group-${group.key}`}
@@ -2298,6 +2319,21 @@ export function Inbox() {
collapsible
collapsed={isGroupCollapsed}
onToggle={() => toggleGroupCollapse(group.key)}
trailing={canCreateIssueInGroup ? (
<Button
variant="ghost"
size="icon-xs"
className="-mr-2 text-muted-foreground"
title={`New issue in ${group.label}`}
aria-label={`New issue in ${group.label}`}
onClick={(event) => {
event.stopPropagation();
openCreateIssueForGroup(group);
}}
>
<Plus className="h-3 w-3" />
</Button>
) : null}
/>
</div>,
);
+45 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, type AnchorHTMLAttributes, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
@@ -18,6 +18,11 @@ const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }),
useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()],
@@ -451,6 +456,45 @@ describe("Routines page", () => {
});
});
it("shows a row-level run now button on the routines table", async () => {
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
issuesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
let runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Run now"),
);
for (let attempts = 0; attempts < 5 && !runNowButton; attempts += 1) {
await act(async () => {
await flush();
});
runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Run now"),
);
}
expect(runNowButton).toBeTruthy();
await act(async () => {
root.unmount();
});
});
it("passes company mention options to the routine description editor", async () => {
routinesListMock.mockResolvedValue([]);
issuesListMock.mockResolvedValue([]);
+1
View File
@@ -917,6 +917,7 @@ export function Routines() {
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
href={`/routines/${routine.id}`}
runNowButton
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}