mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
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:
parent
11ffd6f2c5
commit
424e81d087
47 changed files with 1739 additions and 250 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue