fix(activity): tighten portal target, unread count, and selection edges

- activity-terminal-portal: replace document.body MutationObserver with a
  module-level pub/sub registry; the page publishes the target via a ref
  callback, Terminal subscribes. Removes body-wide DOM observation.
- Terminal: memoize activityTerminalPortal so WorktreeSplitSurface's
  React.memo bail-out is preserved across unrelated Terminal re-renders.
- ActivityPrototypePage: cap events per-pane (5) so a chatty pane can't
  push quiet panes off the left list; render an empty-state when a
  retained thread's tab is gone; skip workspace mutation in
  activateThreadTerminal when the tab is no longer live; keep the
  selected thread visible in unread-only mode after auto-mark-read.
- ActivityTitlebarControls: walk stateHistory in the unread accumulator
  (mirrors the new feed semantics); drop worktree-created counting since
  the new feed has no surface to dismiss them.
- AgentStateDot: remove the dead 'lg' size variant.
- App: hide worktree sidebar on the Activity view and include it in the
  back/forward shortcut + button cluster.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-12 17:32:37 -07:00
co-authored by Orca
parent 84bcec9053
commit f8527a42c4
6 changed files with 162 additions and 95 deletions
+9 -5
View File
@@ -572,7 +572,10 @@ function App(): React.JSX.Element {
activeWorktreeId !== null &&
!hasTabBar &&
effectiveActiveTabExpanded
const showSidebar = activeView !== 'settings'
// Why: Activity is a full-page navigation surface — same treatment as
// Settings — so the worktree sidebar is removed for that view, letting the
// thread list + agent terminal span edge-to-edge.
const showSidebar = activeView !== 'settings' && activeView !== 'activity'
// Why: only the terminal workspace replaces the full-width titlebar with
// split-column chrome. Full-page navigation views keep the draggable app
// titlebar so their page-level controls can live in that window strip.
@@ -635,7 +638,7 @@ function App(): React.JSX.Element {
// Why: Back/Forward traverse mixed worktree + Tasks visits, so the
// shortcut is active wherever the titlebar button cluster is (terminal
// or tasks). Still suppressed in Settings to keep that view modal-ish.
if (activeView !== 'terminal' && activeView !== 'tasks') {
if (activeView !== 'terminal' && activeView !== 'tasks' && activeView !== 'activity') {
return
}
e.preventDefault()
@@ -840,9 +843,10 @@ function App(): React.JSX.Element {
)}
</div>
{/* Why: Back/Forward traverse mixed worktree + Tasks history, so the
cluster is shown wherever the history shortcut is live (terminal or
tasks). Hidden in Settings to keep that view modal-ish. */}
{(activeView === 'terminal' || activeView === 'tasks') && (
cluster is shown wherever the history shortcut is live (terminal,
tasks, or activity — selecting an agent there records a worktree
visit). Hidden in Settings to keep that view modal-ish. */}
{(activeView === 'terminal' || activeView === 'tasks' || activeView === 'activity') && (
<div className="ml-auto mr-3 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
@@ -44,7 +44,7 @@ export function agentStateLabel(state: AgentDotState): string {
type Props = {
state: AgentDotState
size?: 'sm' | 'md' | 'lg'
size?: 'sm' | 'md'
className?: string
}
@@ -53,12 +53,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({
size = 'sm',
className
}: Props): React.JSX.Element {
// Why ('lg' size): the Activity page renders this in its own 32px column
// alongside text rows, where 'md' (12px) reads small and floats. 'lg' (18px)
// sits center of mass in that cell.
const box = size === 'lg' ? 'h-[18px] w-[18px]' : size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
const inner = size === 'lg' ? 'size-3' : size === 'md' ? 'size-2' : 'size-1.5'
const icon = size === 'lg' ? 'size-[18px]' : size === 'md' ? 'size-3' : 'size-2.5'
const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
const inner = size === 'md' ? 'size-2' : 'size-1.5'
const icon = size === 'md' ? 'size-3' : 'size-2.5'
if (state === 'working') {
return (
+17 -12
View File
@@ -107,18 +107,23 @@ function Terminal(): React.JSX.Element | null {
const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree)
const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined
const activityTerminalPortalTarget = useActivityTerminalPortalTarget(activeView === 'activity')
const activityTerminalPortal: ActivityTerminalPortalTarget | null =
activeView === 'activity' &&
activeTabType === 'terminal' &&
activeWorktreeId &&
activeTabId &&
activityTerminalPortalTarget
? {
target: activityTerminalPortalTarget,
worktreeId: activeWorktreeId,
tabId: activeTabId
}
: null
// Why: stable identity preserves WorktreeSplitSurface's React.memo bail-out across unrelated Terminal re-renders.
const activityTerminalPortal: ActivityTerminalPortalTarget | null = useMemo(() => {
if (
activeView !== 'activity' ||
activeTabType !== 'terminal' ||
!activeWorktreeId ||
!activeTabId ||
!activityTerminalPortalTarget
) {
return null
}
return {
target: activityTerminalPortalTarget,
worktreeId: activeWorktreeId,
tabId: activeTabId
}
}, [activeView, activeTabType, activeWorktreeId, activeTabId, activityTerminalPortalTarget])
const tabs = useMemo(
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
@@ -16,6 +16,7 @@ import {
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
@@ -34,7 +35,7 @@ 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 { ACTIVITY_TERMINAL_PORTAL_TARGET_ID } from './activity-terminal-portal'
import { setActivityTerminalPortalTarget } from './activity-terminal-portal'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import type {
AgentStateHistoryEntry,
@@ -161,6 +162,9 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt
return state === 'done' || state === 'blocked' || state === 'waiting'
}
// Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history.
const EVENTS_PER_PANE_CAP = 5
function historyEntrySnapshot(
entry: AgentStatusEntry,
history: AgentStateHistoryEntry
@@ -309,7 +313,22 @@ export function buildActivityEvents(args: {
})
}
return events.sort((a, b) => b.timestamp - a.timestamp).slice(0, 80)
const sorted = events.sort((a, b) => b.timestamp - a.timestamp)
const perPaneCount = new Map<string, number>()
const capped: ActivityEvent[] = []
for (const event of sorted) {
const paneKey = event.entry.paneKey
const count = perPaneCount.get(paneKey) ?? 0
if (count >= EVENTS_PER_PANE_CAP) {
continue
}
perPaneCount.set(paneKey, count + 1)
capped.push(event)
if (capped.length >= 80) {
break
}
}
return capped
}
function buildAgentPaneThreads(events: ActivityEvent[]): AgentPaneThread[] {
@@ -543,7 +562,10 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const visibleThreads = useMemo(() => {
const trimmedQuery = query.trim().toLowerCase()
return allThreads.filter((thread) => {
if (readFilter === 'unread' && !thread.unread) {
// Why: keep the just-selected thread visible even after auto-mark-read
// flips it to read, otherwise clicking a row in unread-only mode makes it
// vanish from the left list while staying selected on the right.
if (readFilter === 'unread' && !thread.unread && thread.paneKey !== selectedPaneKey) {
return false
}
if (!trimmedQuery) {
@@ -554,7 +576,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
`${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`.toLowerCase()
return text.includes(trimmedQuery)
})
}, [allThreads, readFilter, query])
}, [allThreads, readFilter, query, selectedPaneKey])
useEffect(() => {
if (selectedPaneKey && !allThreads.some((thread) => thread.paneKey === selectedPaneKey)) {
@@ -576,6 +598,14 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const activateThreadTerminal = (thread: AgentPaneThread): void => {
const state = useAppStore.getState()
// Why: retained-agent threads can outlive their tab. With no live tab, the
// right pane shows the empty-state placeholder; reorienting the workspace
// and dispatching focus to a dead tab id would just confuse the user.
const liveTabs = state.tabsByWorktree[thread.worktree.id] ?? []
const hasLiveTab = liveTabs.some((t) => t.id === thread.latestEvent.tab.id)
if (!hasLiveTab) {
return
}
if (state.activeRepoId !== thread.worktree.repoId) {
state.setActiveRepo(thread.worktree.repoId)
}
@@ -713,7 +743,7 @@ 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="shrink-0 border-b border-border px-4 pt-2 pb-3">
<div className="flex shrink-0 items-start justify-between 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]">
@@ -734,15 +764,42 @@ export default function ActivityPrototypePage(): React.JSX.Element {
</span>
</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0"
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
move here instead of creating a second PTY/xterm owner. */}
<div
id={ACTIVITY_TERMINAL_PORTAL_TARGET_ID}
className="relative min-h-0 flex-1 overflow-hidden bg-editor-surface"
data-activity-terminal-tab-id={selectedThread.latestEvent.tab.id}
/>
{(() => {
// Why: retained threads can outlive their tab; portal needs a live TerminalPane to render into.
const liveTabs = storeData.tabsByWorktree[selectedThread.worktree.id] ?? []
const hasLiveTab = liveTabs.some((t) => t.id === selectedThread.latestEvent.tab.id)
if (!hasLiveTab) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-4 text-sm text-muted-foreground">
<TerminalSquare className="size-7" />
Agent terminal closed. Open a new terminal in this workspace to continue.
</div>
)
}
return (
<div
ref={setActivityTerminalPortalTarget}
className="relative min-h-0 flex-1 overflow-hidden bg-editor-surface"
data-activity-terminal-tab-id={selectedThread.latestEvent.tab.id}
/>
)
})()}
</div>
) : (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
@@ -1,34 +1,34 @@
import { Bell } from 'lucide-react'
import { ArrowLeft, Bell } from 'lucide-react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
function useActivityUnreadCount(): number {
return useAppStore((s) => {
let count = 0
for (const worktrees of Object.values(s.worktreesByRepo)) {
for (const worktree of worktrees) {
if (worktree.createdAt && worktree.isUnread) {
const isUnreadState = (state: AgentStatusState): boolean =>
state === 'done' || state === 'blocked' || state === 'waiting'
// Why: Activity feed surfaces every historical done/blocked/waiting event from
// stateHistory plus the live state, so the badge must mirror the same walk —
// counting only on entry.state under-counts panes that started a new turn.
const accumulate = (entry: AgentStatusEntry, ackAt: number): void => {
for (const history of entry.stateHistory) {
if (isUnreadState(history.state) && ackAt < history.startedAt) {
count += 1
}
}
if (isUnreadState(entry.state) && ackAt < entry.stateStartedAt) {
count += 1
}
}
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
if (entry.state !== 'done' && entry.state !== 'blocked' && entry.state !== 'waiting') {
continue
}
if ((s.acknowledgedAgentsByPaneKey[paneKey] ?? 0) < entry.stateStartedAt) {
count += 1
}
accumulate(entry, s.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
for (const [paneKey, retained] of Object.entries(s.retainedAgentsByPaneKey)) {
if (retained.entry.state !== 'done') {
continue
}
if ((s.acknowledgedAgentsByPaneKey[paneKey] ?? 0) < retained.entry.stateStartedAt) {
count += 1
}
accumulate(retained.entry, s.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
return count
})
@@ -37,31 +37,43 @@ function useActivityUnreadCount(): number {
export function ActivityTitlebarControls(): React.JSX.Element {
const unreadCount = useActivityUnreadCount()
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
const clearWorktreeUnread = useAppStore((s) => s.clearWorktreeUnread)
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)
.filter(
(entry) =>
entry.state === 'done' || entry.state === 'blocked' || entry.state === 'waiting'
)
.map((entry) => entry.paneKey),
...Object.values(state.agentStatusByPaneKey).map((entry) => entry.paneKey),
...Object.values(state.retainedAgentsByPaneKey).map((retained) => retained.entry.paneKey)
])
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
if (worktree.createdAt && worktree.isUnread) {
clearWorktreeUnread(worktree.id)
}
}
}
}
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 min-w-0 items-center gap-2">
<div
className="flex min-w-0 items-center gap-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{/* Why: Activity hides the worktree sidebar (full-page surface), so the
sidebar's nav row isn't available as the back path. This Back button
is the dedicated exit, mirroring Settings' onBack pattern. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={closeActivityPage}
aria-label="Close activity"
>
<ArrowLeft className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Close activity
</TooltipContent>
</Tooltip>
<Bell className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">Activity</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[11px] font-normal">
@@ -1,48 +1,40 @@
import { useLayoutEffect, useState } from 'react'
export const ACTIVITY_TERMINAL_PORTAL_TARGET_ID = 'activity-terminal-portal-target'
export type ActivityTerminalPortalTarget = {
target: HTMLElement
worktreeId: string
tabId: string
}
let currentTarget: HTMLElement | null = null
const subscribers = new Set<(target: HTMLElement | null) => void>()
// Why: replaces a body-wide MutationObserver. ActivityPrototypePage owns the
// target div's lifecycle and publishes here on mount/unmount; Terminal
// subscribes. Avoids global DOM observation while activity is open.
export function setActivityTerminalPortalTarget(target: HTMLElement | null): void {
if (currentTarget === target) {
return
}
currentTarget = target
for (const subscriber of subscribers) {
subscriber(target)
}
}
export function useActivityTerminalPortalTarget(enabled: boolean): HTMLElement | null {
const [target, setTarget] = useState<HTMLElement | null>(null)
const [target, setTarget] = useState<HTMLElement | null>(enabled ? currentTarget : null)
useLayoutEffect(() => {
if (!enabled || typeof document === 'undefined') {
if (!enabled) {
setTarget(null)
return
}
let animationFrame: number | null = null
const syncTarget = (): void => {
const nextTarget = document.getElementById(ACTIVITY_TERMINAL_PORTAL_TARGET_ID)
setTarget((currentTarget) => (currentTarget === nextTarget ? currentTarget : nextTarget))
}
const scheduleSync = (): void => {
if (animationFrame !== null) {
return
}
animationFrame = requestAnimationFrame(() => {
animationFrame = null
syncTarget()
})
}
syncTarget()
// Why: ActivityPrototypePage is a lazy sibling of Terminal, so the portal
// target can appear/disappear without Terminal itself remounting.
const observer = new MutationObserver(scheduleSync)
observer.observe(document.body, { childList: true, subtree: true })
setTarget(currentTarget)
const subscriber = (next: HTMLElement | null): void => setTarget(next)
subscribers.add(subscriber)
return () => {
observer.disconnect()
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame)
}
subscribers.delete(subscriber)
}
}, [enabled])