mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
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:
parent
c4269bab59
commit
1fe1067361
28 changed files with 1718 additions and 173 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue