From ba080cb4dd9f19be1c58d95d0304754fcfc16804 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:52:29 -0600 Subject: [PATCH 01/17] Fix stale work section overflowing on mobile in inbox Add min-w-0 and overflow-hidden to the stale work row flex containers so the title truncates properly on narrow screens. Add shrink-0 to identifier and assignee spans to prevent them from being compressed. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 25d1da06..2a1e3917 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,26 +841,26 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : ( - + {issue.assigneeAgentId.slice(0, 8)} ); From 7661fae4b3865960d707c09d032f10f35b0e6bca Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:55:32 -0600 Subject: [PATCH 02/17] Fix inbox row irregular heights on mobile from unread badge - Give unread dot container fixed h-5 so rows are consistent height regardless of badge presence - Use flex-wrap on mobile so title gets its own line with line-clamp-2 - On sm+ screens, keep single-line truncated layout - Move timestamp to ml-auto with sm:order-last for clean wrapping Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 2a1e3917..ff72947e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -898,9 +898,9 @@ export function Inbox() { return (
- + {(isUnread || isFading) && (
); From 6e86f69f95bd9917a58299b48174669f55877455 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:01:16 -0600 Subject: [PATCH 03/17] Unify issue rows with GitHub-style mobile layout across app On mobile, all issue rows now show title first (up to 2 lines), with metadata (icons, identifier, timestamp) on a second line below. Desktop layout is preserved as single-line rows. Affected locations: Inbox recent issues, Inbox stale work, Dashboard recent tasks, and IssuesList. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 225 ++++++++++++++++--------------- ui/src/pages/Dashboard.tsx | 40 +++--- ui/src/pages/Inbox.tsx | 76 ++++++----- 3 files changed, 180 insertions(+), 161 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6335f02c..4abd2cea 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -608,41 +608,26 @@ export function IssuesList({ - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} -
-
{ e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> -
- - {issue.identifier ?? issue.id.slice(0, 8)} + {/* Title line - first on mobile, middle on desktop */} + + {issue.title} - {issue.title} - {(issue.labels ?? []).length > 0 && ( -
- {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} - )} -
- )} -
+ + {/* Metadata line - second on mobile, first on desktop */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + {liveIssueIds?.has(issue.id) && ( @@ -652,90 +637,116 @@ export function IssuesList({ Live )} -
- { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} + · + + {formatDate(issue.createdAt)} + + + + {/* Desktop-only trailing content */} + + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + +{(issue.labels ?? []).length - 3} + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} > - + setAssigneeSearch(e.target.value)} + autoFocus + /> +
- - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
- -
- + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+ + + {formatDate(issue.createdAt)} -
+
))} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 823d57df..e4b8747e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,26 +313,28 @@ export function Dashboard() { -
-
-
- - -
-

- {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} -

-
- - {timeAgo(issue.updatedAt)} +
+ + {issue.title} + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff72947e..e55b3866 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,38 +841,39 @@ export function Inbox() { {staleIssues.map((issue) => (
+ - - - - - {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} - {issue.title} - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : ( - - {issue.assigneeAgentId.slice(0, 8)} - - ); - })()} - - updated {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && + (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : null; + })()} + · + + updated {timeAgo(issue.updatedAt)} +
); From ce8fe38ffc8f9804a182460ace866b9b98c9a73d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:32 -0600 Subject: [PATCH 04/17] Unify mobile issue row layout across issues, inbox, and dashboard Add PriorityIcon and timeAgo to IssuesList mobile rows to match the pattern used in Inbox and Dashboard. Align Dashboard row padding to match Inbox. All mobile issue rows now show: title (2-line clamp), then priority + status + identifier + relative time. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 6 ++++-- ui/src/pages/Dashboard.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 4abd2cea..498752b4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,6 +7,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; @@ -619,13 +620,14 @@ export function IssuesList({ {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> - + {issue.identifier ?? issue.id.slice(0, 8)} {liveIssueIds?.has(issue.id) && ( @@ -639,7 +641,7 @@ export function IssuesList({ )} · - {formatDate(issue.createdAt)} + {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e4b8747e..23296bba 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,7 +313,7 @@ export function Dashboard() {
From a96556b8f4530ad6e8915405f3d8ae855c21b31e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:41 -0600 Subject: [PATCH 05/17] Move unread badge inline with metadata row in inbox Moves the unread dot from a separate left column into the metadata line (alongside status/identifier), with an empty placeholder for read items to keep spacing consistent. Reduces left padding on mobile inbox rows to reclaim space. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 54 +++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index e55b3866..8b8a52b5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -897,12 +897,16 @@ export function Inbox() { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( -
- - {(isUnread || isFading) && ( + + {issue.title} + + + {(isUnread || isFading) ? ( + ) : ( + )} + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} + - - - {issue.title} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - - -
+ ); })}
From 45473b3e726c35b5e552c711e37f339fd321ad70 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:07:39 -0600 Subject: [PATCH 06/17] Move scroll-to-bottom button to issue detail and run pages Removed the scroll-to-bottom button from IssuesList (wrong location) and created a shared ScrollToBottom component. Added it to IssueDetail and RunDetail pages. On mobile, the button sits above the bottom nav to avoid overlap. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 29 +------------------- ui/src/components/ScrollToBottom.tsx | 40 ++++++++++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 2 ++ ui/src/pages/IssueDetail.tsx | 2 ++ 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 ui/src/components/ScrollToBottom.tsx diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 498752b4..e9f7ac8d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -18,7 +18,7 @@ import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclipai/shared"; @@ -234,24 +234,6 @@ export function IssuesList({ const activeFilterCount = countActiveFilters(viewState); - const [showScrollBottom, setShowScrollBottom] = useState(false); - useEffect(() => { - const el = document.getElementById("main-content"); - if (!el) return; - const check = () => { - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - setShowScrollBottom(distanceFromBottom > 300); - }; - check(); - el.addEventListener("scroll", check, { passive: true }); - return () => el.removeEventListener("scroll", check); - }, [filtered.length]); - - const scrollToBottom = useCallback(() => { - const el = document.getElementById("main-content"); - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, []); - const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; @@ -755,15 +737,6 @@ export function IssuesList({ )) )} - {showScrollBottom && ( - - )}
); } diff --git a/ui/src/components/ScrollToBottom.tsx b/ui/src/components/ScrollToBottom.tsx new file mode 100644 index 00000000..4ea8a494 --- /dev/null +++ b/ui/src/components/ScrollToBottom.tsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from "react"; +import { ArrowDown } from "lucide-react"; + +/** + * 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. + */ +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); + }; + check(); + el.addEventListener("scroll", check, { passive: true }); + return () => el.removeEventListener("scroll", check); + }, []); + + const scroll = useCallback(() => { + const el = document.getElementById("main-content"); + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }, []); + + if (!visible) return null; + + return ( + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 06c3a2f4..596cb98d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -24,6 +24,7 @@ import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; @@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen {/* Log viewer */} +
); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 90c94888..a0266c16 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusBadge } from "../components/StatusBadge"; @@ -926,6 +927,7 @@ export function IssueDetail() { +
); } From 0f75c35392190e5c90b30e95c90fe96386be8128 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:12:26 -0600 Subject: [PATCH 07/17] Fix unread dot making inbox rows taller than read rows Replace + ) : ( - + )} From 31b5ff1c619afc20a2f5f8a23b724a2652698eab Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 08:21:07 -0500 Subject: [PATCH 08/17] Add bottom padding to new issue description editor for iOS keyboard The description text was being covered by the property chips bar when typing long content on iOS with the virtual keyboard open. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/NewIssueDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 99106f9f..4401821f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -831,7 +831,7 @@ export function NewIssueDialog() { placeholder="Add description..." bordered={false} mentions={mentionOptions} - contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} + contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); return asset.contentPath; From 2a7043d67761456d92d0b4be8fa9c5b121e9e4b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:56:17 -0500 Subject: [PATCH 09/17] GitHub-style mobile issue rows: status left column, hide priority, unread dot right - Move status icon to left column on mobile across issues list, inbox, and dashboard - Hide priority icon on mobile (only show on desktop) - Move unread indicator dot to right side vertically centered on mobile inbox - Stale work section: show status icon instead of clock on mobile - Desktop layout unchanged Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 67 +++++++++------- ui/src/pages/Dashboard.tsx | 42 ++++++---- ui/src/pages/Inbox.tsx | 129 ++++++++++++++++++++----------- 3 files changed, 149 insertions(+), 89 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index e9f7ac8d..3770d10d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -591,39 +591,50 @@ export function IssuesList({ - {/* Title line - first on mobile, middle on desktop */} - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> - {/* Metadata line - second on mobile, first on desktop */} - - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} - - - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> + {/* Right column on mobile: title + metadata stacked */} + + {/* Title line */} + + {issue.title} - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - Live + + {/* Metadata line */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + Live + + )} + · + + {timeAgo(issue.updatedAt)} - )} - · - - {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 23296bba..f782555e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -315,25 +315,33 @@ export function Dashboard() { to={`/issues/${issue.identifier ?? issue.id}`} className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block" > -
- - {issue.title} - - - +
+ {/* Status icon - left column on mobile */} + - - {issue.identifier ?? issue.id.slice(0, 8)} + + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} - · - - {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 66fe71c4..4091e36e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,9 +841,14 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {/* Status icon - left column on mobile; Clock icon on desktop */} + + + + + - - + + {issue.identifier ?? issue.id.slice(0, 8)} @@ -900,54 +905,90 @@ export function Inbox() { - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + + - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} + + + {(isUnread || isFading) ? ( + { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); - } - }} - className="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" - > - + }} + 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)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} - ) : ( - - )} - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} + + {/* 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 === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="shrink-0 self-center cursor-pointer sm:hidden" + aria-label="Mark as read" + > + + + )} ); })} From d58f2692816817baa73ff40fe8d40d90cf72fa00 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:58:39 -0500 Subject: [PATCH 10/17] Fix mobile status icon vertical alignment: remove pt-0.5 to center with text Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- ui/src/pages/Dashboard.tsx | 2 +- ui/src/pages/Inbox.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 3770d10d..f9b4dcca 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index f782555e..e1f9b9b0 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -317,7 +317,7 @@ export function Dashboard() { >
{/* Status icon - left column on mobile */} - + diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4091e36e..990f30ca 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -844,7 +844,7 @@ export function Inbox() { className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > {/* Status icon - left column on mobile; Clock icon on desktop */} - + @@ -908,7 +908,7 @@ export function Inbox() { 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 */} - + From e35e2c43432002004162a376668b270428bdeffd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 11:00:02 -0500 Subject: [PATCH 11/17] Fine-tune mobile status icon alignment on issues page: add 1px top padding Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index f9b4dcca..10d0709b 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} From 77e04407b9b412ae051bd8b3805288e43d069ef9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:21:33 -0500 Subject: [PATCH 12/17] fix(publish): always bundle ui-dist into server package --- scripts/prepare-server-ui-dist.sh | 21 +++++++++++++++++++++ scripts/release.sh | 4 +--- server/package.json | 3 +++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100755 scripts/prepare-server-ui-dist.sh diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh new file mode 100755 index 00000000..d43807b3 --- /dev/null +++ b/scripts/prepare-server-ui-dist.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. +# This keeps @paperclipai/server publish artifacts self-contained for static UI serving. + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UI_DIST="$REPO_ROOT/ui/dist" +SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" + +echo " -> Building @paperclipai/ui..." +pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build + +if [ ! -f "$UI_DIST/index.html" ]; then + echo "Error: UI build output missing at $UI_DIST/index.html" + exit 1 +fi + +rm -rf "$SERVER_UI_DIST" +cp -r "$UI_DIST" "$SERVER_UI_DIST" +echo " -> Copied ui/dist to server/ui-dist" diff --git a/scripts/release.sh b/scripts/release.sh index 6827e0fa..3668d87c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -283,9 +283,7 @@ pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving -pnpm --filter @paperclipai/ui build -rm -rf "$REPO_ROOT/server/ui-dist" -cp -r "$REPO_ROOT/ui/dist" "$REPO_ROOT/server/ui-dist" +bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" # Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do diff --git a/server/package.json b/server/package.json index 3e74286b..5c37c211 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,10 @@ "scripts": { "dev": "tsx src/index.ts", "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc", + "prepack": "pnpm run prepare:ui-dist", + "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", "typecheck": "tsc --noEmit" From ee7fddf8d5fcd76d7ffc93164f972e717fb15c25 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:22:34 -0500 Subject: [PATCH 13/17] fix: convert lockfile refresh to PR-based flow for protected master The refresh-lockfile workflow was pushing directly to master, which fails with branch protection rules. Convert to use peter-evans/create-pull-request to create a PR instead. Exempt the bot's branch from the lockfile policy check. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pr-policy.yml | 1 + .github/workflows/refresh-lockfile.yml | 42 +++++++++----------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index eb515eda..16953380 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -32,6 +32,7 @@ jobs: node-version: 20 - name: Block manual lockfile edits + if: github.head_ref != 'chore/refresh-lockfile' run: | changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 079fdd4e..b0cfb78a 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -11,11 +11,12 @@ concurrency: cancel-in-progress: false jobs: - refresh_and_verify: + refresh: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 permissions: contents: write + pull-requests: write steps: - name: Checkout repository @@ -40,6 +41,7 @@ jobs: run: | changed="$(git status --porcelain)" if [ -z "$changed" ]; then + echo "Lockfile is already up to date." exit 0 fi if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then @@ -48,29 +50,15 @@ jobs: exit 1 fi - - name: Commit refreshed lockfile - run: | - if git diff --quiet -- pnpm-lock.yaml; then - exit 0 - fi - git config user.name "lockfile-bot" - git config user.email "lockfile-bot@users.noreply.github.com" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push || { - echo "Push failed because master moved during lockfile refresh." - echo "A later refresh run should recompute the lockfile from the newer master state." - exit 1 - } + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore(lockfile): refresh pnpm-lock.yaml" + branch: chore/refresh-lockfile + delete-branch: true + title: "chore(lockfile): refresh pnpm-lock.yaml" + body: | + Auto-generated lockfile refresh after dependencies changed on `master`. - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm -r typecheck - - - name: Run tests - run: pnpm test:run - - - name: Build - run: pnpm build + This PR only updates `pnpm-lock.yaml` — no source changes. + labels: lockfile-bot From f32b76f21343ef415a6389351d6189f8d7d395e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:26:31 -0500 Subject: [PATCH 14/17] fix: replace third-party action with gh CLI for lockfile PR creation Replace peter-evans/create-pull-request with plain gh CLI commands to avoid third-party supply chain risk. Uses only GitHub's own tooling (GITHUB_TOKEN + gh CLI) to create the lockfile refresh PR. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index b0cfb78a..604f394f 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -50,15 +50,31 @@ jobs: exit 1 fi - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - commit-message: "chore(lockfile): refresh pnpm-lock.yaml" - branch: chore/refresh-lockfile - delete-branch: true - title: "chore(lockfile): refresh pnpm-lock.yaml" - body: | - Auto-generated lockfile refresh after dependencies changed on `master`. + - name: Create or update pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- pnpm-lock.yaml; then + echo "Lockfile unchanged, nothing to do." + exit 0 + fi - This PR only updates `pnpm-lock.yaml` — no source changes. - labels: lockfile-bot + BRANCH="chore/refresh-lockfile" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push -f origin "$BRANCH" + + # Create PR if one doesn't already exist for this branch + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -n "$existing" ]; then + echo "PR #$existing already exists for $BRANCH, updated branch." + else + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." + echo "Created new PR." + fi From 035e1a9333965f33547ddd10a00aa57f16dc76fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:33:49 -0500 Subject: [PATCH 15/17] fix: use lockfile-bot identity and remove force push in refresh workflow - Use lockfile-bot name/email instead of github-actions[bot] - Remove force push: close any stale PR and delete branch first, then create a fresh branch and PR each time Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 604f394f..4da9d047 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -60,21 +60,21 @@ jobs: fi BRANCH="chore/refresh-lockfile" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "$BRANCH" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push -f origin "$BRANCH" + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" - # Create PR if one doesn't already exist for this branch + # Close any stale PR and delete the remote branch so we start fresh existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') if [ -n "$existing" ]; then - echo "PR #$existing already exists for $BRANCH, updated branch." - else - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." - echo "Created new PR." + gh pr close "$existing" --delete-branch || true fi + + git checkout -b "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push origin "$BRANCH" + + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." From f2a0a0b80452165387f519a30abfdd28dc6b40c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:38:49 -0500 Subject: [PATCH 16/17] fix: restore force push in lockfile refresh workflow Simplify the PR-based flow: force push to update the branch if it already exists, and only create a new PR when one doesn't exist yet. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 4da9d047..a879e5bc 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -63,18 +63,19 @@ jobs: git config user.name "lockfile-bot" git config user.email "lockfile-bot@users.noreply.github.com" - # Close any stale PR and delete the remote branch so we start fresh - existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') - if [ -n "$existing" ]; then - gh pr close "$existing" --delete-branch || true - fi - - git checkout -b "$BRANCH" + git checkout -B "$BRANCH" git add pnpm-lock.yaml git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push origin "$BRANCH" + git push --force origin "$BRANCH" - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + # Create PR if one doesn't already exist + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -z "$existing" ]; then + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + echo "Created new PR." + else + echo "PR #$existing already exists, branch updated via force push." + fi From 38cb2bf3c44f0f0454e51b06c42b3eaf66364356 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:43:47 -0500 Subject: [PATCH 17/17] fix: add missing disableSignUp to auth config objects in CLI All auth config literals in the CLI were missing the required disableSignUp field after it was added to authConfigSchema. Co-Authored-By: Claude Opus 4.6 --- cli/src/__tests__/allowed-hostname.test.ts | 1 + cli/src/commands/configure.ts | 1 + cli/src/commands/onboard.ts | 1 + cli/src/prompts/server.ts | 4 +++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42..572689c4 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: { provider: "local_disk", diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index d072fee9..969ead97 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0e70d9cf..e3f17001 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): { }, auth: { baseUrlMode: authBaseUrlMode, + disableSignUp: false, ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), }, storage: { diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index c2ab4218..00611560 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -113,7 +113,7 @@ export async function promptServer(opts?: { } const port = Number(portStr) || 3100; - let auth: AuthConfig = { baseUrlMode: "auto" }; + let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false }; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", @@ -139,11 +139,13 @@ export async function promptServer(opts?: { } auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), }; } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: currentAuth.publicBaseUrl, }; }