Enhance UI: favicon, AgentDetail overhaul, PageTabBar, and config form

Add favicon and web manifest branding assets. Major AgentDetail page
rework with tabbed sections, run history, and live status. Add
PageTabBar component for consistent page-level tabs. Expand
AgentConfigForm with more adapter fields. Improve NewAgentDialog,
OnboardingWizard, and Issues page layouts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-18 13:02:23 -06:00
parent 11c8c1af78
commit d6024b3ca5
17 changed files with 982 additions and 160 deletions

View File

@@ -3,7 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#18181b" />
<title>Paperclip</title> <title>Paperclip</title>
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
ui/public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 B

BIN
ui/public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
ui/public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<style>
path { stroke: #18181b; }
@media (prefers-color-scheme: dark) {
path { stroke: #e4e4e7; }
}
</style>
<path stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -0,0 +1,19 @@
{
"name": "Paperclip",
"short_name": "Paperclip",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#18181b",
"background_color": "#18181b",
"display": "standalone"
}

View File

@@ -27,6 +27,7 @@ export function App() {
<Route path="org" element={<Navigate to="/agents" replace />} /> <Route path="org" element={<Navigate to="/agents" replace />} />
<Route path="agents" element={<Agents />} /> <Route path="agents" element={<Agents />} />
<Route path="agents/:agentId" element={<AgentDetail />} /> <Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
<Route path="projects" element={<Projects />} /> <Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} /> <Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} /> <Route path="issues" element={<Issues />} />

View File

@@ -37,6 +37,8 @@ export interface CreateConfigValues {
dangerouslyBypassSandbox: boolean; dangerouslyBypassSandbox: boolean;
command: string; command: string;
args: string; args: string;
extraArgs: string;
envVars: string;
url: string; url: string;
bootstrapPrompt: string; bootstrapPrompt: string;
maxTurnsPerRun: number; maxTurnsPerRun: number;
@@ -54,6 +56,8 @@ export const defaultCreateValues: CreateConfigValues = {
dangerouslyBypassSandbox: false, dangerouslyBypassSandbox: false,
command: "", command: "",
args: "", args: "",
extraArgs: "",
envVars: "",
url: "", url: "",
bootstrapPrompt: "", bootstrapPrompt: "",
maxTurnsPerRun: 80, maxTurnsPerRun: 80,
@@ -65,6 +69,10 @@ export const defaultCreateValues: CreateConfigValues = {
type AgentConfigFormProps = { type AgentConfigFormProps = {
adapterModels?: AdapterModel[]; adapterModels?: AdapterModel[];
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
hideInlineSave?: boolean;
} & ( } & (
| { | {
mode: "create"; mode: "create";
@@ -110,6 +118,51 @@ function isOverlayDirty(o: Overlay): boolean {
const inputClass = const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function formatArgList(value: unknown): string {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.join(", ");
}
return typeof value === "string" ? value : "";
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function formatEnvVars(value: unknown): string {
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
return Object.entries(value as Record<string, unknown>)
.filter(([, v]) => typeof v === "string")
.map(([k, v]) => `${k}=${String(v)}`)
.join("\n");
}
function extractPickedDirectoryPath(handle: unknown): string | null {
if (typeof handle !== "object" || handle === null) return null;
const maybePath = (handle as { path?: unknown }).path;
return typeof maybePath === "string" && maybePath.length > 0 ? maybePath : null;
}
/* ---- Form ---- */ /* ---- Form ---- */
export function AgentConfigForm(props: AgentConfigFormProps) { export function AgentConfigForm(props: AgentConfigFormProps) {
@@ -175,6 +228,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
props.onSave(patch); props.onSave(patch);
} }
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(() => handleSave());
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
}
return;
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
// ---- Resolve values ---- // ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {}; const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
@@ -195,6 +262,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Section toggle state — advanced always starts collapsed // Section toggle state — advanced always starts collapsed
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
const [cwdPickerNotice, setCwdPickerNotice] = useState<string | null>(null);
// Popover states // Popover states
const [modelOpen, setModelOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false);
@@ -213,7 +281,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
return ( return (
<div className="relative"> <div className="relative">
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && ( {isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20"> <div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Unsaved changes</span> <span className="text-xs text-muted-foreground">Unsaved changes</span>
@@ -237,6 +305,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<DraftInput <DraftInput
value={eff("identity", "name", props.agent.name)} value={eff("identity", "name", props.agent.name)}
onCommit={(v) => mark("identity", "name", v)} onCommit={(v) => mark("identity", "name", v)}
immediate
className={inputClass} className={inputClass}
placeholder="Agent name" placeholder="Agent name"
/> />
@@ -245,6 +314,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<DraftInput <DraftInput
value={eff("identity", "title", props.agent.title ?? "")} value={eff("identity", "title", props.agent.title ?? "")}
onCommit={(v) => mark("identity", "title", v || null)} onCommit={(v) => mark("identity", "title", v || null)}
immediate
className={inputClass} className={inputClass}
placeholder="e.g. VP of Engineering" placeholder="e.g. VP of Engineering"
/> />
@@ -253,6 +323,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<DraftTextarea <DraftTextarea
value={eff("identity", "capabilities", props.agent.capabilities ?? "")} value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
onCommit={(v) => mark("identity", "capabilities", v || null)} onCommit={(v) => mark("identity", "capabilities", v || null)}
immediate
placeholder="Describe what this agent can do..." placeholder="Describe what this agent can do..."
minRows={2} minRows={2}
/> />
@@ -303,7 +374,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ cwd: v }) ? set!({ cwd: v })
: mark("adapterConfig", "cwd", v || undefined) : mark("adapterConfig", "cwd", v || undefined)
} }
immediate={isCreate} immediate
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="/path/to/project" placeholder="/path/to/project"
/> />
@@ -312,10 +383,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0" className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={async () => { onClick={async () => {
try { try {
setCwdPickerNotice(null);
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
const handle = await window.showDirectoryPicker({ mode: "read" }); const handle = await window.showDirectoryPicker({ mode: "read" });
if (isCreate) set!({ cwd: handle.name }); const absolutePath = extractPickedDirectoryPath(handle);
else mark("adapterConfig", "cwd", handle.name); if (absolutePath) {
if (isCreate) set!({ cwd: absolutePath });
else mark("adapterConfig", "cwd", absolutePath);
return;
}
const selectedName =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { name?: unknown }).name === "string"
? String((handle as { name: string }).name)
: "selected folder";
setCwdPickerNotice(
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
);
} catch { } catch {
// user cancelled or API unsupported // user cancelled or API unsupported
} }
@@ -324,6 +409,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Choose Choose
</button> </button>
</div> </div>
{cwdPickerNotice && (
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
)}
</Field> </Field>
)} )}
@@ -347,6 +435,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onCommit={(v) => onCommit={(v) =>
mark("adapterConfig", "promptTemplate", v || undefined) mark("adapterConfig", "promptTemplate", v || undefined)
} }
immediate
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
minRows={4} minRows={4}
/> />
@@ -429,7 +518,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ command: v }) ? set!({ command: v })
: mark("adapterConfig", "command", v || undefined) : mark("adapterConfig", "command", v || undefined)
} }
immediate={isCreate} immediate
className={inputClass} className={inputClass}
placeholder="e.g. node, python" placeholder="e.g. node, python"
/> />
@@ -439,7 +528,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={ value={
isCreate isCreate
? val!.args ? val!.args
: eff("adapterConfig", "args", String(config.args ?? "")) : eff("adapterConfig", "args", formatArgList(config.args))
} }
onCommit={(v) => onCommit={(v) =>
isCreate isCreate
@@ -447,15 +536,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: mark( : mark(
"adapterConfig", "adapterConfig",
"args", "args",
v v ? parseCommaArgs(v) : undefined,
? v
.split(",")
.map((a) => a.trim())
.filter(Boolean)
: undefined,
) )
} }
immediate={isCreate} immediate
className={inputClass} className={inputClass}
placeholder="e.g. script.js, --flag" placeholder="e.g. script.js, --flag"
/> />
@@ -477,7 +561,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ url: v }) ? set!({ url: v })
: mark("adapterConfig", "url", v || undefined) : mark("adapterConfig", "url", v || undefined)
} }
immediate={isCreate} immediate
className={inputClass} className={inputClass}
placeholder="https://..." placeholder="https://..."
/> />
@@ -492,6 +576,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)} onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
> >
<div className="space-y-3"> <div className="space-y-3">
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff("adapterConfig", "command", String(config.command ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", "command", v || undefined)
}
immediate
className={inputClass}
placeholder={adapterType === "codex_local" ? "codex" : "claude"}
/>
</Field>
<ModelDropdown <ModelDropdown
models={models} models={models}
value={currentModelId} value={currentModelId}
@@ -521,6 +623,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onCommit={(v) => onCommit={(v) =>
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
} }
immediate
placeholder="Optional initial setup prompt for the first run" placeholder="Optional initial setup prompt for the first run"
minRows={2} minRows={2}
/> />
@@ -543,12 +646,57 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Number(config.maxTurnsPerRun ?? 80), Number(config.maxTurnsPerRun ?? 80),
)} )}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)} onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
immediate
className={inputClass} className={inputClass}
/> />
)} )}
</Field> </Field>
)} )}
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
value={
isCreate
? val!.extraArgs
: eff("adapterConfig", "extraArgs", formatArgList(config.extraArgs))
}
onCommit={(v) =>
isCreate
? set!({ extraArgs: v })
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
}
immediate
className={inputClass}
placeholder="e.g. --verbose, --foo=bar"
/>
</Field>
<Field label="Environment variables" hint={help.envVars}>
{isCreate ? (
<AutoExpandTextarea
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
value={val!.envVars}
onChange={(v) => set!({ envVars: v })}
minRows={3}
/>
) : (
<DraftTextarea
value={eff("adapterConfig", "env", formatEnvVars(config.env))}
onCommit={(v) => {
const parsed = parseEnvVars(v);
mark(
"adapterConfig",
"env",
Object.keys(parsed).length > 0 ? parsed : undefined,
);
}}
immediate
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
minRows={3}
/>
)}
</Field>
{/* Edit-only: timeout + grace period */} {/* Edit-only: timeout + grace period */}
{!isCreate && ( {!isCreate && (
<> <>
@@ -560,6 +708,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Number(config.timeoutSec ?? 0), Number(config.timeoutSec ?? 0),
)} )}
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)} onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
immediate
className={inputClass} className={inputClass}
/> />
</Field> </Field>
@@ -571,6 +720,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Number(config.graceSec ?? 15), Number(config.graceSec ?? 15),
)} )}
onCommit={(v) => mark("adapterConfig", "graceSec", v)} onCommit={(v) => mark("adapterConfig", "graceSec", v)}
immediate
className={inputClass} className={inputClass}
/> />
</Field> </Field>
@@ -669,6 +819,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
Number(heartbeat.cooldownSec ?? 10), Number(heartbeat.cooldownSec ?? 10),
)} )}
onCommit={(v) => mark("heartbeat", "cooldownSec", v)} onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
immediate
className={inputClass} className={inputClass}
/> />
</Field> </Field>
@@ -695,6 +846,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
props.agent.budgetMonthlyCents, props.agent.budgetMonthlyCents,
)} )}
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)} onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
immediate
className={inputClass} className={inputClass}
/> />
</Field> </Field>

View File

@@ -30,6 +30,28 @@ import {
type CreateConfigValues, type CreateConfigValues,
} from "./AgentConfigForm"; } from "./AgentConfigForm";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const valueAtKey = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = valueAtKey;
}
return env;
}
export function NewAgentDialog() { export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent } = useDialog(); const { newAgentOpen, closeNewAgent } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
@@ -102,16 +124,22 @@ export function NewAgentDialog() {
if (v.model) ac.model = v.model; if (v.model) ac.model = v.model;
ac.timeoutSec = 0; ac.timeoutSec = 0;
ac.graceSec = 15; ac.graceSec = 15;
const env = parseEnvVars(v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.adapterType === "claude_local") { if (v.adapterType === "claude_local") {
ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
} else if (v.adapterType === "codex_local") { } else if (v.adapterType === "codex_local") {
ac.search = v.search; ac.search = v.search;
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox; ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
} else if (v.adapterType === "process") { } else if (v.adapterType === "process") {
if (v.command) ac.command = v.command; if (v.command) ac.command = v.command;
if (v.args) ac.args = v.args.split(",").map((a) => a.trim()).filter(Boolean); if (v.args) ac.args = parseCommaArgs(v.args);
} else if (v.adapterType === "http") { } else if (v.adapterType === "http") {
if (v.url) ac.url = v.url; if (v.url) ac.url = v.url;
} }

View File

@@ -58,6 +58,7 @@ export function OnboardingWizard() {
const [command, setCommand] = useState(""); const [command, setCommand] = useState("");
const [args, setArgs] = useState(""); const [args, setArgs] = useState("");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [cwdPickerNotice, setCwdPickerNotice] = useState<string | null>(null);
// Step 3 // Step 3
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
@@ -88,6 +89,7 @@ export function OnboardingWizard() {
setCommand(""); setCommand("");
setArgs(""); setArgs("");
setUrl(""); setUrl("");
setCwdPickerNotice(null);
setTaskTitle("Create your CEO HEARTBEAT.md"); setTaskTitle("Create your CEO HEARTBEAT.md");
setTaskDescription("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL."); setTaskDescription("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL.");
setCreatedCompanyId(null); setCreatedCompanyId(null);
@@ -406,9 +408,28 @@ export function OnboardingWizard() {
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0" className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={async () => { onClick={async () => {
try { try {
setCwdPickerNotice(null);
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
const handle = await window.showDirectoryPicker({ mode: "read" }); const handle = await window.showDirectoryPicker({ mode: "read" });
setCwd(handle.name); const pickedPath =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { path?: unknown }).path === "string"
? String((handle as { path: string }).path)
: "";
if (pickedPath) {
setCwd(pickedPath);
return;
}
const selectedName =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { name?: unknown }).name === "string"
? String((handle as { name: string }).name)
: "selected folder";
setCwdPickerNotice(
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
);
} catch { } catch {
// user cancelled or API unsupported // user cancelled or API unsupported
} }
@@ -417,6 +438,9 @@ export function OnboardingWizard() {
Choose Choose
</button> </button>
</div> </div>
{cwdPickerNotice && (
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
)}
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block"> <label className="text-xs text-muted-foreground mb-1 block">

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface PageTabItem {
value: string;
label: ReactNode;
}
export function PageTabBar({ items }: { items: PageTabItem[] }) {
return (
<TabsList variant="line">
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
);
}

View File

@@ -15,7 +15,7 @@ export const help: Record<string, string> = {
reportsTo: "The agent this one reports to in the org hierarchy.", reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.", adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
cwd: "The working directory where the agent operates. Should be an absolute path on the server.", cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.", model: "Override the default model used by the adapter.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
@@ -24,7 +24,10 @@ export const help: Record<string, string> = {
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.", bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).", command: "The command to execute (e.g. node, python).",
localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
args: "Command-line arguments, comma-separated.", args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. One KEY=VALUE per line.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.", webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.", heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.", intervalSec: "Seconds between automatic heartbeat invocations.",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -12,8 +12,9 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageTabBar } from "../components/PageTabBar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { CircleDot, Plus } from "lucide-react"; import { CircleDot, Plus } from "lucide-react";
import { formatDate } from "../lib/utils"; import { formatDate } from "../lib/utils";
import type { Issue } from "@paperclip/shared"; import type { Issue } from "@paperclip/shared";
@@ -26,6 +27,18 @@ function statusLabel(status: string): string {
type TabFilter = "all" | "active" | "backlog" | "done"; type TabFilter = "all" | "active" | "backlog" | "done";
const issueTabItems = [
{ value: "all", label: "All Issues" },
{ value: "active", label: "Active" },
{ value: "backlog", label: "Backlog" },
{ value: "done", label: "Done" },
] as const;
function parseIssueTab(value: string | null): TabFilter {
if (value === "active" || value === "backlog" || value === "done") return value;
return "all";
}
function filterIssues(issues: Issue[], tab: TabFilter): Issue[] { function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
switch (tab) { switch (tab) {
case "active": case "active":
@@ -45,7 +58,8 @@ export function Issues() {
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tab, setTab] = useState<TabFilter>("all"); const [searchParams, setSearchParams] = useSearchParams();
const tab = parseIssueTab(searchParams.get("tab"));
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -86,16 +100,18 @@ export function Issues() {
.filter((s) => grouped[s]?.length) .filter((s) => grouped[s]?.length)
.map((s) => ({ status: s, items: grouped[s]! })); .map((s) => ({ status: s, items: grouped[s]! }));
const setTab = (nextTab: TabFilter) => {
const next = new URLSearchParams(searchParams);
if (nextTab === "all") next.delete("tab");
else next.set("tab", nextTab);
setSearchParams(next);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}> <Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
<TabsList variant="line"> <PageTabBar items={[...issueTabItems]} />
<TabsTrigger value="all">All Issues</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="backlog">Backlog</TabsTrigger>
<TabsTrigger value="done">Done</TabsTrigger>
</TabsList>
</Tabs> </Tabs>
<Button size="sm" onClick={() => openNewIssue()}> <Button size="sm" onClick={() => openNewIssue()}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />