Improve operator workflow QoL (#5291)

## Thinking Path

> - Paperclip is a control plane operators use repeatedly to supervise
agent companies.
> - Common operator workflows depend on fast scanning of inboxes, issue
sidebars, workspaces, cost totals, and runtime services.
> - Several small UI and service gaps made those workflows slower or
less clear.
> - This pull request groups the operator-facing QoL changes that can
stand alone from recovery and adapter work.
> - The benefit is a denser, clearer board experience for issue triage
and workspace operation.

## What Changed

- Added inbox assignee/project grouping and issue list token/runtime
totals.
- Improved issue properties with removable blocker chips and workspace
task links.
- Improved execution workspace layout, runtime controls, issues tab
default, and stopped-port reuse behavior.
- Added mobile markdown/routine dialog fixes, page title company names,
sidebar polish, and dashboard run task label cleanup.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/lib/inbox.test.ts
ui/src/components/IssueProperties.test.tsx
ui/src/components/WorkspaceRuntimeControls.test.tsx
server/src/__tests__/workspace-runtime.test.ts
server/src/__tests__/costs-service.test.ts`

## Risks

- Medium UI risk because this touches several operator surfaces. The
branch is intentionally grouped around workflow/QoL files and keeps the
file count below the Greptile limit.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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-06 06:30:44 -05:00 committed by GitHub
parent 11ffd6f2c5
commit 424e81d087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1739 additions and 250 deletions

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -25,6 +25,7 @@ import {
} from "../components/RoutineRunVariablesDialog";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
WorkspaceRuntimeControls,
type WorkspaceRuntimeControlRequest,
} from "../components/WorkspaceRuntimeControls";
@ -53,13 +54,14 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
const tab = segments[executionWorkspacesIndex + 2];
if (tab === "services") return "services";
if (tab === "issues") return "issues";
if (tab === "routines") return "routines";
if (tab === "runtime-logs") return "runtime_logs";
@ -72,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT
return `/execution-workspaces/${workspaceId}/${segment}`;
}
function LegacyWorkspaceTabRedirect({ workspaceId }: { workspaceId: string }) {
useEffect(() => {
try {
localStorage.removeItem(`paperclip:execution-workspace-tab:${workspaceId}`);
} catch {}
}, [workspaceId]);
return <Navigate to={executionWorkspaceTabPath(workspaceId, "issues")} replace />;
}
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
@ -259,14 +271,14 @@ function WorkspaceLink({
function ExecutionWorkspaceIssuesList({
companyId,
workspaceId,
workspace,
issues,
isLoading,
error,
project,
}: {
companyId: string;
workspaceId: string;
workspace: ExecutionWorkspace;
issues: Issue[];
isLoading: boolean;
error: Error | null;
@ -292,7 +304,7 @@ function ExecutionWorkspaceIssuesList({
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (project?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
@ -304,6 +316,15 @@ function ExecutionWorkspaceIssuesList({
() => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
[project],
);
const createIssueDefaults = useMemo(
() => ({
projectId: workspace.projectId,
...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}),
executionWorkspaceId: workspace.id,
executionWorkspaceMode: "reuse_existing",
}),
[workspace.id, workspace.projectId, workspace.projectWorkspaceId],
);
return (
<IssuesList
@ -315,6 +336,7 @@ function ExecutionWorkspaceIssuesList({
liveIssueIds={liveIssueIds}
projectId={project?.id}
viewStateKey="paperclip:execution-workspace-issues-view"
baseCreateIssueDefaults={createIssueDefaults}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
@ -663,25 +685,10 @@ export function ExecutionWorkspaceDetail() {
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
if (workspaceId && activeTab === null) {
let cachedTab: ExecutionWorkspaceTab = "configuration";
try {
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
if (
storedTab === "issues" ||
storedTab === "routines" ||
storedTab === "configuration" ||
storedTab === "runtime_logs"
) {
cachedTab = storedTab;
}
} catch {}
return <Navigate to={executionWorkspaceTabPath(workspaceId, cachedTab)} replace />;
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
try {
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
} catch {}
navigate(executionWorkspaceTabPath(workspace.id, tab));
};
@ -707,43 +714,39 @@ export function ExecutionWorkspaceDetail() {
return (
<>
<div className="space-y-4 overflow-hidden sm:space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to all workspaces
</Link>
</Button>
<StatusPill>{workspace.mode}</StatusPill>
<StatusPill>{workspace.providerType}</StatusPill>
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
{workspace.status}
</StatusPill>
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
<span className="hidden sm:inline"> These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.</span>
</p>
<WorkspaceRuntimeQuickControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
</div>
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
<Card className="rounded-none">
<CardHeader>
<CardTitle>Services and jobs</CardTitle>
<CardDescription>
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none"}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "issues", label: "Issues" },
{ value: "services", label: "Services" },
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "issues"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "services" ? (
<WorkspaceRuntimeControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
@ -761,26 +764,7 @@ export function ExecutionWorkspaceDetail() {
}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
</CardContent>
</Card>
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "issues", label: "Issues" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "configuration"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
) : activeTab === "configuration" ? (
<div className="space-y-4 sm:space-y-6">
<Card className="rounded-none">
<CardHeader>
@ -792,7 +776,7 @@ export function ExecutionWorkspaceDetail() {
<Button
variant="destructive"
size="sm"
className="w-full rounded-none sm:w-auto"
className="w-full sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
@ -1138,7 +1122,7 @@ export function ExecutionWorkspaceDetail() {
) : activeTab === "issues" ? (
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspaceId={workspace.id}
workspace={workspace}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}

View file

@ -982,11 +982,23 @@ export function Inbox() {
}, [executionWorkspaces]);
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
() => ({
agentById,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
projectById,
userLabelById: companyUserLabelMap,
currentUserId,
}),
[defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectWorkspaceById],
[
agentById,
companyUserLabelMap,
currentUserId,
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
projectById,
projectWorkspaceById,
],
);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
@ -1990,6 +2002,8 @@ export function Inbox() {
{([
["none", "None"],
["type", "Type"],
["assignee", "Assignee"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
<button

View file

@ -60,7 +60,7 @@ import {
} from "../lib/optimistic-issue-comments";
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
@ -966,8 +966,11 @@ function IssueDetailActivityTab({
let output = 0;
let cached = 0;
let cost = 0;
let runtimeMs = 0;
let runCount = 0;
let hasCost = false;
let hasTokens = false;
const nowMs = Date.now();
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
@ -987,6 +990,15 @@ function IssueDetailActivityTab({
output += runOutput;
cached += runCached;
cost += runCost;
if (run.startedAt) {
const startMs = new Date(run.startedAt).getTime();
const endMs = run.finishedAt ? new Date(run.finishedAt).getTime() : nowMs;
if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
runtimeMs += endMs - startMs;
runCount += 1;
}
}
}
return {
@ -997,6 +1009,9 @@ function IssueDetailActivityTab({
totalTokens: input + output,
hasCost,
hasTokens,
runtimeMs,
runCount,
hasRuntime: runtimeMs > 0,
};
}, [linkedRuns]);
const issueTreeCostTokens =
@ -1006,6 +1021,7 @@ function IssueDetailActivityTab({
&& (issueTreeCostSummary.costCents > 0
|| issueTreeCostTokens > 0
|| issueTreeCostSummary.cachedInputTokens > 0
|| issueTreeCostSummary.runtimeMs > 0
|| issueTreeCostSummary.issueCount > 1);
const shouldShowCostSummary =
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
@ -1038,7 +1054,13 @@ function IssueDetailActivityTab({
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
{issueCostSummary.hasRuntime ? (
<span>
Runtime {formatDurationMs(issueCostSummary.runtimeMs)}
{` (${issueCostSummary.runCount} run${issueCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !issueCostSummary.hasRuntime ? (
<span>No direct cost data.</span>
) : null}
</div>
@ -1058,6 +1080,12 @@ function IssueDetailActivityTab({
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
</span>
{issueTreeCostSummary.runCount > 0 ? (
<span>
Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)}
{` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
</div>
) : null}
@ -3466,6 +3494,7 @@ export function IssueDetail() {
createIssueLabel="Sub-issue"
defaultSortField="workflow"
showProgressSummary
parentIssueIdForCostSummary={issue.id}
onUpdateIssue={handleChildIssueUpdate}
/>
</div>