From f8527a42c433f9654e61abc5aa29fbc4213dc7e8 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Tue, 12 May 2026 17:32:37 -0700 Subject: [PATCH] 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 --- src/renderer/src/App.tsx | 14 ++-- src/renderer/src/components/AgentStateDot.tsx | 11 +-- src/renderer/src/components/Terminal.tsx | 29 ++++--- .../activity/ActivityPrototypePage.tsx | 77 ++++++++++++++++--- .../activity/ActivityTitlebarControls.tsx | 74 ++++++++++-------- .../activity/activity-terminal-portal.ts | 52 ++++++------- 6 files changed, 162 insertions(+), 95 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cf1f063ec8a..ed470ef12f9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { )} {/* 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') && (
diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 821efc848aa..cd7d20ff534 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -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 ( diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index bdcc4ea4973..b786c89c460 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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] ?? []) : []), diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 30a40fd4661..1ea70094083 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -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() + 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 {
{selectedThread ? (
-
+
@@ -734,15 +764,42 @@ export default function ActivityPrototypePage(): React.JSX.Element {
+
{/* 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. */} -
+ {(() => { + // 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 ( +
+ + Agent terminal closed. Open a new terminal in this workspace to continue. +
+ ) + } + return ( +
+ ) + })()}
) : (
diff --git a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx index 10f4010c6f7..47e25fb399d 100644 --- a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx +++ b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx @@ -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 (
-
+
+ {/* 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. */} + + + + + + Close activity + + Activity diff --git a/src/renderer/src/components/activity/activity-terminal-portal.ts b/src/renderer/src/components/activity/activity-terminal-portal.ts index 7de94042985..d663abcae92 100644 --- a/src/renderer/src/components/activity/activity-terminal-portal.ts +++ b/src/renderer/src/components/activity/activity-terminal-portal.ts @@ -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(null) + const [target, setTarget] = useState(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])