From c2c63868e9219524cd1cd690eeefbdf9a0591b84 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 20:55:41 -0500 Subject: [PATCH 01/16] Refine issue markdown typography --- ui/src/components/MarkdownBody.tsx | 2 +- ui/src/index.css | 115 +++++++++++++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 2 +- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b996629a..f37d6767 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { return (
:first-child { + margin-top: 0; +} + +.paperclip-markdown > :last-child { + margin-bottom: 0; +} + +.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) { + margin-top: 0.7rem; + margin-bottom: 0.7rem; +} + +.paperclip-markdown :where(ul, ol) { + padding-left: 1.15rem; +} + +.paperclip-markdown ul { + list-style-type: disc; +} + +.paperclip-markdown ol { + list-style-type: decimal; +} + +.paperclip-markdown li { + margin: 0.14rem 0; + padding-left: 0.2rem; +} + +.paperclip-markdown li > :where(p, ul, ol) { + margin-top: 0.3rem; + margin-bottom: 0.3rem; +} + +.paperclip-markdown li::marker { + color: var(--muted-foreground); +} + +.paperclip-markdown :where(h1, h2, h3, h4) { + margin-top: 1.15rem; + margin-bottom: 0.45rem; + color: var(--foreground); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; +} + +.paperclip-markdown h1 { + font-size: 1.5rem; +} + +.paperclip-markdown h2 { + font-size: 1.25rem; +} + +.paperclip-markdown h3 { + font-size: 1.05rem; +} + +.paperclip-markdown h4 { + font-size: 0.95rem; +} + +.paperclip-markdown :where(strong, b) { + color: var(--foreground); + font-weight: 600; +} + +.paperclip-markdown a { + color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); + text-decoration: none; +} + +.paperclip-markdown a:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.dark .paperclip-markdown a { + color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); +} + +.paperclip-markdown blockquote { + margin-left: 0; + padding-left: 0.95rem; + border-left: 0.24rem solid color-mix(in oklab, var(--border) 84%, var(--muted-foreground) 16%); + color: var(--muted-foreground); +} + +.paperclip-markdown hr { + margin: 1.25rem 0; + border-color: var(--border); +} + +.paperclip-markdown img { + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); +} + +.paperclip-markdown table { + width: 100%; +} + +.paperclip-markdown th { + font-weight: 600; + text-align: left; +} + .paperclip-mermaid { margin: 0.5rem 0; padding: 0.45rem 0.55rem; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a0266c16..d9e0a7ef 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -661,7 +661,7 @@ export function IssueDetail() { value={issue.description ?? ""} onSave={(description) => updateIssue.mutate({ description })} as="p" - className="text-sm text-muted-foreground" + className="text-[15px] leading-7 text-foreground" placeholder="Add a description..." multiline mentions={mentionOptions} From 4b49efa02edafc25cfe8164ccc7404572e01223f Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 20:58:18 -0500 Subject: [PATCH 02/16] Smooth agent config save button state --- ui/src/components/AgentConfigForm.tsx | 31 ++++++++++++++++----------- ui/src/components/IssuesList.tsx | 3 +++ ui/src/lib/issueDetailBreadcrumb.ts | 24 +++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 25 +++++++++++++++++---- ui/src/pages/Issues.tsx | 14 +++++++++++- 5 files changed, 80 insertions(+), 17 deletions(-) create mode 100644 ui/src/lib/issueDetailBreadcrumb.ts diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 06cf01d4..103a3cb4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { @@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } /** Build accumulated patch and send to parent */ - function handleSave() { + const handleCancel = useCallback(() => { + setOverlay({ ...emptyOverlay }); + }, []); + + const handleSave = useCallback(() => { if (isCreate || !isDirty) return; const agent = props.agent; const patch: Record = {}; @@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } props.onSave(patch); - } + }, [isCreate, isDirty, overlay, props]); useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); - props.onSaveActionChange?.(() => handleSave()); - props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay })); - return () => { - props.onSaveActionChange?.(null); - props.onCancelActionChange?.(null); - props.onDirtyChange?.(false); - }; + props.onSaveActionChange?.(handleSave); + props.onCancelActionChange?.(handleCancel); } - return; - }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); + + useEffect(() => { + if (isCreate) return; + return () => { + props.onSaveActionChange?.(null); + props.onCancelActionChange?.(null); + props.onDirtyChange?.(false); + }; + }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 10d0709b..481ce933 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -142,6 +142,7 @@ interface IssuesListProps { liveIssueIds?: Set; projectId?: string; viewStateKey: string; + issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; onSearchChange?: (search: string) => void; @@ -156,6 +157,7 @@ export function IssuesList({ liveIssueIds, projectId, viewStateKey, + issueLinkState, initialAssignees, initialSearch, onSearchChange, @@ -591,6 +593,7 @@ export function IssuesList({ {/* Status icon - left column on mobile, inline on desktop */} diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts new file mode 100644 index 00000000..ba330eb3 --- /dev/null +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -0,0 +1,24 @@ +type IssueDetailBreadcrumb = { + label: string; + href: string; +}; + +type IssueDetailLocationState = { + issueDetailBreadcrumb?: IssueDetailBreadcrumb; +}; + +function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return typeof candidate.label === "string" && typeof candidate.href === "string"; +} + +export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { + return { issueDetailBreadcrumb: { label, href } }; +} + +export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { + if (typeof state !== "object" || state === null) return null; + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + return isIssueDetailBreadcrumb(candidate) ? candidate : null; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 3cbb6394..fc97cbc7 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -437,7 +437,7 @@ export function AgentDetail() { return ; } const isPendingApproval = agent.status === "pending_approval"; - const showConfigActionBar = activeView === "configuration" && configDirty; + const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving); return (
@@ -1037,6 +1037,8 @@ function ConfigurationTab({ updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); + const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); + const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ queryKey: @@ -1049,16 +1051,31 @@ function ConfigurationTab({ const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), + onMutate: () => { + setAwaitingRefreshAfterSave(true); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, + onError: () => { + setAwaitingRefreshAfterSave(false); + }, }); useEffect(() => { - onSavingChange(updateAgent.isPending); - }, [onSavingChange, updateAgent.isPending]); + if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { + setAwaitingRefreshAfterSave(false); + } + lastAgentRef.current = agent; + }, [agent, awaitingRefreshAfterSave]); + + const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; + + useEffect(() => { + onSavingChange(isConfigSaving); + }, [onSavingChange, isConfigSaving]); return (
@@ -1066,7 +1083,7 @@ function ConfigurationTab({ mode="edit" agent={agent} onSave={(patch) => updateAgent.mutate(patch)} - isSaving={updateAgent.isPending} + isSaving={isConfigSaving} adapterModels={adapterModels} onDirtyChange={onDirtyChange} onSaveActionChange={onSaveActionChange} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index fce74c7a..5b601e48 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useCallback, useRef } from "react"; -import { useSearchParams } from "@/lib/router"; +import { useLocation, useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; @@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react"; export function Issues() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const location = useLocation(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); @@ -63,6 +65,15 @@ export function Issues() { return ids; }, [liveRuns]); + const issueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Issues", + `${location.pathname}${location.search}${location.hash}`, + ), + [location.pathname, location.search, location.hash], + ); + useEffect(() => { setBreadcrumbs([{ label: "Issues" }]); }, [setBreadcrumbs]); @@ -93,6 +104,7 @@ export function Issues() { agents={agents} liveIssueIds={liveIssueIds} viewStateKey="paperclip:issues-view" + issueLinkState={issueLinkState} initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange} From b5935349edfba0c265b32ebea99bc94712ea7dc1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 20:59:55 -0500 Subject: [PATCH 03/16] Preserve issue breadcrumb source --- ui/src/pages/Inbox.tsx | 14 ++++++++++++++ ui/src/pages/IssueDetail.tsx | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index cc77e0f0..0a3ede64 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -11,6 +11,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; @@ -171,11 +172,13 @@ function FailedRunCard({ run, issueById, agentName: linkedAgentName, + issueLinkState, onDismiss, }: { run: HeartbeatRun; issueById: Map; agentName: string | null; + issueLinkState: unknown; onDismiss: () => void; }) { const queryClient = useQueryClient(); @@ -227,6 +230,7 @@ function FailedRunCard({ {issue ? ( @@ -315,6 +319,14 @@ export function Inbox() { const pathSegment = location.pathname.split("/").pop() ?? "new"; const tab: InboxTab = pathSegment === "all" ? "all" : "new"; + const issueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Inbox", + `${location.pathname}${location.search}${location.hash}`, + ), + [location.pathname, location.search, location.hash], + ); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -749,6 +761,7 @@ export function Inbox() { run={run} issueById={issueById} agentName={agentName(run.agentId)} + issueLinkState={issueLinkState} onDismiss={() => dismiss(`run:${run.id}`)} /> ))} @@ -890,6 +903,7 @@ export function Inbox() { {/* Status icon - left column on mobile, inline on desktop */} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d9e0a7ef..d10a8114 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; -import { useParams, Link, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { activityApi } from "../api/activity"; @@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; @@ -150,6 +151,7 @@ export function IssueDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); const [moreOpen, setMoreOpen] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("comments"); @@ -213,6 +215,10 @@ export function IssueDetail() { }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; + const sourceBreadcrumb = useMemo( + () => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" }, + [location.state], + ); // Filter out runs already shown by the live widget to avoid duplication const timelineRuns = useMemo(() => { @@ -468,17 +474,17 @@ export function IssueDetail() { useEffect(() => { const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ - { label: "Issues", href: "/issues" }, + sourceBreadcrumb, { label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel }, ]); - }, [setBreadcrumbs, issue, issueId, hasLiveRuns]); + }, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]); // Redirect to identifier-based URL if navigated via UUID useEffect(() => { if (issue?.identifier && issueId !== issue.identifier) { - navigate(`/issues/${issue.identifier}`, { replace: true }); + navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state }); } - }, [issue, issueId, navigate]); + }, [issue, issueId, navigate, location.state]); useEffect(() => { if (!issue?.id) return; @@ -524,6 +530,7 @@ export function IssueDetail() { {i > 0 && } @@ -800,6 +807,7 @@ export function IssueDetail() {
From 3273692944b359c213c0baa1d76af172cf1f44fc Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:01:47 -0500 Subject: [PATCH 04/16] Fix markdown link dialog positioning --- ui/src/components/MarkdownEditor.tsx | 1 - ui/src/index.css | 30 ++++++++++++---------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 85b67c32..372b8a4d 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -566,7 +566,6 @@ export const MarkdownEditor = forwardRef "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", contentClassName, )} - overlayContainer={containerRef.current} plugins={plugins} /> diff --git a/ui/src/index.css b/ui/src/index.css index a578f5d3..aab6536a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -591,25 +591,21 @@ a.paperclip-project-mention-chip { white-space: nowrap; } -/* Keep MDXEditor popups above app dialogs when editor is inside a modal. */ -.paperclip-mdxeditor-scope [class*="_dialogOverlay_"], -.paperclip-mdxeditor [class*="_dialogOverlay_"] { +/* Keep MDXEditor popups above app dialogs, even when they portal to . */ +[class*="_popupContainer_"] { + z-index: 81 !important; +} + +[class*="_dialogOverlay_"] { z-index: 80; } -.paperclip-mdxeditor-scope [class*="_dialogContent_"], -.paperclip-mdxeditor-scope [class*="_largeDialogContent_"], -.paperclip-mdxeditor-scope [class*="_popoverContent_"], -.paperclip-mdxeditor-scope [class*="_linkDialogPopoverContent_"], -.paperclip-mdxeditor-scope [class*="_tableColumnEditorPopoverContent_"], -.paperclip-mdxeditor-scope [class*="_toolbarButtonDropdownContainer_"], -.paperclip-mdxeditor-scope [class*="_toolbarNodeKindSelectContainer_"], -.paperclip-mdxeditor [class*="_dialogContent_"], -.paperclip-mdxeditor [class*="_largeDialogContent_"], -.paperclip-mdxeditor [class*="_popoverContent_"], -.paperclip-mdxeditor [class*="_linkDialogPopoverContent_"], -.paperclip-mdxeditor [class*="_tableColumnEditorPopoverContent_"], -.paperclip-mdxeditor [class*="_toolbarButtonDropdownContainer_"], -.paperclip-mdxeditor [class*="_toolbarNodeKindSelectContainer_"] { +[class*="_dialogContent_"], +[class*="_largeDialogContent_"], +[class*="_popoverContent_"], +[class*="_linkDialogPopoverContent_"], +[class*="_tableColumnEditorPopoverContent_"], +[class*="_toolbarButtonDropdownContainer_"], +[class*="_toolbarNodeKindSelectContainer_"] { z-index: 81 !important; } From 183d71eb7c27bc96ad747d90e47543d89cd56333 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:06:10 -0500 Subject: [PATCH 05/16] Restore native mobile page scrolling --- ui/src/components/Layout.tsx | 82 ++++++++++++++++++++-------- ui/src/components/ScrollToBottom.tsx | 61 +++++++++++++++++---- 2 files changed, 109 insertions(+), 34 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 3a58e614..9af35ada 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; @@ -177,28 +177,56 @@ export function Layout() { }; }, [isMobile, sidebarOpen, setSidebarOpen]); - const handleMainScroll = useCallback( - (event: UIEvent) => { - if (!isMobile) return; + const updateMobileNavVisibility = useCallback((currentTop: number) => { + const delta = currentTop - lastMainScrollTop.current; - const currentTop = event.currentTarget.scrollTop; - const delta = currentTop - lastMainScrollTop.current; + if (currentTop <= 24) { + setMobileNavVisible(true); + } else if (delta > 8) { + setMobileNavVisible(false); + } else if (delta < -8) { + setMobileNavVisible(true); + } - if (currentTop <= 24) { - setMobileNavVisible(true); - } else if (delta > 8) { - setMobileNavVisible(false); - } else if (delta < -8) { - setMobileNavVisible(true); - } + lastMainScrollTop.current = currentTop; + }, []); - lastMainScrollTop.current = currentTop; - }, - [isMobile], - ); + useEffect(() => { + if (!isMobile) { + setMobileNavVisible(true); + lastMainScrollTop.current = 0; + return; + } + + const onScroll = () => { + updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0); + }; + + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + + return () => { + window.removeEventListener("scroll", onScroll); + }; + }, [isMobile, updateMobileNavVisibility]); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + + document.body.style.overflow = isMobile ? "visible" : "hidden"; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isMobile]); return ( -
+
- -
+
+
+ +
+
{hasUnknownCompanyPrefix ? ( mainContent.clientHeight + 1; + + if (usesOwnScroll) { + return { type: "element" as const, element: mainContent }; + } + } + + return { type: "window" as const }; +} + +function distanceFromBottom(target: ReturnType) { + if (target.type === "element") { + return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight; + } + + const scroller = document.scrollingElement ?? document.documentElement; + return scroller.scrollHeight - window.scrollY - window.innerHeight; +} + /** - * Floating scroll-to-bottom button that appears when the user is far from the - * bottom of the `#main-content` scroll container. Hides when within 300px of - * the bottom. Positioned to avoid the mobile bottom nav. + * Floating scroll-to-bottom button that follows the active page scroller. + * On desktop that is `#main-content`; on mobile it falls back to window/page scroll. */ export function ScrollToBottom() { const [visible, setVisible] = useState(false); useEffect(() => { - const el = document.getElementById("main-content"); - if (!el) return; const check = () => { - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - setVisible(distance > 300); + setVisible(distanceFromBottom(resolveScrollTarget()) > 300); }; + + const mainContent = document.getElementById("main-content"); + check(); - el.addEventListener("scroll", check, { passive: true }); - return () => el.removeEventListener("scroll", check); + mainContent?.addEventListener("scroll", check, { passive: true }); + window.addEventListener("scroll", check, { passive: true }); + window.addEventListener("resize", check); + + return () => { + mainContent?.removeEventListener("scroll", check); + window.removeEventListener("scroll", check); + window.removeEventListener("resize", check); + }; }, []); const scroll = useCallback(() => { - const el = document.getElementById("main-content"); - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + const target = resolveScrollTarget(); + + if (target.type === "element") { + target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" }); + return; + } + + const scroller = document.scrollingElement ?? document.documentElement; + window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }, []); if (!visible) return null; From d3ac8722bee80e5316c773187efd4838dcb7b95e Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:06:15 -0500 Subject: [PATCH 06/16] Add agent runs tab to detail page --- ui/src/pages/AgentDetail.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index fc97cbc7..5f721644 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -311,7 +311,12 @@ export function AgentDetail() { } return; } - const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard"; + const canonicalTab = + activeView === "configuration" + ? "configuration" + : activeView === "runs" + ? "runs" + : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); return; @@ -558,15 +563,16 @@ export function AgentDetail() { {!urlRunId && ( navigate(`/agents/${canonicalAgentRef}/${value}`)} > navigate(`/agents/${canonicalAgentRef}/${value}`)} /> From 5f76d03913b4ee93494eb0244b83115a3913d72c Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:06:16 -0500 Subject: [PATCH 07/16] ui: smooth new issue submit state --- ui/src/components/NewIssueDialog.tsx | 57 +++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 9a9076bc..108e282b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -34,6 +34,7 @@ import { Tag, Calendar, Paperclip, + Loader2, } from "lucide-react"; import { cn } from "../lib/utils"; import { extractProviderIdWithFallback } from "../lib/model-utils"; @@ -420,7 +421,7 @@ export function NewIssueDialog() { } function handleSubmit() { - if (!effectiveCompanyId || !title.trim()) return; + if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return; const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, modelOverride: assigneeModelOverride, @@ -516,6 +517,11 @@ export function NewIssueDialog() { })), [orderedProjects], ); + const savedDraft = loadDraft(); + const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim()); + const canDiscardDraft = hasDraft || hasSavedDraft; + const createIssueErrorMessage = + createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again."; const handleProjectChange = useCallback((nextProjectId: string) => { setProjectId(nextProjectId); @@ -563,7 +569,7 @@ export function NewIssueDialog() { { - if (!open) closeNewIssue(); + if (!open && !createIssue.isPending) closeNewIssue(); }} > { + if (createIssue.isPending) { + event.preventDefault(); + } + }} onPointerDownOutside={(event) => { + if (createIssue.isPending) { + event.preventDefault(); + return; + } // Radix Dialog's modal DismissableLayer calls preventDefault() on // pointerdown events that originate outside the Dialog DOM tree. // Popover portals render at the body level (outside the Dialog), so @@ -654,6 +669,7 @@ export function NewIssueDialog() { size="icon-xs" className="text-muted-foreground" onClick={() => setExpanded(!expanded)} + disabled={createIssue.isPending} > {expanded ? : } @@ -662,6 +678,7 @@ export function NewIssueDialog() { size="icon-xs" className="text-muted-foreground" onClick={() => closeNewIssue()} + disabled={createIssue.isPending} > × @@ -680,6 +697,7 @@ export function NewIssueDialog() { e.target.style.height = "auto"; e.target.style.height = `${e.target.scrollHeight}px`; }} + readOnly={createIssue.isPending} onKeyDown={(e) => { if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); @@ -998,17 +1016,36 @@ export function NewIssueDialog() { size="sm" className="text-muted-foreground" onClick={discardDraft} - disabled={!hasDraft && !loadDraft()} + disabled={createIssue.isPending || !canDiscardDraft} > Discard Draft - +
+
+ {createIssue.isPending ? ( + + + Creating issue... + + ) : createIssue.isError ? ( + {createIssueErrorMessage} + ) : canDiscardDraft ? ( + Draft autosaves locally + ) : null} +
+ +
From 92aef9bae842c6231966026487d3e683cd529807 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 21:16:33 -0500 Subject: [PATCH 08/16] Slim heartbeat run list payloads --- server/src/routes/agents.ts | 11 ++++++ server/src/services/heartbeat.ts | 67 +++++++++++++++++++++++++++++++- ui/src/api/heartbeats.ts | 1 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.tsx | 8 +++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d150bb10..c27b893a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) { res.json(liveRuns); }); + router.get("/heartbeat-runs/:runId", async (req, res) => { + const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.status(404).json({ error: "Heartbeat run not found" }); + return; + } + assertCompanyAccess(req, run.companyId); + res.json(run); + }); + router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index daa3ac69..af0952ac 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -46,6 +46,69 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; +const summarizedHeartbeatRunResultJson = sql | null>` + CASE + WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL + ELSE NULLIF( + jsonb_strip_nulls( + jsonb_build_object( + 'summary', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500) + END, + 'result', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500) + END, + 'message', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500) + END, + 'error', CASE + WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL + ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500) + END, + 'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd', + 'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd', + 'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd' + ) + ), + '{}'::jsonb + ) + END +`; + +const heartbeatRunListColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + status: heartbeatRuns.status, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + error: heartbeatRuns.error, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + exitCode: heartbeatRuns.exitCode, + signal: heartbeatRuns.signal, + usageJson: heartbeatRuns.usageJson, + resultJson: summarizedHeartbeatRunResultJson.as("resultJson"), + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, + logBytes: heartbeatRuns.logBytes, + logSha256: heartbeatRuns.logSha256, + logCompressed: heartbeatRuns.logCompressed, + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), + errorCode: heartbeatRuns.errorCode, + externalRunId: heartbeatRuns.externalRunId, + contextSnapshot: heartbeatRuns.contextSnapshot, + createdAt: heartbeatRuns.createdAt, + updatedAt: heartbeatRuns.updatedAt, +} as const; + function appendExcerpt(prev: string, chunk: string) { return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); } @@ -2260,9 +2323,9 @@ export function heartbeatService(db: Db) { } return { - list: (companyId: string, agentId?: string, limit?: number) => { + list: async (companyId: string, agentId?: string, limit?: number) => { const query = db - .select() + .select(heartbeatRunListColumns) .from(heartbeatRuns) .where( agentId diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 680412da..b579a65d 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -29,6 +29,7 @@ export const heartbeatsApi = { const qs = searchParams.toString(); return api.get(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`); }, + get: (runId: string) => api.get(`/heartbeat-runs/${runId}`), events: (runId: string, afterSeq = 0, limit = 200) => api.get( `/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 34791488..ff73701f 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -68,6 +68,7 @@ export const queryKeys = { ["costs", companyId, from, to] as const, heartbeats: (companyId: string, agentId?: string) => ["heartbeats", companyId, agentId] as const, + runDetail: (runId: string) => ["heartbeat-run", runId] as const, liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 5f721644..b5571255 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1254,9 +1254,15 @@ function RunsTab({ /* ---- Run Detail (expanded) ---- */ -function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { +function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { const queryClient = useQueryClient(); const navigate = useNavigate(); + const { data: hydratedRun } = useQuery({ + queryKey: queryKeys.runDetail(initialRun.id), + queryFn: () => heartbeatsApi.get(initialRun.id), + enabled: Boolean(initialRun.id), + }); + const run = hydratedRun ?? initialRun; const metrics = runMetrics(run); const [sessionOpen, setSessionOpen] = useState(false); const [claudeLoginResult, setClaudeLoginResult] = useState(null); From 21d2b075e7cde134cdb9ce7f513d5cb26a75b738 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 22:55:45 -0500 Subject: [PATCH 09/16] Fix inbox badge logic and landing view --- packages/shared/src/types/dashboard.ts | 1 - server/src/routes/sidebar-badges.ts | 5 +- server/src/services/dashboard.ts | 14 -- server/src/services/issues.ts | 18 --- ui/src/App.tsx | 2 +- ui/src/components/CommandPalette.tsx | 2 +- ui/src/components/MobileBottomNav.tsx | 17 +-- ui/src/components/Sidebar.tsx | 16 +- ui/src/hooks/useInboxBadge.ts | 101 +++++++++++++ ui/src/lib/inbox.test.ts | 195 +++++++++++++++++++++++++ ui/src/lib/inbox.ts | 121 +++++++++++++++ ui/src/pages/Dashboard.tsx | 2 +- ui/src/pages/Inbox.tsx | 187 +++--------------------- ui/vitest.config.ts | 2 +- 14 files changed, 453 insertions(+), 230 deletions(-) create mode 100644 ui/src/hooks/useInboxBadge.ts create mode 100644 ui/src/lib/inbox.test.ts create mode 100644 ui/src/lib/inbox.ts diff --git a/packages/shared/src/types/dashboard.ts b/packages/shared/src/types/dashboard.ts index 514d0ee4..8350589d 100644 --- a/packages/shared/src/types/dashboard.ts +++ b/packages/shared/src/types/dashboard.ts @@ -18,5 +18,4 @@ export interface DashboardSummary { monthUtilizationPercent: number; }; pendingApprovals: number; - staleTasks: number; } diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 0cd302e5..03cb4cb0 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db"; import { and, eq, sql } from "drizzle-orm"; import { joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; -import { issueService } from "../services/issues.js"; import { accessService } from "../services/access.js"; import { dashboardService } from "../services/dashboard.js"; import { assertCompanyAccess } from "./authz.js"; @@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js"; export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); - const issueSvc = issueService(db); const access = accessService(db); const dashboard = dashboardService(db); @@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) { joinRequests: joinRequestCount, }); const summary = await dashboard.summary(companyId); - const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60); const hasFailedRuns = badges.failedRuns > 0; const alertsCount = (summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) + (summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0); - badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals; + badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals; res.json(badges); }); diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index cf7f32cf..991c9c61 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -32,19 +32,6 @@ export function dashboardService(db: Db) { .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending"))) .then((rows) => Number(rows[0]?.count ?? 0)); - const staleCutoff = new Date(Date.now() - 60 * 60 * 1000); - const staleTasks = await db - .select({ count: sql`count(*)` }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.status, "in_progress"), - sql`${issues.startedAt} < ${staleCutoff.toISOString()}`, - ), - ) - .then((rows) => Number(rows[0]?.count ?? 0)); - const agentCounts: Record = { active: 0, running: 0, @@ -107,7 +94,6 @@ export function dashboardService(db: Db) { monthUtilizationPercent: Number(utilization.toFixed(2)), }, pendingApprovals, - staleTasks, }; }, }; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f875ea53..a25d21fc 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1411,23 +1411,5 @@ export function issueService(db: Db) { goal: a.goalId ? goalMap.get(a.goalId) ?? null : null, })); }, - - staleCount: async (companyId: string, minutes = 60) => { - const cutoff = new Date(Date.now() - minutes * 60 * 1000); - const result = await db - .select({ count: sql`count(*)` }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.status, "in_progress"), - isNull(issues.hiddenAt), - sql`${issues.startedAt} < ${cutoff.toISOString()}`, - ), - ) - .then((rows) => rows[0]); - - return Number(result?.count ?? 0); - }, }; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d47af808..8f1b3ef9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -138,7 +138,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 3defb0e6..04b4b035 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -142,7 +142,7 @@ export function CommandPalette() { Dashboard - go("/inbox")}> + go("/inbox/all")}> Inbox diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index e9e5c150..7ba83b89 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { House, CircleDot, @@ -8,11 +7,10 @@ import { Users, Inbox, } from "lucide-react"; -import { sidebarBadgesApi } from "../api/sidebarBadges"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; -import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; +import { useInboxBadge } from "../hooks/useInboxBadge"; interface MobileBottomNavProps { visible: boolean; @@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { const location = useLocation(); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); - - const { data: sidebarBadges } = useQuery({ - queryKey: queryKeys.sidebarBadges(selectedCompanyId!), - queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); + const inboxBadge = useInboxBadge(selectedCompanyId); const items = useMemo( () => [ @@ -54,13 +47,13 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { { type: "link", to: "/agents/all", label: "Agents", icon: Users }, { type: "link", - to: "/inbox", + to: "/inbox/all", label: "Inbox", icon: Inbox, - badge: sidebarBadges?.inbox, + badge: inboxBadge.inbox, }, ], - [openNewIssue, sidebarBadges?.inbox], + [openNewIssue, inboxBadge.inbox], ); return ( diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index ae5e83d4..684c878a 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects"; import { SidebarAgents } from "./SidebarAgents"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { sidebarBadgesApi } from "../api/sidebarBadges"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; +import { useInboxBadge } from "../hooks/useInboxBadge"; import { Button } from "@/components/ui/button"; export function Sidebar() { const { openNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); - const { data: sidebarBadges } = useQuery({ - queryKey: queryKeys.sidebarBadges(selectedCompanyId!), - queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); + const inboxBadge = useInboxBadge(selectedCompanyId); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), @@ -77,12 +73,12 @@ export function Sidebar() { 0} + badge={inboxBadge.inbox} + badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"} + alert={inboxBadge.failedRuns > 0} />
diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts new file mode 100644 index 00000000..b004850e --- /dev/null +++ b/ui/src/hooks/useInboxBadge.ts @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "../api/access"; +import { ApiError } from "../api/client"; +import { approvalsApi } from "../api/approvals"; +import { dashboardApi } from "../api/dashboard"; +import { heartbeatsApi } from "../api/heartbeats"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { + computeInboxBadgeData, + loadDismissedInboxItems, + saveDismissedInboxItems, +} from "../lib/inbox"; + +const TOUCHED_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; + +export function useDismissedInboxItems() { + const [dismissed, setDismissed] = useState>(loadDismissedInboxItems); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key !== "paperclip:inbox:dismissed") return; + setDismissed(loadDismissedInboxItems()); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const dismiss = (id: string) => { + setDismissed((prev) => { + const next = new Set(prev); + next.add(id); + saveDismissedInboxItems(next); + return next; + }); + }; + + return { dismissed, dismiss }; +} + +export function useInboxBadge(companyId: string | null | undefined) { + const { dismissed } = useDismissedInboxItems(); + + const { data: approvals = [] } = useQuery({ + queryKey: queryKeys.approvals.list(companyId!), + queryFn: () => approvalsApi.list(companyId!), + enabled: !!companyId, + }); + + const { data: joinRequests = [] } = useQuery({ + queryKey: queryKeys.access.joinRequests(companyId!), + queryFn: async () => { + try { + return await accessApi.listJoinRequests(companyId!, "pending_approval"); + } catch (err) { + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + return []; + } + throw err; + } + }, + enabled: !!companyId, + retry: false, + }); + + const { data: dashboard } = useQuery({ + queryKey: queryKeys.dashboard(companyId!), + queryFn: () => dashboardApi.summary(companyId!), + enabled: !!companyId, + }); + + const { data: touchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.listTouchedByMe(companyId!), + queryFn: () => + issuesApi.list(companyId!, { + touchedByUserId: "me", + status: TOUCHED_ISSUE_STATUSES, + }), + enabled: !!companyId, + }); + + const { data: heartbeatRuns = [] } = useQuery({ + queryKey: queryKeys.heartbeats(companyId!), + queryFn: () => heartbeatsApi.list(companyId!), + enabled: !!companyId, + }); + + return useMemo( + () => + computeInboxBadgeData({ + approvals, + joinRequests, + dashboard, + heartbeatRuns, + touchedIssues, + dismissed, + }), + [approvals, joinRequests, dashboard, heartbeatRuns, touchedIssues, dismissed], + ); +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts new file mode 100644 index 00000000..10183cf2 --- /dev/null +++ b/ui/src/lib/inbox.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; +import { computeInboxBadgeData, getUnreadTouchedIssues } from "./inbox"; + +function makeApproval(status: Approval["status"]): Approval { + return { + id: `approval-${status}`, + companyId: "company-1", + type: "hire_agent", + requestedByAgentId: null, + requestedByUserId: null, + status, + payload: {}, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + }; +} + +function makeJoinRequest(id: string): JoinRequest { + return { + id, + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestEmailSnapshot: null, + requestIp: "127.0.0.1", + requestingUserId: null, + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + }; +} + +function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun { + return { + id, + companyId: "company-1", + agentId, + invocationSource: "assignment", + triggerDetail: null, + status, + error: null, + wakeupRequestId: null, + exitCode: null, + signal: null, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + errorCode: null, + externalRunId: null, + stdoutExcerpt: null, + stderrExcerpt: null, + contextSnapshot: null, + startedAt: new Date(createdAt), + finishedAt: null, + createdAt: new Date(createdAt), + updatedAt: new Date(createdAt), + }; +} + +function makeIssue(id: string, isUnreadForMe: boolean): Issue { + return { + id, + companyId: "company-1", + projectId: null, + goalId: null, + parentId: null, + title: `Issue ${id}`, + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: `PAP-${id}`, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"), + lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"), + isUnreadForMe, + }; +} + +const dashboard: DashboardSummary = { + companyId: "company-1", + agents: { + active: 1, + running: 0, + paused: 0, + error: 1, + }, + tasks: { + open: 1, + inProgress: 0, + blocked: 0, + done: 0, + }, + costs: { + monthSpendCents: 900, + monthBudgetCents: 1000, + monthUtilizationPercent: 90, + }, + pendingApprovals: 1, +}; + +describe("inbox helpers", () => { + it("counts the same inbox sources the badge uses", () => { + const result = computeInboxBadgeData({ + approvals: [makeApproval("pending"), makeApproval("approved")], + joinRequests: [makeJoinRequest("join-1")], + dashboard, + heartbeatRuns: [ + makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"), + makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"), + makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), + ], + touchedIssues: [makeIssue("1", true), makeIssue("2", false)], + dismissed: new Set(), + }); + + expect(result).toEqual({ + inbox: 6, + approvals: 1, + failedRuns: 2, + joinRequests: 1, + unreadTouchedIssues: 1, + alerts: 1, + }); + }); + + it("drops dismissed runs and alerts from the computed badge", () => { + const result = computeInboxBadgeData({ + approvals: [], + joinRequests: [], + dashboard, + heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], + touchedIssues: [], + dismissed: new Set(["run:run-1", "alert:budget", "alert:agent-errors"]), + }); + + expect(result).toEqual({ + inbox: 0, + approvals: 0, + failedRuns: 0, + joinRequests: 0, + unreadTouchedIssues: 0, + alerts: 0, + }); + }); + + it("keeps read issues in the touched list but excludes them from unread counts", () => { + const issues = [makeIssue("1", true), makeIssue("2", false)]; + + expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]); + expect(issues).toHaveLength(2); + }); +}); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts new file mode 100644 index 00000000..3ae9f9a9 --- /dev/null +++ b/ui/src/lib/inbox.ts @@ -0,0 +1,121 @@ +import type { + Approval, + DashboardSummary, + HeartbeatRun, + Issue, + JoinRequest, +} from "@paperclipai/shared"; + +export const RECENT_ISSUES_LIMIT = 100; +export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); +export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); +export const DISMISSED_KEY = "paperclip:inbox:dismissed"; + +export interface InboxBadgeData { + inbox: number; + approvals: number; + failedRuns: number; + joinRequests: number; + unreadTouchedIssues: number; + alerts: number; +} + +export function loadDismissedInboxItems(): Set { + try { + const raw = localStorage.getItem(DISMISSED_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +export function saveDismissedInboxItems(ids: Set) { + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); +} + +export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { + const sorted = [...runs].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + const latestByAgent = new Map(); + + for (const run of sorted) { + if (!latestByAgent.has(run.agentId)) { + latestByAgent.set(run.agentId, run); + } + } + + return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status)); +} + +export function normalizeTimestamp(value: string | Date | null | undefined): number { + if (!value) return 0; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function issueLastActivityTimestamp(issue: Issue): number { + const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt); + if (lastExternalCommentAt > 0) return lastExternalCommentAt; + + const updatedAt = normalizeTimestamp(issue.updatedAt); + const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt); + if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0; + + return updatedAt; +} + +export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number { + const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a); + if (activityDiff !== 0) return activityDiff; + return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt); +} + +export function getUnreadTouchedIssues(issues: Issue[]): Issue[] { + return issues.filter((issue) => issue.isUnreadForMe); +} + +export function computeInboxBadgeData({ + approvals, + joinRequests, + dashboard, + heartbeatRuns, + touchedIssues, + dismissed, +}: { + approvals: Approval[]; + joinRequests: JoinRequest[]; + dashboard: DashboardSummary | undefined; + heartbeatRuns: HeartbeatRun[]; + touchedIssues: Issue[]; + dismissed: Set; +}): InboxBadgeData { + const actionableApprovals = approvals.filter((approval) => + ACTIONABLE_APPROVAL_STATUSES.has(approval.status), + ).length; + const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( + (run) => !dismissed.has(`run:${run.id}`), + ).length; + const unreadTouchedIssues = getUnreadTouchedIssues(touchedIssues).length; + const agentErrorCount = dashboard?.agents.error ?? 0; + const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; + const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; + const showAggregateAgentError = + agentErrorCount > 0 && + failedRuns === 0 && + !dismissed.has("alert:agent-errors"); + const showBudgetAlert = + monthBudgetCents > 0 && + monthUtilizationPercent >= 80 && + !dismissed.has("alert:budget"); + const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert); + + return { + inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts, + approvals: actionableApprovals, + failedRuns, + joinRequests: joinRequests.length, + unreadTouchedIssues, + alerts, + }; +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e1f9b9b0..051fc053 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -255,7 +255,7 @@ export function Dashboard() { to="/approvals" description={ - {data.staleTasks} stale tasks + Awaiting board review } /> diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 0a3ede64..3858e88f 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -32,7 +32,6 @@ import { import { Inbox as InboxIcon, AlertTriangle, - Clock, ArrowUpRight, XCircle, X, @@ -41,11 +40,14 @@ import { import { Identity } from "../components/Identity"; import { PageTabBar } from "../components/PageTabBar"; import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; - -const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours -const RECENT_ISSUES_LIMIT = 100; -const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); -const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); +import { + ACTIONABLE_APPROVAL_STATUSES, + getLatestFailedRunsByAgent, + normalizeTimestamp, + RECENT_ISSUES_LIMIT, + sortIssuesByMostRecentActivity, +} from "../lib/inbox"; +import { useDismissedInboxItems } from "../hooks/useInboxBadge"; type InboxTab = "new" | "all"; type InboxCategoryFilter = @@ -54,46 +56,14 @@ type InboxCategoryFilter = | "join_requests" | "approvals" | "failed_runs" - | "alerts" - | "stale_work"; + | "alerts"; type InboxApprovalFilter = "all" | "actionable" | "resolved"; type SectionKey = | "issues_i_touched" | "join_requests" | "approvals" | "failed_runs" - | "alerts" - | "stale_work"; - -const DISMISSED_KEY = "paperclip:inbox:dismissed"; - -function loadDismissed(): Set { - try { - const raw = localStorage.getItem(DISMISSED_KEY); - return raw ? new Set(JSON.parse(raw)) : new Set(); - } catch { - return new Set(); - } -} - -function saveDismissed(ids: Set) { - localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); -} - -function useDismissedItems() { - const [dismissed, setDismissed] = useState>(loadDismissed); - - const dismiss = useCallback((id: string) => { - setDismissed((prev) => { - const next = new Set(prev); - next.add(id); - saveDismissed(next); - return next; - }); - }, []); - - return { dismissed, dismiss }; -} + | "alerts"; const RUN_SOURCE_LABELS: Record = { timer: "Scheduled", @@ -102,32 +72,6 @@ const RUN_SOURCE_LABELS: Record = { automation: "Automation", }; -function getStaleIssues(issues: Issue[]): Issue[] { - const now = Date.now(); - return issues - .filter( - (i) => - ["in_progress", "todo"].includes(i.status) && - now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS, - ) - .sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); -} - -function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { - const sorted = [...runs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - const latestByAgent = new Map(); - - for (const run of sorted) { - if (!latestByAgent.has(run.agentId)) { - latestByAgent.set(run.agentId, run); - } - } - - return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status)); -} - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -138,23 +82,6 @@ function runFailureMessage(run: HeartbeatRun): string { return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error."; } -function normalizeTimestamp(value: string | Date | null | undefined): number { - if (!value) return 0; - const timestamp = new Date(value).getTime(); - return Number.isFinite(timestamp) ? timestamp : 0; -} - -function issueLastActivityTimestamp(issue: Issue): number { - const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt); - if (lastExternalCommentAt > 0) return lastExternalCommentAt; - - const updatedAt = normalizeTimestamp(issue.updatedAt); - const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt); - if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0; - - return updatedAt; -} - function readIssueIdFromRun(run: HeartbeatRun): string | null { const context = run.contextSnapshot; if (!context) return null; @@ -315,7 +242,7 @@ export function Inbox() { const [actionError, setActionError] = useState(null); const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); const [allApprovalFilter, setAllApprovalFilter] = useState("all"); - const { dismissed, dismiss } = useDismissedItems(); + const { dismissed, dismiss } = useDismissedInboxItems(); const pathSegment = location.pathname.split("/").pop() ?? "new"; const tab: InboxTab = pathSegment === "all" ? "all" : "new"; @@ -397,22 +324,13 @@ export function Inbox() { enabled: !!selectedCompanyId, }); - const staleIssues = useMemo( - () => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)), - [issues, dismissed], - ); - const sortByMostRecentActivity = useCallback( - (a: Issue, b: Issue) => { - const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a); - if (activityDiff !== 0) return activityDiff; - return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt); - }, - [], - ); - const touchedIssues = useMemo( - () => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), - [sortByMostRecentActivity, touchedIssuesRaw], + () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), + [touchedIssuesRaw], + ); + const unreadTouchedIssues = useMemo( + () => touchedIssues.filter((issue) => issue.isUnreadForMe), + [touchedIssues], ); const agentById = useMemo(() => { @@ -547,9 +465,8 @@ export function Inbox() { dashboard.costs.monthUtilizationPercent >= 80 && !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; - const hasStale = staleIssues.length > 0; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = touchedIssues.length > 0; + const hasTouchedIssues = unreadTouchedIssues.length > 0; const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; @@ -559,7 +476,6 @@ export function Inbox() { const showFailedRunsCategory = allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work"; const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues; @@ -572,12 +488,10 @@ export function Inbox() { const showFailedRunsSection = tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; - const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, showAlertsSection ? "alerts" : null, - showStaleSection ? "stale_work" : null, showApprovalsSection ? "approvals" : null, showJoinRequestsSection ? "join_requests" : null, showTouchedSection ? "issues_i_touched" : null, @@ -624,7 +538,6 @@ export function Inbox() { Approvals Failed runs Alerts - Stale work @@ -659,7 +572,7 @@ export function Inbox() { icon={InboxIcon} message={ tab === "new" - ? "No issues you're involved in yet." + ? "No new inbox items." : "No inbox items match these filters." } /> @@ -828,66 +741,6 @@ export function Inbox() { )} - {showStaleSection && ( - <> - {showSeparatorBefore("stale_work") && } -
-

- Stale Work -

-
- {staleIssues.map((issue) => ( -
- {/* Status icon - left column on mobile; Clock icon on desktop */} - - - - - - - - {issue.title} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : null; - })()} - · - - updated {timeAgo(issue.updatedAt)} - - - - -
- ))} -
-
- - )} - {showTouchedSection && ( <> {showSeparatorBefore("issues_i_touched") && } @@ -896,7 +749,7 @@ export function Inbox() { My Recent Issues
- {touchedIssues.map((issue) => { + {(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 9f6250a3..f624398e 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: "jsdom", + environment: "node", }, }); From a503d2c12ceb65b5a2635e930724863b14bcaf51 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 07:42:19 -0500 Subject: [PATCH 10/16] Adjust inbox tab memory and badge counts --- ui/src/App.tsx | 7 ++++- ui/src/components/CommandPalette.tsx | 2 +- ui/src/components/MobileBottomNav.tsx | 2 +- ui/src/components/Sidebar.tsx | 2 +- ui/src/hooks/useInboxBadge.ts | 14 ++++----- ui/src/lib/inbox.test.ts | 43 ++++++++++++++++++++++++--- ui/src/lib/inbox.ts | 31 ++++++++++++++++--- ui/src/pages/Inbox.tsx | 15 +++++----- 8 files changed, 90 insertions(+), 26 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8f1b3ef9..f51679de 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,7 @@ import { NotFoundPage } from "./pages/NotFound"; import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; +import { loadLastInboxTab } from "./lib/inbox"; function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -138,7 +139,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -147,6 +148,10 @@ function boardRoutes() { ); } +function InboxRootRedirect() { + return ; +} + function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialog(); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 04b4b035..3defb0e6 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -142,7 +142,7 @@ export function CommandPalette() { Dashboard - go("/inbox/all")}> + go("/inbox")}> Inbox diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index 7ba83b89..daa17318 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -47,7 +47,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { { type: "link", to: "/agents/all", label: "Agents", icon: Users }, { type: "link", - to: "/inbox/all", + to: "/inbox", label: "Inbox", icon: Inbox, badge: inboxBadge.inbox, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 684c878a..0cc46d87 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -73,7 +73,7 @@ export function Sidebar() { >(loadDismissedInboxItems); @@ -70,12 +70,12 @@ export function useInboxBadge(companyId: string | null | undefined) { enabled: !!companyId, }); - const { data: touchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.listTouchedByMe(companyId!), + const { data: unreadIssues = [] } = useQuery({ + queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!), queryFn: () => issuesApi.list(companyId!, { - touchedByUserId: "me", - status: TOUCHED_ISSUE_STATUSES, + unreadForUserId: "me", + status: INBOX_ISSUE_STATUSES, }), enabled: !!companyId, }); @@ -93,9 +93,9 @@ export function useInboxBadge(companyId: string | null | undefined) { joinRequests, dashboard, heartbeatRuns, - touchedIssues, + unreadIssues, dismissed, }), - [approvals, joinRequests, dashboard, heartbeatRuns, touchedIssues, dismissed], + [approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed], ); } diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 10183cf2..016ad9e3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1,8 +1,31 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; -import { computeInboxBadgeData, getUnreadTouchedIssues } from "./inbox"; +import { + computeInboxBadgeData, + getUnreadTouchedIssues, + loadLastInboxTab, + saveLastInboxTab, +} from "./inbox"; + +const storage = new Map(); + +Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + }, + configurable: true, +}); function makeApproval(status: Approval["status"]): Approval { return { @@ -142,6 +165,10 @@ const dashboard: DashboardSummary = { }; describe("inbox helpers", () => { + beforeEach(() => { + storage.clear(); + }); + it("counts the same inbox sources the badge uses", () => { const result = computeInboxBadgeData({ approvals: [makeApproval("pending"), makeApproval("approved")], @@ -152,7 +179,7 @@ describe("inbox helpers", () => { makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"), makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), ], - touchedIssues: [makeIssue("1", true), makeIssue("2", false)], + unreadIssues: [makeIssue("1", true)], dismissed: new Set(), }); @@ -172,7 +199,7 @@ describe("inbox helpers", () => { joinRequests: [], dashboard, heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], - touchedIssues: [], + unreadIssues: [], dismissed: new Set(["run:run-1", "alert:budget", "alert:agent-errors"]), }); @@ -192,4 +219,12 @@ describe("inbox helpers", () => { expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]); expect(issues).toHaveLength(2); }); + + it("defaults the remembered inbox tab to new and persists all", () => { + localStorage.clear(); + expect(loadLastInboxTab()).toBe("new"); + + saveLastInboxTab("all"); + expect(loadLastInboxTab()).toBe("all"); + }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 3ae9f9a9..635991d4 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -10,6 +10,8 @@ export const RECENT_ISSUES_LIMIT = 100; export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const DISMISSED_KEY = "paperclip:inbox:dismissed"; +export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; +export type InboxTab = "new" | "all"; export interface InboxBadgeData { inbox: number; @@ -30,7 +32,28 @@ export function loadDismissedInboxItems(): Set { } export function saveDismissedInboxItems(ids: Set) { - localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); + try { + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids])); + } catch { + // Ignore localStorage failures. + } +} + +export function loadLastInboxTab(): InboxTab { + try { + const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); + return raw === "all" ? "all" : "new"; + } catch { + return "new"; + } +} + +export function saveLastInboxTab(tab: InboxTab) { + try { + localStorage.setItem(INBOX_LAST_TAB_KEY, tab); + } catch { + // Ignore localStorage failures. + } } export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { @@ -80,14 +103,14 @@ export function computeInboxBadgeData({ joinRequests, dashboard, heartbeatRuns, - touchedIssues, + unreadIssues, dismissed, }: { approvals: Approval[]; joinRequests: JoinRequest[]; dashboard: DashboardSummary | undefined; heartbeatRuns: HeartbeatRun[]; - touchedIssues: Issue[]; + unreadIssues: Issue[]; dismissed: Set; }): InboxBadgeData { const actionableApprovals = approvals.filter((approval) => @@ -96,7 +119,7 @@ export function computeInboxBadgeData({ const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( (run) => !dismissed.has(`run:${run.id}`), ).length; - const unreadTouchedIssues = getUnreadTouchedIssues(touchedIssues).length; + const unreadTouchedIssues = unreadIssues.length; const agentErrorCount = dashboard?.agents.error ?? 0; const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3858e88f..1a87b97a 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -43,13 +43,14 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, + type InboxTab, normalizeTimestamp, RECENT_ISSUES_LIMIT, + saveLastInboxTab, sortIssuesByMostRecentActivity, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; -type InboxTab = "new" | "all"; type InboxCategoryFilter = | "everything" | "issues_i_touched" @@ -265,6 +266,10 @@ export function Inbox() { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); + useEffect(() => { + saveLastInboxTab(tab); + }, [tab]); + const { data: approvals, isLoading: isApprovalsLoading, @@ -328,10 +333,6 @@ export function Inbox() { () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), [touchedIssuesRaw], ); - const unreadTouchedIssues = useMemo( - () => touchedIssues.filter((issue) => issue.isUnreadForMe), - [touchedIssues], - ); const agentById = useMemo(() => { const map = new Map(); @@ -466,7 +467,7 @@ export function Inbox() { !dismissed.has("alert:budget"); const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasJoinRequests = joinRequests.length > 0; - const hasTouchedIssues = unreadTouchedIssues.length > 0; + const hasTouchedIssues = touchedIssues.length > 0; const showJoinRequestsCategory = allCategoryFilter === "everything" || allCategoryFilter === "join_requests"; @@ -749,7 +750,7 @@ export function Inbox() { My Recent Issues
- {(tab === "new" ? unreadTouchedIssues : touchedIssues).map((issue) => { + {touchedIssues.map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( From 57dcdb51af77a2ca0c25acb2af19c50ae97c5ffd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 08:20:24 -0500 Subject: [PATCH 11/16] ui: apply interface polish from design article review - Add global font smoothing (antialiased) to body - Add tabular-nums to all numeric displays: MetricCard values, Costs page, AgentDetail token/cost grids and tables, IssueDetail cost summary, Companies page budget display - Replace markdown image hard border with subtle inset box-shadow overlay - Replace all animate-ping status dots with calmer animate-pulse across AgentDetail, IssueDetail, Agents, sidebar, kanban, issues list, and active agents panel Co-Authored-By: Claude Opus 4.6 --- ui/src/components/ActiveAgentsPanel.tsx | 2 +- ui/src/components/CompanyRail.tsx | 2 +- ui/src/components/IssuesList.tsx | 2 +- ui/src/components/KanbanBoard.tsx | 2 +- ui/src/components/MetricCard.tsx | 2 +- ui/src/components/SidebarAgents.tsx | 2 +- ui/src/components/SidebarNavItem.tsx | 2 +- ui/src/index.css | 4 ++-- ui/src/pages/AgentDetail.tsx | 18 +++++++++--------- ui/src/pages/Agents.tsx | 2 +- ui/src/pages/Companies.tsx | 2 +- ui/src/pages/Costs.tsx | 6 +++--- ui/src/pages/DesignGuide.tsx | 2 +- ui/src/pages/IssueDetail.tsx | 4 ++-- 14 files changed, 26 insertions(+), 26 deletions(-) diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 2c382a9e..d3dcd1d3 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -434,7 +434,7 @@ function AgentRunCard({
{isActive ? ( - + ) : ( diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 62a8bf3e..4737d047 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -132,7 +132,7 @@ function SortableCompanyItem({ {hasLiveAgents && ( - + diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 481ce933..da1c161d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -628,7 +628,7 @@ export function IssuesList({ {liveIssueIds?.has(issue.id) && ( - + Live diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 3e8feb65..96750558 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -154,7 +154,7 @@ function KanbanCard({ {isLive && ( - + )} diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index b954367d..38a2f9d1 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -18,7 +18,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
-

+

{value}

diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 9d36377f..b94ccfe4 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -127,7 +127,7 @@ export function SidebarAgents() { {runCount > 0 && ( - + diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index 6d3f4995..18ba03f1 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -53,7 +53,7 @@ export function SidebarNavItem({ {liveCount != null && liveCount > 0 && ( - + {liveCount} live diff --git a/ui/src/index.css b/ui/src/index.css index aab6536a..adcc1e9b 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -123,7 +123,7 @@ -webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent); } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; height: 100%; overflow: hidden; } @@ -528,8 +528,8 @@ } .paperclip-markdown img { - border: 1px solid var(--border); border-radius: calc(var(--radius) + 2px); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent); } .paperclip-markdown table { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index b5571255..77923019 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -511,7 +511,7 @@ export function AgentDetail() { className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline" > - + Live @@ -713,7 +713,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin

{isLive && ( - + )} @@ -857,7 +857,7 @@ function CostsSection({
{runtimeState && (
-
+
Input tokens {formatTokens(runtimeState.totalInputTokens)} @@ -896,9 +896,9 @@ function CostsSection({ {formatDate(run.createdAt)} {run.id.slice(0, 8)} - {formatTokens(Number(u.input_tokens ?? 0))} - {formatTokens(Number(u.output_tokens ?? 0))} - + {formatTokens(Number(u.input_tokens ?? 0))} + {formatTokens(Number(u.output_tokens ?? 0))} + {(u.cost_usd || u.total_cost_usd) ? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}` : "-" @@ -1163,7 +1163,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect )} {(metrics.totalTokens > 0 || metrics.cost > 0) && ( -
+
{metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tok} {metrics.cost > 0 && ${metrics.cost.toFixed(3)}}
@@ -1539,7 +1539,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb {/* Right column: metrics */} {hasMetrics && ( -
+
Input
{formatTokens(metrics.input)}
@@ -2138,7 +2138,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin {isLive && ( - + Live diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 5c00444f..fbae126d 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -398,7 +398,7 @@ function LiveRunIndicator({ onClick={(e) => e.stopPropagation()} > - + diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index e00c25db..6844850d 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -244,7 +244,7 @@ export function Companies() { {issueCount} {issueCount === 1 ? "issue" : "issues"}
-
+
{formatCents(company.spentMonthlyCents)} diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 12207f09..6b977928 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -144,7 +144,7 @@ export function Costs() {

)}
-

+

{formatCents(data.summary.spendCents)}{" "} {data.summary.budgetCents > 0 @@ -192,7 +192,7 @@ export function Costs() { )}

-
+
{formatCents(row.costCents)} in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok @@ -229,7 +229,7 @@ export function Costs() { {row.projectName ?? row.projectId ?? "Unattributed"} - {formatCents(row.costCents)} + {formatCents(row.costCents)}
))}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index b2ec4f5a..e7ee898d 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1061,7 +1061,7 @@ export function DesignGuide() {
[12:00:17] INFO Reconnected successfully
- + Live diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d10a8114..bb152e17 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -565,7 +565,7 @@ export function IssueDetail() { {hasLiveRuns && ( - + Live @@ -901,7 +901,7 @@ export function IssueDetail() { {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
No cost data yet.
) : ( -
+
{issueCostSummary.hasCost && ( ${issueCostSummary.cost.toFixed(4)} From 96e03b45b970e3058e6589ac7ef2e73b840af896 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 08:26:41 -0500 Subject: [PATCH 12/16] Refine inbox tabs and layout --- ui/src/App.tsx | 4 +- ui/src/context/LiveUpdatesProvider.tsx | 2 +- ui/src/lib/inbox.test.ts | 9 +++- ui/src/lib/inbox.ts | 8 +-- ui/src/pages/Inbox.tsx | 74 ++++++++++++++------------ 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f51679de..114034d1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -140,8 +140,10 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> + } /> } /> } /> diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 25d0381e..fd9df800 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -256,7 +256,7 @@ function buildJoinRequestToast( title: `${label} wants to join`, body: "A new join request is waiting for approval.", tone: "info", - action: { label: "View inbox", href: "/inbox/new" }, + action: { label: "View inbox", href: "/inbox/unread" }, dedupeKey: `join-request:${entityId}`, }; } diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 016ad9e3..ef23423f 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -220,11 +220,16 @@ describe("inbox helpers", () => { expect(issues).toHaveLength(2); }); - it("defaults the remembered inbox tab to new and persists all", () => { + it("defaults the remembered inbox tab to recent and persists all", () => { localStorage.clear(); - expect(loadLastInboxTab()).toBe("new"); + expect(loadLastInboxTab()).toBe("recent"); saveLastInboxTab("all"); expect(loadLastInboxTab()).toBe("all"); }); + + it("maps legacy new-tab storage to recent", () => { + localStorage.setItem("paperclip:inbox:last-tab", "new"); + expect(loadLastInboxTab()).toBe("recent"); + }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 635991d4..e21dbabf 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -11,7 +11,7 @@ export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; -export type InboxTab = "new" | "all"; +export type InboxTab = "recent" | "unread" | "all"; export interface InboxBadgeData { inbox: number; @@ -42,9 +42,11 @@ export function saveDismissedInboxItems(ids: Set) { export function loadLastInboxTab(): InboxTab { try { const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); - return raw === "all" ? "all" : "new"; + if (raw === "all" || raw === "unread" || raw === "recent") return raw; + if (raw === "new") return "recent"; + return "recent"; } catch { - return "new"; + return "recent"; } } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 1a87b97a..4d7f3b0b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; @@ -44,7 +44,6 @@ import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, type InboxTab, - normalizeTimestamp, RECENT_ISSUES_LIMIT, saveLastInboxTab, sortIssuesByMostRecentActivity, @@ -245,8 +244,9 @@ export function Inbox() { const [allApprovalFilter, setAllApprovalFilter] = useState("all"); const { dismissed, dismiss } = useDismissedInboxItems(); - const pathSegment = location.pathname.split("/").pop() ?? "new"; - const tab: InboxTab = pathSegment === "all" ? "all" : "new"; + const pathSegment = location.pathname.split("/").pop() ?? "recent"; + const tab: InboxTab = + pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent"; const issueLinkState = useMemo( () => createIssueDetailLocationState( @@ -333,6 +333,10 @@ export function Inbox() { () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), [touchedIssuesRaw], ); + const unreadTouchedIssues = useMemo( + () => touchedIssues.filter((issue) => issue.isUnreadForMe), + [touchedIssues], + ); const agentById = useMemo(() => { const map = new Map(); @@ -478,17 +482,22 @@ export function Inbox() { allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals; - const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues; + const approvalsToRender = tab === "unread" ? actionableApprovals : filteredAllApprovals; + const showTouchedSection = + tab === "all" + ? showTouchedCategory && hasTouchedIssues + : tab === "unread" + ? unreadTouchedIssues.length > 0 + : hasTouchedIssues; const showJoinRequestsSection = - tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests; + tab === "all" ? showJoinRequestsCategory && hasJoinRequests : hasJoinRequests; const showApprovalsSection = - tab === "new" + tab === "unread" ? actionableApprovals.length > 0 : showApprovalsCategory && filteredAllApprovals.length > 0; const showFailedRunsSection = - tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures; - const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts; + tab === "all" ? showFailedRunsCategory && hasRunFailures : hasRunFailures; + const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : hasAlerts; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, @@ -511,13 +520,14 @@ export function Inbox() { return (
- navigate(`/inbox/${value === "all" ? "all" : "new"}`)}> + navigate(`/inbox/${value}`)}> @@ -572,9 +582,11 @@ export function Inbox() { )} @@ -584,7 +596,7 @@ export function Inbox() { {showSeparatorBefore("approvals") && }

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

{approvalsToRender.map((approval) => ( @@ -750,7 +762,7 @@ export function Inbox() { My Recent Issues

- {touchedIssues.map((issue) => { + {(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( @@ -760,17 +772,18 @@ export function Inbox() { state={issueLinkState} className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > - {/* Status icon - left column on mobile, inline on desktop */} - - - - - {/* Right column on mobile: title + metadata stacked */} - - {issue.title} + + + {issue.title} + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} + - + {(isUnread || isFading) ? ( )} + {issue.identifier ?? issue.id.slice(0, 8)} - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - From 521b24da3d08f50ca95690caf2d97b2924392625 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 08:42:41 -0500 Subject: [PATCH 13/16] Tighten recent inbox tab behavior --- ui/src/pages/Inbox.tsx | 129 ++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 80 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4d7f3b0b..cf7c1052 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -482,7 +482,7 @@ export function Inbox() { allCategoryFilter === "everything" || allCategoryFilter === "failed_runs"; const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts"; - const approvalsToRender = tab === "unread" ? actionableApprovals : filteredAllApprovals; + const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals; const showTouchedSection = tab === "all" ? showTouchedCategory && hasTouchedIssues @@ -490,14 +490,13 @@ export function Inbox() { ? unreadTouchedIssues.length > 0 : hasTouchedIssues; const showJoinRequestsSection = - tab === "all" ? showJoinRequestsCategory && hasJoinRequests : hasJoinRequests; - const showApprovalsSection = - tab === "unread" - ? actionableApprovals.length > 0 - : showApprovalsCategory && filteredAllApprovals.length > 0; + tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests; + const showApprovalsSection = tab === "all" + ? showApprovalsCategory && filteredAllApprovals.length > 0 + : actionableApprovals.length > 0; const showFailedRunsSection = - tab === "all" ? showFailedRunsCategory && hasRunFailures : hasRunFailures; - const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : hasAlerts; + tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures; + const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts; const visibleSections = [ showFailedRunsSection ? "failed_runs" : null, @@ -772,82 +771,52 @@ export function Inbox() { state={issueLinkState} className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > - - - - {issue.title} - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - } - }} - className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" - aria-label="Mark as read" - > - - - ) : ( - - )} - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - - - {/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */} - {(isUnread || isFading) && ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + + {(isUnread || isFading) ? ( + { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); - } - }} - className="shrink-0 self-center cursor-pointer sm:hidden" - aria-label="Mark as read" - > - + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + aria-label="Mark as read" + > + + + ) : ( + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + + {issue.title} - )} + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} + ); })} From 345c7f4a889ccc67088db983f2d0e1bfbd915e3a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 08:51:30 -0500 Subject: [PATCH 14/16] Remove inbox recent issues label --- ui/src/pages/Inbox.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index cf7c1052..b25eec25 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -757,9 +757,6 @@ export function Inbox() { <> {showSeparatorBefore("issues_i_touched") && }
-

- My Recent Issues -

{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); From 57d8d0107968c14e58f20402c9deeee258f8af83 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 09:02:23 -0500 Subject: [PATCH 15/16] Align inbox badge with visible unread items --- ui/src/hooks/useInboxBadge.ts | 13 +++++-- ui/src/lib/inbox.test.ts | 15 +++++++ ui/src/lib/inbox.ts | 4 ++ ui/src/pages/Inbox.tsx | 73 ++++++++++++++++++++++++++++------- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index f2e916bd..fff0ff13 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -9,8 +9,10 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { computeInboxBadgeData, + getRecentTouchedIssues, loadDismissedInboxItems, saveDismissedInboxItems, + getUnreadTouchedIssues, } from "../lib/inbox"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; @@ -70,16 +72,21 @@ export function useInboxBadge(companyId: string | null | undefined) { enabled: !!companyId, }); - const { data: unreadIssues = [] } = useQuery({ - queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId!), + const { data: touchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.listTouchedByMe(companyId!), queryFn: () => issuesApi.list(companyId!, { - unreadForUserId: "me", + touchedByUserId: "me", status: INBOX_ISSUE_STATUSES, }), enabled: !!companyId, }); + const unreadIssues = useMemo( + () => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)), + [touchedIssues], + ); + const { data: heartbeatRuns = [] } = useQuery({ queryKey: queryKeys.heartbeats(companyId!), queryFn: () => heartbeatsApi.list(companyId!), diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index ef23423f..a8480828 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { computeInboxBadgeData, + getRecentTouchedIssues, getUnreadTouchedIssues, loadLastInboxTab, + RECENT_ISSUES_LIMIT, saveLastInboxTab, } from "./inbox"; @@ -220,6 +222,19 @@ describe("inbox helpers", () => { expect(issues).toHaveLength(2); }); + it("limits recent touched issues before unread badge counting", () => { + const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => { + const issue = makeIssue(String(index + 1), index < 3); + issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000); + return issue; + }); + + const recentIssues = getRecentTouchedIssues(issues); + + expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT); + expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]); + }); + it("defaults the remembered inbox tab to recent and persists all", () => { localStorage.clear(); expect(loadLastInboxTab()).toBe("recent"); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index e21dbabf..88447a98 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -96,6 +96,10 @@ export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number { return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt); } +export function getRecentTouchedIssues(issues: Issue[]): Issue[] { + return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT); +} + export function getUnreadTouchedIssues(issues: Issue[]): Issue[] { return issues.filter((issue) => issue.isUnreadForMe); } diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b25eec25..97a191f5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -43,10 +43,9 @@ import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, getLatestFailedRunsByAgent, + getRecentTouchedIssues, type InboxTab, - RECENT_ISSUES_LIMIT, saveLastInboxTab, - sortIssuesByMostRecentActivity, } from "../lib/inbox"; import { useDismissedInboxItems } from "../hooks/useInboxBadge"; @@ -329,10 +328,7 @@ export function Inbox() { enabled: !!selectedCompanyId, }); - const touchedIssues = useMemo( - () => [...touchedIssuesRaw].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT), - [touchedIssuesRaw], - ); + const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const unreadTouchedIssues = useMemo( () => touchedIssues.filter((issue) => issue.isUnreadForMe), [touchedIssues], @@ -435,17 +431,20 @@ export function Inbox() { const [fadingOutIssues, setFadingOutIssues] = useState>(new Set()); + const invalidateInboxIssueQueries = () => { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); + }; + const markReadMutation = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onMutate: (id) => { setFadingOutIssues((prev) => new Set(prev).add(id)); }, onSuccess: () => { - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); - } + invalidateInboxIssueQueries(); }, onSettled: (_data, _error, id) => { setTimeout(() => { @@ -458,6 +457,31 @@ export function Inbox() { }, }); + const markAllReadMutation = useMutation({ + mutationFn: async (issueIds: string[]) => { + await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId))); + }, + onMutate: (issueIds) => { + setFadingOutIssues((prev) => { + const next = new Set(prev); + for (const issueId of issueIds) next.add(issueId); + return next; + }); + }, + onSuccess: () => { + invalidateInboxIssueQueries(); + }, + onSettled: (_data, _error, issueIds) => { + setTimeout(() => { + setFadingOutIssues((prev) => { + const next = new Set(prev); + for (const issueId of issueIds) next.delete(issueId); + return next; + }); + }, 300); + }, + }); + if (!selectedCompanyId) { return ; } @@ -515,6 +539,10 @@ export function Inbox() { !isRunsLoading; const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; + const unreadIssueIds = unreadTouchedIssues + .filter((issue) => !fadingOutIssues.has(issue.id)) + .map((issue) => issue.id); + const canMarkAllRead = unreadIssueIds.length > 0; return (
@@ -532,8 +560,22 @@ export function Inbox() { /> - {tab === "all" && ( -
+
+ {canMarkAllRead && ( + + )} + + {tab === "all" && ( + <> )} -
- )} + + )} +
{approvalsError &&

{approvalsError.message}

} From d9492f02d6fda9873712b88c03fbbf0788020ad7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 09:15:27 -0500 Subject: [PATCH 16/16] Add worktree start-point support --- cli/src/__tests__/worktree.test.ts | 22 ++++++++++++++++++++++ cli/src/commands/worktree.ts | 28 ++++++++++++++++++++++++---- doc/SPEC-implementation.md | 6 +----- doc/spec/ui.md | 2 +- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 106cbc74..8493f897 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -115,6 +115,28 @@ describe("worktree helpers", () => { ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); }); + it("builds git worktree add args with a start point", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: false, + startPoint: "public-gh/master", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); + }); + + it("uses start point even when a local branch with the same name exists", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: true, + startPoint: "origin/main", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); + }); + it("rewrites loopback auth URLs to the new port only", () => { expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 2ef42abf..d511881e 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -62,7 +62,9 @@ type WorktreeInitOptions = { force?: boolean; }; -type WorktreeMakeOptions = WorktreeInitOptions; +type WorktreeMakeOptions = WorktreeInitOptions & { + startPoint?: string; +}; type WorktreeEnvOptions = { config?: string; @@ -166,11 +168,13 @@ export function resolveGitWorktreeAddArgs(input: { branchName: string; targetPath: string; branchExists: boolean; + startPoint?: string; }): string[] { - if (input.branchExists) { + if (input.branchExists && !input.startPoint) { return ["worktree", "add", input.targetPath, input.branchName]; } - return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"]; + const commitish = input.startPoint ?? "HEAD"; + return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; } function readPidFilePort(postmasterPidFile: string): number | null { @@ -715,10 +719,25 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt } mkdirSync(path.dirname(targetPath), { recursive: true }); + if (opts.startPoint) { + const [remote] = opts.startPoint.split("/", 1); + try { + execFileSync("git", ["fetch", remote], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error( + `Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, + ); + } + } + const worktreeArgs = resolveGitWorktreeAddArgs({ branchName: name, targetPath, - branchExists: localBranchExists(sourceCwd, name), + branchExists: !opts.startPoint && localBranchExists(sourceCwd, name), + startPoint: opts.startPoint, }); const spinner = p.spinner(); @@ -775,6 +794,7 @@ export function registerWorktreeCommands(program: Command): void { .command("worktree:make") .description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it") .argument("", "Worktree directory and branch name (created at ~/NAME)") + .option("--start-point ", "Remote ref to base the new branch on (e.g. origin/main)") .option("--instance ", "Explicit isolated instance id") .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 430dcabb..efaf6518 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1. | Visibility | Full visibility to board and all agents in same company | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | -| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed | +| Recovery | No automatic reassignment; work recovery stays manual/explicit | | Agent adapters | Built-in `process` and `http` adapters | | Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | | Budget period | Monthly UTC calendar window | @@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles: - heartbeat trigger checks - stuck run detection - budget threshold checks -- stale task reporting generation Separate queue infrastructure is not required for V1. @@ -502,7 +501,6 @@ Dashboard payload must include: - open/in-progress/blocked/done issue counts - month-to-date spend and budget utilization - pending approvals count -- stale task count ## 10.9 Error Semantics @@ -681,7 +679,6 @@ Required UX behaviors: - global company selector - quick actions: pause/resume agent, create task, approve/reject request - conflict toasts on atomic checkout failure -- clear stale-task indicators - no silent background failures; every failed run visible in UI ## 15. Operational Requirements @@ -780,7 +777,6 @@ A release candidate is blocked unless these pass: - add company selector and org chart view - add approvals and cost pages -- add operational dashboard and stale-task surfacing ## Milestone 6: Hardening and Release diff --git a/doc/spec/ui.md b/doc/spec/ui.md index c7779393..c2ffdb7c 100644 --- a/doc/spec/ui.md +++ b/doc/spec/ui.md @@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header. My Issues ``` -- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count. +- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count. - **My Issues** — issues created by or assigned to the board operator. ### 3.3 Work Section