fix(ui): mobile viewport, scrollable popovers, and actor labels
- Set viewport-fit=cover and disable user scaling for mobile PWA feel - Wrap assignee/project popover lists in scrollable containers - Remove rounded-t-sm from stacked chart bars for cleaner rendering - Prevent filter bar icons from shrinking on narrow screens - Show "Board" instead of raw user IDs in activity feeds and toasts - Surface server error message in health API failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#18181b" />
|
<meta name="theme-color" content="#18181b" />
|
||||||
<title>Paperclip</title>
|
<title>Paperclip</title>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const healthApi = {
|
|||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load health (${res.status})`);
|
const payload = await res.json().catch(() => null) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error ?? `Failed to load health (${res.status})`);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,12 +101,13 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
|
|||||||
: entityLink(event.entityType, event.entityId, name);
|
: entityLink(event.entityType, event.entityId, name);
|
||||||
|
|
||||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||||
|
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<p className="flex-1 min-w-0">
|
<p className="flex-1 min-w-0">
|
||||||
<Identity
|
<Identity
|
||||||
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
name={actorName}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="align-baseline"
|
className="align-baseline"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -106,35 +106,37 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
!issue.assigneeAgentId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { onUpdate({ assigneeAgentId: null }); setAssigneeOpen(false); }}
|
|
||||||
>
|
|
||||||
No assignee
|
|
||||||
</button>
|
|
||||||
{(agents ?? [])
|
|
||||||
.filter((a) => a.status !== "terminated")
|
|
||||||
.filter((a) => {
|
|
||||||
if (!assigneeSearch.trim()) return true;
|
|
||||||
const q = assigneeSearch.toLowerCase();
|
|
||||||
return a.name.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
.map((a) => (
|
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
a.id === issue.assigneeAgentId && "bg-accent"
|
!issue.assigneeAgentId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
onClick={() => { onUpdate({ assigneeAgentId: null }); setAssigneeOpen(false); }}
|
||||||
>
|
>
|
||||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
No assignee
|
||||||
{a.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
{(agents ?? [])
|
||||||
|
.filter((a) => a.status !== "terminated")
|
||||||
|
.filter((a) => {
|
||||||
|
if (!assigneeSearch.trim()) return true;
|
||||||
|
const q = assigneeSearch.toLowerCase();
|
||||||
|
return a.name.toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
a.id === issue.assigneeAgentId && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
||||||
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{issue.assigneeAgentId && (
|
{issue.assigneeAgentId && (
|
||||||
@@ -176,37 +178,39 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
|||||||
onChange={(e) => setProjectSearch(e.target.value)}
|
onChange={(e) => setProjectSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
||||||
!issue.projectId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
|
||||||
>
|
|
||||||
No project
|
|
||||||
</button>
|
|
||||||
{(projects ?? [])
|
|
||||||
.filter((p) => {
|
|
||||||
if (!projectSearch.trim()) return true;
|
|
||||||
const q = projectSearch.toLowerCase();
|
|
||||||
return p.name.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
.map((p) => (
|
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||||
p.id === issue.projectId && "bg-accent"
|
!issue.projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||||
>
|
>
|
||||||
<span
|
No project
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
|
||||||
/>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
{(projects ?? [])
|
||||||
|
.filter((p) => {
|
||||||
|
if (!projectSearch.trim()) return true;
|
||||||
|
const q = projectSearch.toLowerCase();
|
||||||
|
return p.name.toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||||
|
p.id === issue.projectId && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{issue.projectId && (
|
{issue.projectId && (
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export function IssuesList({
|
|||||||
<span className="hidden sm:inline">New Issue</span>
|
<span className="hidden sm:inline">New Issue</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|||||||
@@ -396,35 +396,37 @@ export function NewIssueDialog() {
|
|||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
!assigneeId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
|
|
||||||
>
|
|
||||||
No assignee
|
|
||||||
</button>
|
|
||||||
{(agents ?? [])
|
|
||||||
.filter((a) => a.status !== "terminated")
|
|
||||||
.filter((a) => {
|
|
||||||
if (!assigneeSearch.trim()) return true;
|
|
||||||
const q = assigneeSearch.toLowerCase();
|
|
||||||
return a.name.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
.map((a) => (
|
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
a.id === assigneeId && "bg-accent"
|
!assigneeId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
|
||||||
>
|
>
|
||||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
No assignee
|
||||||
{a.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
{(agents ?? [])
|
||||||
|
.filter((a) => a.status !== "terminated")
|
||||||
|
.filter((a) => {
|
||||||
|
if (!assigneeSearch.trim()) return true;
|
||||||
|
const q = assigneeSearch.toLowerCase();
|
||||||
|
return a.name.toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
a.id === assigneeId && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
||||||
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
@@ -449,31 +451,33 @@ export function NewIssueDialog() {
|
|||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
|
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
|
||||||
<button
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
||||||
!projectId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
|
||||||
>
|
|
||||||
No project
|
|
||||||
</button>
|
|
||||||
{(projects ?? []).map((p) => (
|
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||||
p.id === projectId && "bg-accent"
|
!projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
||||||
>
|
>
|
||||||
<span
|
No project
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
|
||||||
/>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
{(projects ?? []).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||||
|
p.id === projectId && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ function truncate(text: string, max: number): string {
|
|||||||
return text.slice(0, max - 1) + "\u2026";
|
return text.slice(0, max - 1) + "\u2026";
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeUuid(value: string): boolean {
|
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(value: string): string {
|
|
||||||
return value
|
|
||||||
.split(" ")
|
|
||||||
.filter((part) => part.length > 0)
|
|
||||||
.map((part) => part[0]!.toUpperCase() + part.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActorLabel(
|
function resolveActorLabel(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -62,8 +50,7 @@ function resolveActorLabel(
|
|||||||
}
|
}
|
||||||
if (actorType === "system") return "System";
|
if (actorType === "system") return "System";
|
||||||
if (actorType === "user" && actorId) {
|
if (actorType === "user" && actorId) {
|
||||||
if (looksLikeUuid(actorId)) return `User ${shortId(actorId)}`;
|
return "Board";
|
||||||
return titleCase(actorId.replace(/[_-]+/g, " "));
|
|
||||||
}
|
}
|
||||||
return "Someone";
|
return "Someone";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
|||||||
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
|
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
|
||||||
}
|
}
|
||||||
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
|
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
|
||||||
return <Identity name={id || "You"} size="sm" />;
|
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
|
||||||
|
return <Identity name={id || "Unknown"} size="sm" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
|
|||||||
Reference in New Issue
Block a user