Polish board settings and skills workflow (#4863)

## Thinking Path

> - Paperclip's board UI and bundled skills are the operator layer for
configuring agents, routines, issue workflows, and local troubleshooting
loops.
> - The prior rollup mixed this operator polish with database backups,
backend reliability, thread scale, and cost/workflow primitives.
> - This pull request isolates the remaining board QoL, settings,
issue-detail integration, adapter config cleanup, and skills smoke
tooling.
> - It includes some integration-level overlap with the thread and
workflow slices so this branch can run from `origin/master` while still
preserving the full original work.
> - Preferred merge order is the narrower primitives first, then this
integration PR last.
> - The benefit is that reviewers can inspect the user-facing
board/settings/skills layer separately from backend infrastructure
changes.

## What Changed

- Added board/settings polish for agents, routines, company settings,
project workspace detail, and issue detail controls.
- Added agent/routine UI regression tests and New Issue dialog coverage.
- Integrated issue-detail activity/cost/interaction surfaces and leaf
work pause/resume controls.
- Cleaned bundled adapter UI config defaults and onboarding copy.
- Added terminal-bench loop and work-stoppage diagnosis skills plus a
smoke test script.
- Updated attachment type handling and Paperclip skill/API guidance.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/pages/Agents.test.tsx
ui/src/pages/Routines.test.tsx ui/src/components/NewIssueDialog.test.tsx
ui/src/pages/IssueDetail.test.tsx
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts`
- Result: 7 test files passed, 54 tests passed.
- `pnpm run smoke:terminal-bench-loop-skill`
- Result: JSON output included `"ok": true` and `"cleanup": true`.
- UI screenshots not included because verification is focused
component/page coverage for the changed board surfaces.

## Risks

- This is the integration-heavy PR in the split and intentionally
overlaps some component/API primitives with the issue-thread and
workflow PRs so it can run from `origin/master`.
- Preferred merge order: #4859, #4860, #4861, #4862, then this PR last.
If earlier branches merge first, this PR may need a straightforward
conflict refresh in shared UI files.
- The terminal-bench smoke script creates temporary mock issues and
relies on cleanup; the verified run returned `cleanup: true`.

> 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, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## 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-04-30 15:28:11 -05:00 committed by GitHub
parent c4269bab59
commit 1fe1067361
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1718 additions and 173 deletions

View file

@ -1,7 +1,7 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@ -83,8 +83,12 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
type RoutineViewState = {
sortField: RoutineSortField;
sortDir: RoutineSortDir;
groupBy: RoutineGroupBy;
collapsedGroups: string[];
};
@ -96,6 +100,8 @@ type RoutineGroup = {
};
const defaultRoutineViewState: RoutineViewState = {
sortField: "updated",
sortDir: "desc",
groupBy: "none",
collapsedGroups: [],
};
@ -114,6 +120,16 @@ function saveRoutineViewState(key: string, state: RoutineViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function timestampValue(value: Date | string | null | undefined) {
if (!value) return Number.NEGATIVE_INFINITY;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
}
function compareNullableText(left: string | null | undefined, right: string | null | undefined) {
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
}
function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
@ -176,6 +192,31 @@ export function buildRoutineGroups(
}));
}
export function sortRoutines(
routines: RoutineListItem[],
sortField: RoutineSortField,
sortDir: RoutineSortDir,
): RoutineListItem[] {
const direction = sortDir === "asc" ? 1 : -1;
return [...routines].sort((left, right) => {
let result = 0;
if (sortField === "title") {
result = compareNullableText(left.title, right.title);
} else if (sortField === "created") {
result = timestampValue(left.createdAt) - timestampValue(right.createdAt);
} else if (sortField === "lastRun") {
result = timestampValue(left.lastRun?.triggeredAt ?? left.lastTriggeredAt) -
timestampValue(right.lastRun?.triggeredAt ?? right.lastTriggeredAt);
} else {
result = timestampValue(left.updatedAt) - timestampValue(right.updatedAt);
}
if (result !== 0) return result * direction;
return compareNullableText(left.title, right.title);
});
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
@ -509,9 +550,13 @@ export function Routines() {
[projects],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const sortedRoutines = useMemo(
() => sortRoutines(routines ?? [], routineViewState.sortField, routineViewState.sortDir),
[routineViewState.sortDir, routineViewState.sortField, routines],
);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
);
const recentRunsIssueLinkState = useMemo(
() =>
@ -606,36 +651,79 @@ export function Routines() {
<p className="text-sm text-muted-foreground">
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
</p>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["updated", "Updated"],
["created", "Created"],
["lastRun", "Last run"],
["title", "Title"],
] as const).map(([field, label]) => (
<button
key={field}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.sortField === field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
updateRoutineView(
routineViewState.sortField === field
? { sortDir: routineViewState.sortDir === "asc" ? "desc" : "asc" }
: { sortField: field, sortDir: field === "title" ? "asc" : "desc" },
);
}}
>
<span>{label}</span>
{routineViewState.sortField === field ? (
<span className="text-xs text-muted-foreground">
{routineViewState.sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Group">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</TabsContent>
<TabsContent value="runs">