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 <noreply@anthropic.com>
This commit is contained in:
@@ -608,41 +608,26 @@ export function IssuesList({
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
|
||||
className="flex flex-col gap-1 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:flex-row sm:items-center sm:gap-2 sm:py-2 sm:pl-1"
|
||||
>
|
||||
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
|
||||
<div className="w-3.5 shrink-0 hidden sm:block" />
|
||||
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground font-mono shrink-0">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
{/* Title line - first on mobile, middle on desktop */}
|
||||
<span className="line-clamp-2 text-sm pl-1 sm:order-2 sm:flex-1 sm:min-w-0 sm:pl-0 sm:line-clamp-none sm:truncate">
|
||||
{issue.title}
|
||||
</span>
|
||||
<span className="truncate flex-1 min-w-0">{issue.title}</span>
|
||||
{(issue.labels ?? []).length > 0 && (
|
||||
<div className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: label.color,
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
||||
|
||||
{/* Metadata line - second on mobile, first on desktop */}
|
||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
|
||||
<span className="w-3.5 shrink-0 hidden sm:block" />
|
||||
<span className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground font-mono shrink-0">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds?.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -652,90 +637,116 @@ export function IssuesList({
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="hidden sm:block">
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
||||
<span className="text-xs text-muted-foreground sm:hidden">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Desktop-only trailing content */}
|
||||
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
||||
{(issue.labels ?? []).length > 0 && (
|
||||
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: label.color,
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search agents..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
||||
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={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null);
|
||||
}}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
No assignee
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search agents..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
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={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
|
||||
Reference in New Issue
Block a user