Merge public-gh/master into paperclip-subissues

This commit is contained in:
Dotta
2026-03-17 09:42:31 -05:00
20 changed files with 825 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
@@ -30,6 +30,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs } from "@/components/ui/tabs";
import {
Popover,
@@ -186,11 +187,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget";
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configuration";
if (value === "budget") return "budget";
if (value === "skills") return value;
if (value === "budget") return value;
if (value === "runs") return value;
return "dashboard";
}
@@ -578,10 +580,12 @@ export function AgentDetail() {
const canonicalTab =
activeView === "configuration"
? "configuration"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: activeView === "skills"
? "skills"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
@@ -697,6 +701,8 @@ export function AgentDetail() {
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "skills") {
crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
@@ -856,6 +862,7 @@ export function AgentDetail() {
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "skills", label: "Skills" },
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
@@ -873,14 +880,9 @@ export function AgentDetail() {
)}
{/* Floating Save/Cancel (desktop) */}
{!isMobile && (
{!isMobile && showConfigActionBar && (
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
showConfigActionBar
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
className="sticky top-6 z-10 float-right transition-opacity duration-150"
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<Button
@@ -953,6 +955,12 @@ export function AgentDetail() {
/>
)}
{activeView === "skills" && (
<SkillsTab
agent={agent}
/>
)}
{activeView === "runs" && (
<RunsTab
runs={heartbeats ?? []}
@@ -1419,6 +1427,78 @@ function ConfigurationTab({
);
}
function SkillsTab({ agent }: { agent: Agent }) {
const instructionsPath =
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
? agent.adapterConfig.instructionsFilePath
: null;
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.skills.available,
queryFn: () => agentsApi.availableSkills(),
});
const skills = data?.skills ?? [];
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-2">
<h3 className="text-sm font-medium">Skills</h3>
<p className="text-sm text-muted-foreground">
Skills are reusable instruction bundles the agent can invoke from its local tool environment.
This view shows the current instructions file and the skills currently visible to the local agent runtime.
</p>
<p className="text-xs text-muted-foreground">
Agent: <span className="font-mono">{agent.name}</span>
</p>
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<div className="text-xs uppercase tracking-wide text-muted-foreground mb-1">
Instructions file
</div>
<div className="font-mono break-all">
{instructionsPath ?? "No instructions file configured for this agent."}
</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Available skills
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading available skills</p>
) : error ? (
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load available skills."}
</p>
) : skills.length === 0 ? (
<p className="text-sm text-muted-foreground">No local skills were found.</p>
) : (
<div className="space-y-2">
{skills.map((skill) => (
<SkillRow key={skill.name} skill={skill} />
))}
</div>
)}
</div>
</div>
</div>
);
}
function SkillRow({ skill }: { skill: AvailableSkill }) {
return (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{skill.name}</span>
<Badge variant={skill.isPaperclipManaged ? "secondary" : "outline"}>
{skill.isPaperclipManaged ? "Paperclip" : "Local"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{skill.description || "No description available."}
</p>
</div>
);
}
/* ---- Runs Tab ---- */
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {

View File

@@ -211,10 +211,10 @@ export function ProjectDetail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
@@ -286,13 +286,14 @@ export function ProjectDetail() {
{ archivedAt: archived ? new Date().toISOString() : null },
resolvedCompanyId ?? lookupCompanyId,
),
onSuccess: (_, archived) => {
onSuccess: (updatedProject, archived) => {
invalidateProject();
const name = updatedProject?.name ?? project?.name ?? "Project";
if (archived) {
pushToast({ title: "Project archived", tone: "success" });
pushToast({ title: `"${name}" has been archived`, tone: "success" });
navigate("/dashboard");
} else {
pushToast({ title: "Project unarchived", tone: "success" });
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
}
},
onError: (_, archived) => {
@@ -454,8 +455,24 @@ export function ProjectDetail() {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
// Redirect bare /projects/:id to /projects/:id/issues
// Redirect bare /projects/:id to cached tab or default /issues
if (routeProjectRef && activeTab === null) {
let cachedTab: string | null = null;
if (project?.id) {
try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {}
}
if (cachedTab === "overview") {
return <Navigate to={`/projects/${canonicalProjectRef}/overview`} replace />;
}
if (cachedTab === "configuration") {
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
}
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (isProjectPluginTab(cachedTab)) {
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
}
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
@@ -464,6 +481,10 @@ export function ProjectDetail() {
if (!project) return null;
const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
if (project?.id) {
try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {}
}
if (isProjectPluginTab(tab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
return;
@@ -538,8 +559,8 @@ export function ProjectDetail() {
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar
items={[
{ value: "list", label: "Issues" },
{ value: "overview", label: "Overview" },
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" },
...pluginTabItems.map((item) => ({