From 2a15650341ba7a7e22ecbab82d163173a48f02a3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 10:32:50 -0500 Subject: [PATCH 01/32] feat: reorganize agent detail tabs and add Prompts tab Rearrange tabs to: Dashboard, Prompts, Skills, Configuration, Budget. Move Prompt Template out of Configuration into a dedicated Prompts tab with its own save/cancel flow and dirty tracking. Co-Authored-By: Paperclip --- ui/src/components/AgentConfigForm.tsx | 4 +- ui/src/pages/AgentDetail.tsx | 163 +++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c6e48cd0..78684cd8 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -65,6 +65,8 @@ type AgentConfigFormProps = { onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; hideInlineSave?: boolean; + /** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */ + hidePromptTemplate?: boolean; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; } & ( @@ -473,7 +475,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} /> - {isLocal && ( + {isLocal && !props.hidePromptTemplate && ( <> ; } const isPendingApproval = agent.status === "pending_approval"; - const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving); + const showConfigActionBar = (activeView === "configuration" || activeView === "prompts") && (configDirty || configSaving); return (
@@ -861,9 +868,9 @@ export function AgentDetail() { )} + {activeView === "prompts" && ( + + )} + {activeView === "configuration" && (

API Keys

@@ -1339,6 +1358,7 @@ function ConfigurationTab({ onCancelActionChange, onSavingChange, updatePermissions, + hidePromptTemplate, }: { agent: Agent; companyId?: string; @@ -1347,6 +1367,7 @@ function ConfigurationTab({ onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; + hidePromptTemplate?: boolean; }) { const queryClient = useQueryClient(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); @@ -1401,6 +1422,7 @@ function ConfigurationTab({ onSaveActionChange={onSaveActionChange} onCancelActionChange={onCancelActionChange} hideInlineSave + hidePromptTemplate={hidePromptTemplate} sectionLayout="cards" /> @@ -1427,6 +1449,119 @@ function ConfigurationTab({ ); } +/* ---- Prompts Tab ---- */ + +function PromptsTab({ + agent, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, +}: { + agent: Agent; + companyId?: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; +}) { + const queryClient = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const [draft, setDraft] = useState(null); + const [awaitingRefresh, setAwaitingRefresh] = useState(false); + const lastAgentRef = useRef(agent); + + const currentValue = String(agent.adapterConfig?.promptTemplate ?? ""); + const displayValue = draft ?? currentValue; + const isDirty = draft !== null && draft !== currentValue; + + const isLocal = + agent.adapterType === "claude_local" || + agent.adapterType === "codex_local" || + agent.adapterType === "opencode_local" || + agent.adapterType === "pi_local" || + agent.adapterType === "hermes_local" || + agent.adapterType === "cursor"; + + const updateAgent = useMutation({ + mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const uploadMarkdownImage = useMutation({ + mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to upload images"); + return assetsApi.uploadImage(selectedCompanyId, file, namespace); + }, + }); + + useEffect(() => { + if (awaitingRefresh && agent !== lastAgentRef.current) { + setAwaitingRefresh(false); + setDraft(null); + } + lastAgentRef.current = agent; + }, [agent, awaitingRefresh]); + + const isSaving = updateAgent.isPending || awaitingRefresh; + + useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); + useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); + + useEffect(() => { + onSaveActionChange(isDirty ? () => { + updateAgent.mutate({ adapterConfig: { promptTemplate: draft } }); + } : null); + }, [onSaveActionChange, isDirty, draft, updateAgent]); + + useEffect(() => { + onCancelActionChange(isDirty ? () => setDraft(null) : null); + }, [onCancelActionChange, isDirty]); + + if (!isLocal) { + return ( +
+

+ Prompt templates are only available for local adapters. +

+
+ ); + } + + return ( +
+
+

Prompt Template

+
+

+ {help.promptTemplate} +

+ setDraft(v ?? "")} + placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." + contentClassName="min-h-[88px] text-sm font-mono" + imageUploadHandler={async (file) => { + const namespace = `agents/${agent.id}/prompt-template`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> +
+ Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn. +
+
+
+
+ ); +} + function SkillsTab({ agent }: { agent: Agent }) { const instructionsPath = typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0 From cd67bf1d3d39ad44e66be397c202291f0e7c388f Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 11:12:56 -0500 Subject: [PATCH 02/32] Add copy-to-clipboard button on issue detail header Adds a copy icon button to the left of the properties panel toggle on the issue detail page. Clicking it copies a markdown representation of the issue (identifier, title, description) to the clipboard and shows a success toast. The icon briefly switches to a checkmark for visual feedback. Co-Authored-By: Paperclip --- ui/src/pages/IssueDetail.tsx | 49 +++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0eecd014..8e6c206a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -9,6 +9,7 @@ import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; +import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; @@ -36,8 +37,10 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Activity as ActivityIcon, + Check, ChevronDown, ChevronRight, + Copy, EyeOff, Hexagon, ListTree, @@ -196,7 +199,9 @@ export function IssueDetail() { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); + const { pushToast } = useToast(); const [moreOpen, setMoreOpen] = useState(false); + const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("comments"); const [secondaryOpen, setSecondaryOpen] = useState({ @@ -585,6 +590,15 @@ export function IssueDetail() { return () => closePanel(); }, [issue]); // eslint-disable-line react-hooks/exhaustive-deps + const copyIssueToClipboard = async () => { + if (!issue) return; + const md = `# ${issue.identifier}: ${issue.title}\n\n${issue.description ?? ""}`; + await navigator.clipboard.writeText(md); + setCopied(true); + pushToast({ title: "Copied to clipboard", tone: "success" }); + setTimeout(() => setCopied(false), 2000); + }; + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!issue) return null; @@ -737,17 +751,34 @@ export function IssueDetail() {
)} - +
+ + +
+ + +
+ ) : null} +
+ {showResolutionButtons ? ( +
+ + +
+ ) : null} + + ); +} + export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -336,6 +427,10 @@ export function Inbox() { () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], ); + const issuesToRender = useMemo( + () => (tab === "unread" ? unreadTouchedIssues : touchedIssues), + [tab, touchedIssues, unreadTouchedIssues], + ); const agentById = useMemo(() => { const map = new Map(); @@ -363,20 +458,27 @@ export function Inbox() { return ids; }, [heartbeatRuns]); - const allApprovals = useMemo( - () => getApprovalsForTab(approvals ?? [], "recent", "all"), - [approvals], - ); - - const actionableApprovals = useMemo( - () => allApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)), - [allApprovals], - ); - const approvalsToRender = useMemo( () => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter), [approvals, tab, allApprovalFilter], ); + const showJoinRequestsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; + const showTouchedCategory = + allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched"; + const showApprovalsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "approvals"; + const showFailedRunsCategory = + allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; + const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; + const workItemsToRender = useMemo( + () => + getInboxWorkItems({ + issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender, + approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender, + }), + [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab], + ); const agentName = (id: string | null) => { if (!id) return null; @@ -500,33 +602,9 @@ export function Inbox() { !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = touchedIssues.length > 0; - - const showJoinRequestsCategory = - allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; - const showTouchedCategory = - allCategoryFilter === "everything" || allCategoryFilter === "issues_i_touched"; - const showApprovalsCategory = allCategoryFilter === "everything" || allCategoryFilter === "approvals"; - const showFailedRunsCategory = - allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; - const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - - const showTouchedSection = shouldShowInboxSection({ - tab, - hasItems: tab === "unread" ? unreadTouchedIssues.length > 0 : hasTouchedIssues, - showOnRecent: hasTouchedIssues, - showOnUnread: unreadTouchedIssues.length > 0, - showOnAll: showTouchedCategory && hasTouchedIssues, - }); + const showWorkItemsSection = workItemsToRender.length > 0; const showJoinRequestsSection = tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; - const showApprovalsSection = shouldShowInboxSection({ - tab, - hasItems: approvalsToRender.length > 0, - showOnRecent: approvalsToRender.length > 0, - showOnUnread: actionableApprovals.length > 0, - showOnAll: showApprovalsCategory && approvalsToRender.length > 0, - }); const showFailedRunsSection = shouldShowInboxSection({ tab, hasItems: hasRunFailures, @@ -545,9 +623,8 @@ export function Inbox() { const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, - showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, - showTouchedSection ? "issues_i_touched" : null, + showWorkItemsSection ? "work_items" : null, ].filter((key): key is SectionKey => key !== null); const allLoaded = @@ -653,29 +730,72 @@ export function Inbox() { /> )} - {showApprovalsSection && ( + {showWorkItemsSection && ( <> - {showSeparatorBefore("approvals") && } + {showSeparatorBefore("work_items") && }
-

- {tab === "unread" ? "Approvals Needing Action" : "Approvals"} -

-
- {approvalsToRender.map((approval) => ( - a.id === approval.requestedByAgentId) ?? null - : null - } - onApprove={() => approveMutation.mutate(approval.id)} - onReject={() => rejectMutation.mutate(approval.id)} - detailLink={`/approvals/${approval.id}`} - isPending={approveMutation.isPending || rejectMutation.isPending} - /> - ))} +
+ {workItemsToRender.map((item) => { + if (item.kind === "approval") { + return ( + approveMutation.mutate(item.approval.id)} + onReject={() => rejectMutation.mutate(item.approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> + ); + } + + const issue = item.issue; + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + return ( + + + + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={ + issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}` + } + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + trailingMeta={ + issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}` + } + /> + ); + })}
@@ -816,62 +936,6 @@ export function Inbox() { )} - {showTouchedSection && ( - <> - {showSeparatorBefore("issues_i_touched") && } -
-
- {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { - const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); - const isFading = fadingOutIssues.has(issue.id); - return ( - - - - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={ - issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}` - } - unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} - onMarkRead={() => markReadMutation.mutate(issue.id)} - trailingMeta={ - issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}` - } - /> - ); - })} -
-
- - )}
); } From bfb1960703e29cf9d99caa5b61142064bf5ebb60 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 17 Mar 2026 17:12:41 -0500 Subject: [PATCH 23/32] fix: show only 'v' in sidebar with full version on hover tooltip The full version string was pushing the sidebar too wide. Now displays just "v" with the full version (e.g. "v1.2.3") shown on hover via title attribute, for both mobile and desktop sidebar layouts. Fixes PAP-533 Co-Authored-By: Paperclip --- ui/src/components/Layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8d2e3e6f..c60d6880 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -298,7 +298,7 @@ export function Layout() { Documentation {health?.version && ( - v{health.version} + v )}