diff --git a/cli/src/commands/heartbeat-run.ts b/cli/src/commands/heartbeat-run.ts index 109fa4ad..2ad958e6 100644 --- a/cli/src/commands/heartbeat-run.ts +++ b/cli/src/commands/heartbeat-run.ts @@ -174,6 +174,8 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } if (typeof logResult.nextOffset === "number") { logOffset = logResult.nextOffset; + } else if (logResult.content) { + logOffset += Buffer.byteLength(logResult.content, "utf8"); } } diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 61af427b..0f94586f 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,12 +1,15 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclip/shared"; import type { Agent } from "@paperclip/shared"; import type { AdapterModel } from "../api/agents"; +import { agentsApi } from "../api/agents"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown } from "lucide-react"; import { cn } from "../lib/utils"; import { @@ -18,7 +21,6 @@ import { DraftInput, DraftTextarea, DraftNumberInput, - HintIcon, help, adapterLabels, } from "./agent-config-primitives"; @@ -73,9 +75,37 @@ type AgentConfigFormProps = { mode: "edit"; agent: Agent; onSave: (patch: Record) => void; + isSaving?: boolean; } ); +/* ---- Edit mode overlay (dirty tracking) ---- */ + +interface Overlay { + identity: Record; + adapterType?: string; + adapterConfig: Record; + heartbeat: Record; + runtime: Record; +} + +const emptyOverlay: Overlay = { + identity: {}, + adapterConfig: {}, + heartbeat: {}, + runtime: {}, +}; + +function isOverlayDirty(o: Overlay): boolean { + return ( + Object.keys(o.identity).length > 0 || + o.adapterType !== undefined || + Object.keys(o.adapterConfig).length > 0 || + Object.keys(o.heartbeat).length > 0 || + Object.keys(o.runtime).length > 0 + ); +} + /* ---- Shared input class ---- */ 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"; @@ -83,20 +113,87 @@ const inputClass = /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { - const { mode, adapterModels } = props; + const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; - // Resolve adapter type + config + heartbeat from props - const adapterType = isCreate ? props.values.adapterType : props.agent.adapterType; - const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + // ---- Edit mode: overlay for dirty tracking ---- + const [overlay, setOverlay] = useState(emptyOverlay); + const agentRef = useRef(null); - // Edit mode: extract from agent + // Clear overlay when agent data refreshes (after save) + useEffect(() => { + if (!isCreate) { + if (agentRef.current !== null && props.agent !== agentRef.current) { + setOverlay({ ...emptyOverlay }); + } + agentRef.current = props.agent; + } + }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps + + const isDirty = !isCreate && isOverlayDirty(overlay); + + /** Read effective value: overlay if dirty, else original */ + function eff(group: keyof Omit, field: string, original: T): T { + const o = overlay[group]; + if (field in o) return o[field] as T; + return original; + } + + /** Mark field dirty in overlay */ + function mark(group: keyof Omit, field: string, value: unknown) { + setOverlay((prev) => ({ + ...prev, + [group]: { ...prev[group], [field]: value }, + })); + } + + /** Build accumulated patch and send to parent */ + function handleSave() { + if (isCreate || !isDirty) return; + const agent = props.agent; + const patch: Record = {}; + + if (Object.keys(overlay.identity).length > 0) { + Object.assign(patch, overlay.identity); + } + if (overlay.adapterType !== undefined) { + patch.adapterType = overlay.adapterType; + } + if (Object.keys(overlay.adapterConfig).length > 0) { + const existing = (agent.adapterConfig ?? {}) as Record; + patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; + } + if (Object.keys(overlay.heartbeat).length > 0) { + const existingRc = (agent.runtimeConfig ?? {}) as Record; + const existingHb = (existingRc.heartbeat ?? {}) as Record; + patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; + } + if (Object.keys(overlay.runtime).length > 0) { + Object.assign(patch, overlay.runtime); + } + + props.onSave(patch); + } + + // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; - // Section toggle state - const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(!isCreate); + const adapterType = isCreate + ? props.values.adapterType + : overlay.adapterType ?? props.agent.adapterType; + const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + + // Fetch adapter models for the effective adapter type + const { data: fetchedModels } = useQuery({ + queryKey: ["adapter-models", adapterType], + queryFn: () => agentsApi.adapterModels(adapterType), + }); + const models = fetchedModels ?? externalModels ?? []; + + // Section toggle state — advanced always starts collapsed + const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); // Popover states @@ -108,27 +205,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? (patch: Partial) => props.onChange(patch) : null; - // Edit mode helpers - const saveConfig = !isCreate - ? (field: string, value: unknown) => - props.onSave({ adapterConfig: { ...config, [field]: value } }) - : null; - const saveHeartbeat = !isCreate - ? (field: string, value: unknown) => - props.onSave({ - runtimeConfig: { ...runtimeConfig, heartbeat: { ...heartbeat, [field]: value } }, - }) - : null; - const saveIdentity = !isCreate - ? (field: string, value: unknown) => props.onSave({ [field]: value }) - : null; - // Current model for display - const currentModelId = isCreate ? val!.model : String(config.model ?? ""); - const selectedModel = (adapterModels ?? []).find((m) => m.id === currentModelId); + const currentModelId = isCreate + ? val!.model + : eff("adapterConfig", "model", String(config.model ?? "")); return ( -
+
+ {/* ---- Floating Save button (edit mode, when dirty) ---- */} + {isDirty && ( +
+
+ Unsaved changes + +
+
+ )} + {/* ---- Identity (edit only) ---- */} {!isCreate && (
@@ -136,24 +235,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
saveIdentity!("name", v)} + value={eff("identity", "name", props.agent.name)} + onCommit={(v) => mark("identity", "name", v)} className={inputClass} placeholder="Agent name" /> saveIdentity!("title", v || null)} + value={eff("identity", "title", props.agent.title ?? "")} + onCommit={(v) => mark("identity", "title", v || null)} className={inputClass} placeholder="e.g. VP of Engineering" /> saveIdentity!("capabilities", v || null)} + value={eff("identity", "capabilities", props.agent.capabilities ?? "")} + onCommit={(v) => mark("identity", "capabilities", v || null)} placeholder="Describe what this agent can do..." minRows={2} /> @@ -167,9 +266,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { - isCreate ? set!({ adapterType: t }) : props.onSave({ adapterType: t }) - } + onChange={(t) => { + if (isCreate) { + set!({ adapterType: t }); + } else { + setOverlay((prev) => ({ + ...prev, + adapterType: t, + adapterConfig: {}, // clear adapter config when type changes + })); + } + }} />
@@ -186,9 +293,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
- isCreate ? set!({ cwd: v }) : saveConfig!("cwd", v || undefined) + isCreate + ? set!({ cwd: v }) + : mark("adapterConfig", "cwd", v || undefined) } immediate={isCreate} className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" @@ -202,7 +315,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet const handle = await window.showDirectoryPicker({ mode: "read" }); if (isCreate) set!({ cwd: handle.name }); - else saveConfig!("cwd", handle.name); + else mark("adapterConfig", "cwd", handle.name); } catch { // user cancelled or API unsupported } @@ -226,8 +339,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /> ) : ( saveConfig!("promptTemplate", v || undefined)} + value={eff( + "adapterConfig", + "promptTemplate", + String(config.promptTemplate ?? ""), + )} + onCommit={(v) => + mark("adapterConfig", "promptTemplate", v || undefined) + } placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." minRows={4} /> @@ -243,12 +362,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { checked={ isCreate ? val!.dangerouslySkipPermissions - : config.dangerouslySkipPermissions !== false + : eff( + "adapterConfig", + "dangerouslySkipPermissions", + config.dangerouslySkipPermissions !== false, + ) } onChange={(v) => isCreate ? set!({ dangerouslySkipPermissions: v }) - : saveConfig!("dangerouslySkipPermissions", v) + : mark("adapterConfig", "dangerouslySkipPermissions", v) } /> )} @@ -262,20 +385,30 @@ export function AgentConfigForm(props: AgentConfigFormProps) { checked={ isCreate ? val!.dangerouslyBypassSandbox - : config.dangerouslyBypassApprovalsAndSandbox !== false + : eff( + "adapterConfig", + "dangerouslyBypassApprovalsAndSandbox", + config.dangerouslyBypassApprovalsAndSandbox !== false, + ) } onChange={(v) => isCreate ? set!({ dangerouslyBypassSandbox: v }) - : saveConfig!("dangerouslyBypassApprovalsAndSandbox", v) + : mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v) } /> - isCreate ? set!({ search: v }) : saveConfig!("search", v) + isCreate + ? set!({ search: v }) + : mark("adapterConfig", "search", v) } /> @@ -286,9 +419,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { <> - isCreate ? set!({ command: v }) : saveConfig!("command", v || undefined) + isCreate + ? set!({ command: v }) + : mark("adapterConfig", "command", v || undefined) } immediate={isCreate} className={inputClass} @@ -297,11 +436,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { isCreate ? set!({ args: v }) - : saveConfig!( + : mark( + "adapterConfig", "args", v ? v @@ -323,9 +467,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {adapterType === "http" && ( - isCreate ? set!({ url: v }) : saveConfig!("url", v || undefined) + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) } immediate={isCreate} className={inputClass} @@ -334,90 +484,100 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* Advanced adapter section */} + {/* Advanced adapter section — collapsible in both modes */} {isLocal && ( - <> - {isCreate ? ( - setAdapterAdvancedOpen(!adapterAdvancedOpen)} - > -
- set!({ model: v })} - open={modelOpen} - onOpenChange={setModelOpen} + setAdapterAdvancedOpen(!adapterAdvancedOpen)} + > +
+ + isCreate + ? set!({ model: v }) + : mark("adapterConfig", "model", v || undefined) + } + open={modelOpen} + onOpenChange={setModelOpen} + /> + + {isCreate ? ( + set!({ bootstrapPrompt: v })} + minRows={2} /> - - set!({ bootstrapPrompt: v })} - minRows={2} - /> - - {adapterType === "claude_local" && ( - - set!({ maxTurnsPerRun: Number(e.target.value) })} - /> - - )} -
-
- ) : ( - /* Edit mode: show advanced fields inline (no collapse) */ -
-
- Advanced -
- saveConfig!("model", v || undefined)} - open={modelOpen} - onOpenChange={setModelOpen} - /> - + ) : ( saveConfig!("bootstrapPromptTemplate", v || undefined)} + value={eff( + "adapterConfig", + "bootstrapPromptTemplate", + String(config.bootstrapPromptTemplate ?? ""), + )} + onCommit={(v) => + mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) + } placeholder="Optional initial setup prompt for the first run" minRows={2} /> - - {adapterType === "claude_local" && ( - + )} + + {adapterType === "claude_local" && ( + + {isCreate ? ( + set!({ maxTurnsPerRun: Number(e.target.value) })} + /> + ) : ( saveConfig!("maxTurnsPerRun", v || 80)} + value={eff( + "adapterConfig", + "maxTurnsPerRun", + Number(config.maxTurnsPerRun ?? 80), + )} + onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)} + className={inputClass} + /> + )} + + )} + + {/* Edit-only: timeout + grace period */} + {!isCreate && ( + <> + + mark("adapterConfig", "timeoutSec", v)} className={inputClass} /> - )} - - saveConfig!("timeoutSec", v)} - className={inputClass} - /> - - - saveConfig!("graceSec", v)} - className={inputClass} - /> - -
- )} - + + mark("adapterConfig", "graceSec", v)} + className={inputClass} + /> + + + )} +
+
)}
@@ -456,14 +616,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { saveHeartbeat!("enabled", v)} - number={Number(heartbeat.intervalSec ?? 300)} - onNumberChange={(v) => saveHeartbeat!("intervalSec", v)} + checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)} + onCheckedChange={(v) => mark("heartbeat", "enabled", v)} + number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} + onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} - showNumber={heartbeat.enabled !== false} + showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> {/* Edit-only: wake-on-* and cooldown */} @@ -474,25 +634,41 @@ export function AgentConfigForm(props: AgentConfigFormProps) { saveHeartbeat!("wakeOnAssignment", v)} + checked={eff( + "heartbeat", + "wakeOnAssignment", + heartbeat.wakeOnAssignment !== false, + )} + onChange={(v) => mark("heartbeat", "wakeOnAssignment", v)} /> saveHeartbeat!("wakeOnOnDemand", v)} + checked={eff( + "heartbeat", + "wakeOnOnDemand", + heartbeat.wakeOnOnDemand !== false, + )} + onChange={(v) => mark("heartbeat", "wakeOnOnDemand", v)} /> saveHeartbeat!("wakeOnAutomation", v)} + checked={eff( + "heartbeat", + "wakeOnAutomation", + heartbeat.wakeOnAutomation !== false, + )} + onChange={(v) => mark("heartbeat", "wakeOnAutomation", v)} /> saveHeartbeat!("cooldownSec", v)} + value={eff( + "heartbeat", + "cooldownSec", + Number(heartbeat.cooldownSec ?? 10), + )} + onCommit={(v) => mark("heartbeat", "cooldownSec", v)} className={inputClass} /> @@ -513,8 +689,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { saveIdentity!("budgetMonthlyCents", v)} + value={eff( + "runtime", + "budgetMonthlyCents", + props.agent.budgetMonthlyCents, + )} + onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)} className={inputClass} /> diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index 57238eee..cf1bb683 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -15,8 +15,20 @@ export function BreadcrumbBar() { if (breadcrumbs.length === 0) return null; + // Single breadcrumb = page title (uppercase) + if (breadcrumbs.length === 1) { + return ( +
+

+ {breadcrumbs[0].label} +

+
+ ); + } + + // Multiple breadcrumbs = breadcrumb trail return ( -
+
{breadcrumbs.map((crumb, i) => { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 89fdcdbf..2d04285f 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -37,7 +37,7 @@ export function CommentThread({ comments, onAdd }: CommentThreadProps) {
{comments.map((comment) => ( -
+
{comment.authorAgentId ? "Agent" : "Human"} diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 8b6091f2..2e2612ab 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -12,7 +12,7 @@ interface EmptyStateProps { export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { return (
-
+

{message}

diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx index 0fe931ff..96cd449b 100644 --- a/ui/src/components/GoalTree.tsx +++ b/ui/src/components/GoalTree.tsx @@ -25,7 +25,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
onSelect?.(goal)} @@ -75,7 +75,7 @@ export function GoalTree({ goals, onSelect }: GoalTreeProps) { } return ( -
+
{roots.map((goal) => (
-
+
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 57a9c201..aa54f0a4 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -278,7 +278,7 @@ export function OnboardingWizard() { {step === 1 && (
-
+
@@ -317,7 +317,7 @@ export function OnboardingWizard() { {step === 2 && (
-
+
@@ -506,7 +506,7 @@ export function OnboardingWizard() { {step === 3 && (
-
+
@@ -546,7 +546,7 @@ export function OnboardingWizard() { {step === 4 && (
-
+
@@ -556,7 +556,7 @@ export function OnboardingWizard() {

-
+
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 72dd9fb2..56e15f96 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -12,13 +12,14 @@ import { ListTodo, ShieldCheck, Building2, + BookOpen, + Paperclip, } from "lucide-react"; import { CompanySwitcher } from "./CompanySwitcher"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { useDialog } from "../context/DialogContext"; import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; export function Sidebar() { @@ -29,8 +30,15 @@ export function Sidebar() { } return ( -