fix(activity): polish thread-row hover and widen list (#1750)

* fix(activity): polish thread-row hover, restore stable layout, widen list

The activity thread list had several hover regressions: the cursor wasn't
indicating clickability, hovering shifted the row contents because the
bell-toggle replaced the timestamp under the cursor, and the timestamp
tooltip could get stuck open after the trigger faded out (Radix didn't
dismiss because opacity:0 left it interactable). The Jump-to-workspace
button in the right pane header also read as easy-to-miss chrome.

Surface per-card actions on hover (Open + Mark-unread) in a reserved
slot so the worktree-name's flex width doesn't reflow when the buttons
appear. Drop the redundant header button. Bump the default thread list
width 340 → 480px (range 320–720) and let prompts wrap to 3 lines so
the cards work as the primary surface — the terminal is supplementary.

Mark-unread only appears for already-read threads (selecting an unread
thread auto-marks it read, so a "mark read" button would be redundant).
The remove-events-count badge change was already staged locally and is
included to keep the row chrome consistent with the new layout.

Co-authored-by: Orca <help@stably.ai>

* fix(activity): replace 'Open' label with ExternalLink icon-only button

Co-authored-by: Orca <help@stably.ai>

* fix(activity): move Mark-unread into the bell slot on hover

Co-authored-by: Orca <help@stably.ai>

* fix(activity): match WorktreeCard bell pattern in thread rows

Co-authored-by: Orca <help@stably.ai>

* fix(activity): drop AGENTS heading, add overflow menu with Mark all read

The 'AGENTS' uppercase header was redundant — the activity titlebar
already names the surface. The 'Mark all read' button was also stuck in
the titlebar far from the threads it acts on.

Drop the heading and surface 'Mark all read' as a per-list overflow menu
(MoreVertical) next to the Filter input + unread-only toggle. The action
disables when there's nothing unread to mark.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-13 12:13:18 -07:00 committed by GitHub
parent 96f35dcfaf
commit f4009fdaa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 148 additions and 92 deletions

View File

@ -3,7 +3,15 @@ and current visual skeleton together until the next refinement pass decides
which pieces become production modules. */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { Bell, BellDot, BellOff, MessageSquareText, Search, TerminalSquare } from 'lucide-react'
import {
Bell,
BellDot,
ExternalLink,
MessageSquareText,
MoreVertical,
Search,
TerminalSquare
} from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
@ -13,12 +21,18 @@ import { useAppStore } from '@/store'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Toggle } from '@/components/ui/toggle'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers'
import {
setActivityTerminalPortals,
type ActivityTerminalPortalTarget
@ -513,7 +527,7 @@ function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element {
{formatRelativeTime(timestamp)}
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
<TooltipContent side="right" sideOffset={6}>
{absolute}
</TooltipContent>
</Tooltip>
@ -538,15 +552,16 @@ function ThreadRow({
thread,
selected,
onSelect,
onToggleRead
onJump,
onMarkUnread
}: {
thread: AgentPaneThread
selected: boolean
onSelect: () => void
onToggleRead: () => void
onJump: () => void
onMarkUnread: () => void
}): React.JSX.Element {
const latest = thread.latestEvent
const toggleLabel = thread.unread ? 'Mark thread read' : 'Mark thread unread'
return (
<div
data-current={selected ? 'true' : undefined}
@ -572,8 +587,8 @@ function ThreadRow({
// Why (asymmetric padding): the title uses leading-snug, which adds
// ~3px of internal space above the cap-height that isn't present
// below the secondary badge row. Symmetric py made the top read
// heavier; pt-2 / pb-2.5 visually evens the row.
'group relative flex w-full flex-col gap-1 border-b border-border px-3 pt-2 pb-2.5 text-left transition-colors',
// heavier; the smaller top pad visually evens the row.
'group relative flex w-full cursor-pointer flex-col gap-1 border-b border-border px-3 pt-2.5 pb-3 text-left transition-colors',
selected
? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'hover:bg-accent/40'
@ -583,7 +598,7 @@ function ThreadRow({
<span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r-full bg-primary" />
) : null}
{/* Why (right cluster aligned to title, not centered between rows):
parking time + count on the title row leaves the secondary row
parking the timestamp on the title row leaves the secondary row
full-width for the repo badge + branch name, which used to get
truncated when the right cluster ate horizontal space. */}
<div className="flex min-w-0 items-start gap-2">
@ -592,59 +607,89 @@ function ThreadRow({
</span>
<span
className={cn(
'line-clamp-2 min-w-0 flex-1 break-words text-[11px] leading-snug',
'line-clamp-3 min-w-0 flex-1 break-words text-[11px] leading-snug',
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
)}
>
{thread.paneTitle}
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 pt-[3px]">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-normal">
{thread.events.length}
</Badge>
{/* Why (single right-most slot): the unread BellDot, the timestamp,
and the hover toggle all share this slot. Layered with opacity
transitions so on hover the static bell + timestamp fade out and
the bell-toggle button fades in no double-bell on hover. */}
<span className="relative inline-flex h-5 min-w-16 items-center justify-end">
<span className="inline-flex items-center gap-1.5 transition-opacity group-hover:opacity-0">
{thread.unread ? (
<BellDot
className="size-3.5 shrink-0 text-primary"
fill="currentColor"
aria-label="Unread"
/>
) : null}
<EventTime timestamp={latest.timestamp} />
</span>
<span className="absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100">
{/* Why (bell matches WorktreeCard pattern): unread amber filled
bell as a static, non-interactive cue (selecting the thread
auto-marks it read, so a Mark-read button would be redundant);
read outline Bell that fades in on row hover and acts as
Mark-unread. Bare button (no shadcn outline) so it reads as
an inline cue rather than a discrete control square. */}
<span className="inline-flex size-4 shrink-0 items-center justify-center">
{thread.unread ? (
<FilledBellIcon
className="size-[13px] shrink-0 text-amber-500 drop-shadow-sm"
aria-label="Unread"
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
<button
type="button"
variant="outline"
size="icon-xs"
aria-label={toggleLabel}
onClick={(event) => {
event.stopPropagation()
onToggleRead()
onMarkUnread()
}}
onMouseDown={(event) => event.stopPropagation()}
className={cn(
'group/unread flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-all',
'hover:bg-accent/80 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
)}
aria-label="Mark thread unread"
>
{thread.unread ? <BellOff className="size-3" /> : <Bell className="size-3" />}
</Button>
<Bell className="size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
</button>
</TooltipTrigger>
<TooltipContent side="left">{toggleLabel}</TooltipContent>
<TooltipContent side="left">Mark thread unread</TooltipContent>
</Tooltip>
</span>
)}
</span>
<EventTime timestamp={latest.timestamp} />
</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<EventRepoBadge repo={thread.repo} />
<span className="truncate text-[11px] text-muted-foreground">
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{thread.worktree.displayName}
</span>
{/* Why (Jump-to-workspace lives on the secondary row): the bell slot
on the title row already holds the unread/Mark-unread state, so
the navigation action gets its own slot down here aligned with
the worktree name. Reserved layout via `invisible` +
`pointer-events-none` keeps the worktree-name's flex-1 width
stable across hover. */}
<span
className={cn(
'ml-auto inline-flex shrink-0 items-center transition-opacity',
'pointer-events-none invisible opacity-0',
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label="Jump to workspace"
onClick={(event) => {
event.stopPropagation()
onJump()
}}
onMouseDown={(event) => event.stopPropagation()}
>
<ExternalLink className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Jump to workspace</TooltipContent>
</Tooltip>
</span>
</div>
</div>
)
@ -659,7 +704,12 @@ export default function ActivityPrototypePage(): React.JSX.Element {
useState<ActivityTerminalPortalSlotId>('primary')
const [primaryPortalTargetEl, setPrimaryPortalTargetEl] = useState<HTMLElement | null>(null)
const [secondaryPortalTargetEl, setSecondaryPortalTargetEl] = useState<HTMLElement | null>(null)
const [threadListWidth, setThreadListWidth] = useState(340)
// Why (default width): the thread cards are the primary surface in the
// Activity view; the terminal is supplementary. A narrow list squeezed the
// prompts to truncated single-liners and made the per-card actions feel
// cramped. 480px gives prompts room to breathe at line-clamp-3 and leaves
// the action buttons clearly readable.
const [threadListWidth, setThreadListWidth] = useState(480)
const {
containerRef: threadListRef,
isResizing: isThreadListResizing,
@ -667,8 +717,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
} = useSidebarResize<HTMLDivElement>({
isOpen: true,
width: threadListWidth,
minWidth: 280,
maxWidth: 560,
minWidth: 320,
maxWidth: 720,
deltaSign: 1,
setWidth: setThreadListWidth
})
@ -898,12 +948,19 @@ export default function ActivityPrototypePage(): React.JSX.Element {
activateThreadTerminal(thread)
}
const toggleThreadRead = (thread: AgentPaneThread): void => {
if (thread.unread) {
markThreadRead(thread)
const jumpToWorkspace = (thread: AgentPaneThread): void => {
markThreadRead(thread)
activateAndRevealWorktree(thread.worktree.id)
}
const hasUnreadThreads = allThreads.some((thread) => thread.unread)
const markAllThreadsRead = (): void => {
const unreadKeys = allThreads.filter((t) => t.unread).map((t) => t.paneKey)
if (unreadKeys.length === 0) {
return
}
markThreadUnread(thread)
storeData.acknowledgeAgents(unreadKeys)
}
// Why (page padding): drop top + horizontal padding so the page extends to
@ -919,12 +976,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
className="relative flex min-h-0 shrink-0 flex-col border-r border-border"
style={{ width: threadListWidth }}
>
<div className="shrink-0 border-b border-border px-2 pt-1.5 pb-2">
<div className="mb-2 flex items-center gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Agents
</div>
</div>
<div className="shrink-0 border-b border-border px-2 pt-2 pb-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
@ -955,6 +1007,37 @@ export default function ActivityPrototypePage(): React.JSX.Element {
</TooltipTrigger>
<TooltipContent side="bottom">Show unread threads only</TooltipContent>
</Tooltip>
{/* Why (overflow menu): "Mark all read" is a low-frequency,
destructive-feeling action parking it behind a `` keeps
the toolbar focused on the high-frequency Filter + unread
toggle while still giving the action a stable home next to
the list it acts on (rather than the titlebar). */}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="size-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
aria-label="Thread list options"
>
<MoreVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuItem
onSelect={() => markAllThreadsRead()}
disabled={!hasUnreadThreads}
>
Mark all read
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto scrollbar-sleek">
@ -964,7 +1047,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
thread={thread}
selected={thread.paneKey === selectedThread?.paneKey}
onSelect={() => selectThread(thread)}
onToggleRead={() => toggleThreadRead(thread)}
onJump={() => jumpToWorkspace(thread)}
onMarkUnread={() => markThreadUnread(thread)}
/>
))}
{visibleThreads.length === 0 ? (
@ -995,17 +1079,16 @@ export default function ActivityPrototypePage(): React.JSX.Element {
<section className="min-w-0 flex-1 overflow-hidden">
{selectedThread ? (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-4 pt-2 pb-3">
{/* Why (no header action button): per-card hover actions on the
thread list (Mark unread, Open) are the primary controls now,
so the header keeps just the thread identity. */}
<div className="flex shrink-0 items-start gap-4 border-b border-border px-4 pt-2 pb-3">
<div className="min-w-0">
<div className="flex min-w-0 items-start gap-2">
<span className="inline-flex shrink-0 pt-[3px]">
<AgentIcon agent={agentTypeToIconAgent(selectedThread.agentType)} size={16} />
</span>
{/* Why: clamp to 2 lines (matches ThreadRow) so longer
prompts don't get cut to one ellipsis. Badge moves to
the secondary line so it stays at a fixed position
regardless of title height. */}
<h2 className="line-clamp-2 break-words text-sm font-semibold leading-snug">
<h2 className="line-clamp-3 break-words text-sm font-semibold leading-snug">
{selectedThread.paneTitle}
</h2>
</div>
@ -1016,18 +1099,6 @@ export default function ActivityPrototypePage(): React.JSX.Element {
</span>
</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0 self-center"
onClick={() => {
markThreadRead(selectedThread)
activateAndRevealWorktree(selectedThread.worktree.id)
}}
>
Jump to workspace
</Button>
</div>
{/* Why: Terminal stays mounted in the hidden workspace tree while
Activity is open. This target lets that existing TerminalPane
@ -1093,7 +1164,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
) : (
<>
<TerminalSquare className="size-7" />
Select an agent to open its terminal.
Select an agent to view its activity
</>
)}
</div>

View File

@ -6,6 +6,10 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
// Why: keep the unread accumulator local; "Mark all read" moved to the
// thread-list overflow menu in ActivityPrototypePage so it lives next to
// the cards it acts on.
function useActivityUnreadCount(): number {
return useAppStore((s) => {
let count = 0
@ -36,21 +40,10 @@ function useActivityUnreadCount(): number {
export function ActivityTitlebarControls(): React.JSX.Element {
const unreadCount = useActivityUnreadCount()
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
const closeActivityPage = useAppStore((s) => s.closeActivityPage)
const markAllRead = (): void => {
const state = useAppStore.getState()
// Why: ack every pane (live + retained) — Date.now() exceeds all historical
// startedAt values, clearing the per-history-event unread flags too.
acknowledgeAgents([
...Object.values(state.agentStatusByPaneKey).map((entry) => entry.paneKey),
...Object.values(state.retainedAgentsByPaneKey).map((retained) => retained.entry.paneKey)
])
}
return (
<div className="flex h-full min-w-0 flex-1 items-center justify-between gap-3 border-l border-border px-3">
<div className="flex h-full min-w-0 flex-1 items-center gap-3 border-l border-border px-3">
<div
className="flex min-w-0 items-center gap-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
@ -80,14 +73,6 @@ export function ActivityTitlebarControls(): React.JSX.Element {
{unreadCount} unread
</Badge>
</div>
<div
className="flex shrink-0 items-center gap-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Button type="button" variant="ghost" size="xs" onClick={markAllRead}>
Mark all read
</Button>
</div>
</div>
)
}