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])