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:
parent
96f35dcfaf
commit
f4009fdaa9
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue