From a81016b82d2c3f2f2b4efcd606033f4e28384c06 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 11 May 2026 15:42:25 -0700 Subject: [PATCH] Add experimental Activity page (#1703) --- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/renderer/src/App.tsx | 52 +- .../activity/ActivityPrototypePage.tsx | 914 ++++++++++++++++++ .../activity/ActivityTitlebarControls.tsx | 81 ++ .../components/settings/ExperimentalPane.tsx | 40 + .../settings/experimental-search.ts | 15 + .../src/components/sidebar/SidebarNav.tsx | 59 +- .../src/components/sidebar/WorktreeList.tsx | 7 +- src/renderer/src/hooks/resolve-zoom-target.ts | 2 +- src/renderer/src/lib/worktree-status.test.ts | 25 +- src/renderer/src/lib/worktree-status.ts | 21 +- src/renderer/src/store/slices/ui.ts | 37 +- src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 4 + 16 files changed, 1212 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/activity/ActivityPrototypePage.tsx create mode 100644 src/renderer/src/components/activity/ActivityTitlebarControls.tsx diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c678e5390..091d223fa 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -99,6 +99,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, + experimentalActivity: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index e54e37c0c..3538ec69e 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -92,6 +92,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, + experimentalActivity: false, experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e56eb26fb..7f1f0b799 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -26,6 +26,7 @@ import { useAppStore } from './store' import { useShallow } from 'zustand/react/shallow' import { useIpcEvents } from './hooks/useIpcEvents' import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' +import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' import Sidebar from './components/Sidebar' import Terminal from './components/Terminal' import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' @@ -127,6 +128,7 @@ function WindowControls(): React.JSX.Element { const Landing = lazy(() => import('./components/Landing')) const TaskPage = lazy(() => import('./components/TaskPage')) +const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('./components/settings/Settings')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) @@ -168,6 +170,7 @@ function App(): React.JSX.Element { toggleRightSidebar: s.toggleRightSidebar, setRightSidebarOpen: s.setRightSidebarOpen, setRightSidebarTab: s.setRightSidebarTab, + setActiveView: s.setActiveView, updateSettings: s.updateSettings, pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing @@ -196,6 +199,7 @@ function App(): React.JSX.Element { const isFullScreen = useAppStore((s) => s.isFullScreen) const settings = useAppStore((s) => s.settings) const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) + const activityEnabled = settings?.experimentalActivity === true const petVisible = useAppStore((s) => s.petVisible) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) @@ -569,13 +573,14 @@ function App(): React.JSX.Element { !hasTabBar && effectiveActiveTabExpanded const showSidebar = activeView !== 'settings' - // Why: when a worktree is active (split groups always enabled), the - // full-width titlebar is replaced by a sidebar-width left header so the - // terminal + tab groups extend to the very top of the window. - const workspaceActive = activeView !== 'settings' && activeWorktreeId !== null - // Why: suppress right sidebar controls on the tasks page since that surface - // is intentionally distraction-free (no right sidebar). - const showRightSidebarControls = activeView !== 'settings' && activeView !== 'tasks' + // 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. + const workspaceActive = activeView === 'terminal' && activeWorktreeId !== null + // Why: suppress right sidebar controls on full-page navigation surfaces + // since those surfaces intentionally own the full content area. + const showRightSidebarControls = + activeView !== 'settings' && activeView !== 'tasks' && activeView !== 'activity' const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -660,9 +665,9 @@ function App(): React.JSX.Element { // (contentEditable) or a browser guest webContents, both of which bypass // this renderer-side window keydown listener. - // Why: the tasks page should not be able to reveal the right sidebar at - // all, because that surface is intentionally distraction-free. - if (activeView === 'tasks') { + // Why: full-page navigation surfaces should not reveal the right sidebar; + // they are designed as distraction-free content areas. + if (activeView === 'tasks' || activeView === 'activity') { return } @@ -892,13 +897,19 @@ function App(): React.JSX.Element { ) : null useEffect(() => { - if (activeView === 'tasks' && rightSidebarOpen) { - // Why: hide the right sidebar immediately when entering the tasks page - // so a previous open state can't bleed into that distraction-free view. + if ((activeView === 'tasks' || activeView === 'activity') && rightSidebarOpen) { + // Why: hide the right sidebar immediately when entering full-page + // navigation views so previous side-panel state cannot occlude them. actions.setRightSidebarOpen(false) } }, [activeView, rightSidebarOpen, actions]) + useEffect(() => { + if (settings && !activityEnabled && activeView === 'activity') { + actions.setActiveView('terminal') + } + }, [activeView, activityEnabled, actions, settings]) + return (
{titlebarLeftControls}
-
+ {activeView === 'activity' && activityEnabled ? ( + + ) : ( +
+ )} {showTitlebarExpandButton && ( @@ -1055,6 +1070,9 @@ function App(): React.JSX.Element { {activeView === 'settings' ? : null} {activeView === 'tasks' ? : null} + {activeView === 'activity' && activityEnabled ? ( + + ) : null} {activeView === 'terminal' && !activeWorktreeId ? : null}
diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx new file mode 100644 index 000000000..404bf3534 --- /dev/null +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -0,0 +1,914 @@ +/* eslint-disable max-lines -- Why: this prototype keeps the real-data adapter +and current visual skeleton together until the next refinement pass decides +which pieces become production modules. */ +import React, { useEffect, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { + Bell, + BellDot, + BellOff, + GitBranch, + MessageSquareText, + Plus, + Search, + Settings +} from 'lucide-react' + +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' +import { useSidebarResize } from '@/hooks/useSidebarResize' +import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + 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 type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { + AgentStatusEntry, + AgentStatusState, + AgentType +} from '../../../../shared/agent-status-types' + +type ThreadReadFilter = 'all' | 'unread' +type ActivityDensity = 'compact' | 'comfortable' + +type AgentActivityEvent = { + id: string + kind: 'agent' + state: Extract + timestamp: number + worktree: Worktree + repo: Repo | null + entry: AgentStatusEntry + tab: TerminalTab + agentType: AgentType + agentAlive: boolean + unread: boolean +} + +type WorktreeActivityEvent = { + id: string + kind: 'worktree-created' + timestamp: number + worktree: Worktree + repo: Repo | null + unread: boolean +} + +type ActivityEvent = AgentActivityEvent | WorktreeActivityEvent + +type WorktreeThread = { + worktree: Worktree + repo: Repo | null + latestEvent: ActivityEvent + events: ActivityEvent[] + unread: boolean +} + +const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' +}) + +const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +function formatAbsoluteDate(timestamp: number): string { + return absoluteDateFormatter.format(new Date(timestamp)) +} + +function formatRelativeTime(timestamp: number): string { + const diffMs = timestamp - Date.now() + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return relativeTimeFormatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return relativeTimeFormatter.format(diffHours, 'hour') + } + const diffDays = Math.round(diffHours / 24) + return relativeTimeFormatter.format(diffDays, 'day') +} + +function asDotState(state: AgentStatusState): AgentDotState { + if (state === 'blocked' || state === 'waiting' || state === 'done') { + return state + } + return 'idle' +} + +function paneIdFromPaneKey(paneKey: string): number | null { + const colon = paneKey.indexOf(':') + const tail = colon > 0 ? paneKey.slice(colon + 1) : '' + const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function agentTitle(event: AgentActivityEvent): string { + if (event.state === 'done') { + return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished' + } + return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input' +} + +function agentSummary(event: AgentActivityEvent): string { + const prompt = event.entry.prompt.trim() + if (event.state === 'done') { + const message = event.entry.lastAssistantMessage?.trim() + return message || prompt || 'Completed the current turn.' + } + return prompt || event.entry.lastAssistantMessage?.trim() || 'The agent paused for user input.' +} + +function agentMeta(event: AgentActivityEvent): string { + const agent = formatAgentTypeLabel(event.agentType) + if (event.state === 'done') { + return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed` + } + return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` +} + +function eventTitle(event: ActivityEvent): string { + return event.kind === 'agent' ? agentTitle(event) : 'Worktree created' +} + +function eventSummary(event: ActivityEvent): string { + return event.kind === 'agent' ? agentSummary(event) : event.worktree.displayName +} + +function eventMeta(event: ActivityEvent): string { + if (event.kind === 'agent') { + return agentMeta(event) + } + return event.worktree.branch +} + +function buildActivityEvents(args: { + agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record + tabsByWorktree: Record + worktreeMap: Map + repoMap: Map + acknowledgedAgentsByPaneKey: Record + locallyReadEventIds: Set +}): ActivityEvent[] { + const events: ActivityEvent[] = [] + const tabContext = new Map() + + for (const worktree of args.worktreeMap.values()) { + const tabs = args.tabsByWorktree[worktree.id] ?? [] + for (const tab of tabs) { + tabContext.set(tab.id, { worktree, tab }) + } + if (worktree.createdAt) { + const id = `worktree-created:${worktree.id}` + events.push({ + id, + kind: 'worktree-created', + timestamp: worktree.createdAt, + worktree, + repo: args.repoMap.get(worktree.repoId) ?? null, + unread: worktree.isUnread && !args.locallyReadEventIds.has(id) + }) + } + } + + for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) { + if (entry.state !== 'done' && entry.state !== 'blocked' && entry.state !== 'waiting') { + continue + } + const separatorIndex = paneKey.indexOf(':') + if (separatorIndex <= 0) { + continue + } + const tabId = paneKey.slice(0, separatorIndex) + const context = tabContext.get(tabId) + if (!context) { + continue + } + const ackAt = args.acknowledgedAgentsByPaneKey[paneKey] ?? 0 + events.push({ + id: `agent-live:${paneKey}:${entry.stateStartedAt}`, + kind: 'agent', + state: entry.state, + timestamp: entry.stateStartedAt, + worktree: context.worktree, + repo: args.repoMap.get(context.worktree.repoId) ?? null, + entry, + tab: context.tab, + agentType: entry.agentType ?? 'unknown', + agentAlive: true, + unread: ackAt < entry.stateStartedAt + }) + } + + for (const [paneKey, retained] of Object.entries(args.retainedAgentsByPaneKey)) { + const worktree = args.worktreeMap.get(retained.worktreeId) + if (!worktree || retained.entry.state !== 'done') { + continue + } + const ackAt = args.acknowledgedAgentsByPaneKey[paneKey] ?? 0 + events.push({ + id: `agent-retained:${paneKey}:${retained.entry.stateStartedAt}`, + kind: 'agent', + state: 'done', + timestamp: retained.entry.stateStartedAt, + worktree, + repo: args.repoMap.get(worktree.repoId) ?? null, + entry: retained.entry, + tab: retained.tab, + agentType: retained.agentType, + agentAlive: false, + unread: ackAt < retained.entry.stateStartedAt + }) + } + + return events.sort((a, b) => b.timestamp - a.timestamp).slice(0, 80) +} + +function buildWorktreeThreads(events: ActivityEvent[]): WorktreeThread[] { + const byWorktreeId = new Map() + for (const event of events) { + const existing = byWorktreeId.get(event.worktree.id) + if (!existing) { + byWorktreeId.set(event.worktree.id, { + worktree: event.worktree, + repo: event.repo, + latestEvent: event, + events: [event], + unread: event.unread + }) + continue + } + existing.events.push(event) + existing.unread = existing.unread || event.unread + if (event.timestamp > existing.latestEvent.timestamp) { + existing.latestEvent = event + } + } + + return Array.from(byWorktreeId.values()) + .map((thread) => ({ + ...thread, + events: [...thread.events].sort((a, b) => a.timestamp - b.timestamp) + })) + .sort((a, b) => b.latestEvent.timestamp - a.latestEvent.timestamp) +} + +function EventTime({ timestamp }: { timestamp: number }): React.JSX.Element { + const absolute = formatAbsoluteDate(timestamp) + return ( + + + + + + {absolute} + + + ) +} + +function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null { + if (!repo) { + return null + } + return ( +
+
+ + {repo.displayName} + +
+ ) +} + +function AgentEventRow({ + event, + density, + onMarkRead +}: { + event: AgentActivityEvent + density: ActivityDensity + onMarkRead: (event: ActivityEvent) => void +}): React.JSX.Element { + const compact = density === 'compact' + const dotState = asDotState(event.state) + + const jumpToAgent = (clickEvent: React.MouseEvent): void => { + clickEvent.stopPropagation() + onMarkRead(event) + activateAndRevealWorktree(event.worktree.id) + activateTabAndFocusPane(event.tab.id, paneIdFromPaneKey(event.entry.paneKey)) + } + + return ( +
onMarkRead(event)} + > +
+
+ {event.unread ? ( + + ) : null} + + + + + + + + {event.entry.interrupted ? 'Interrupted' : agentStateLabel(dotState)} + + +
+
+ +
+
+ + {agentTitle(event)} + + + + + + + + + {formatAgentTypeLabel(event.agentType)} + + +
+
+ {agentSummary(event)} +
+
+ + + {event.worktree.displayName} + + {agentMeta(event)} +
+ {!compact && event.agentAlive ? ( +
+ +
+ ) : null} +
+ +
+ + {compact && event.agentAlive ? ( + + ) : null} +
+
+ ) +} + +function WorktreeEventRow({ + event, + density, + onMarkRead +}: { + event: WorktreeActivityEvent + density: ActivityDensity + onMarkRead: (event: ActivityEvent) => void +}): React.JSX.Element { + const compact = density === 'compact' + + return ( +
onMarkRead(event)} + > +
+
+ {event.unread ? ( + + ) : null} + +
+
+
+
+ + Worktree created + +
+
+ {event.worktree.displayName} +
+
+ + + {event.worktree.branch} + + {event.worktree.path} +
+
+
+ +
+
+ ) +} + +function ActivityRow({ + event, + density, + onMarkRead +}: { + event: ActivityEvent + density: ActivityDensity + onMarkRead: (event: ActivityEvent) => void +}): React.JSX.Element { + return event.kind === 'agent' ? ( + + ) : ( + + ) +} + +function groupForTimestamp(timestamp: number): 'Today' | 'Yesterday' | 'Earlier' { + const date = new Date(timestamp) + const today = new Date() + const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() + const startYesterday = startToday - 24 * 60 * 60 * 1000 + if (date.getTime() >= startToday) { + return 'Today' + } + if (date.getTime() >= startYesterday) { + return 'Yesterday' + } + return 'Earlier' +} + +function ThreadRow({ + thread, + density, + selected, + onSelect, + onToggleRead +}: { + thread: WorktreeThread + density: ActivityDensity + selected: boolean + onSelect: () => void + onToggleRead: () => void +}): React.JSX.Element { + const latest = thread.latestEvent + const compact = density === 'compact' + const toggleLabel = thread.unread ? 'Mark thread read' : 'Mark thread unread' + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelect() + } + }} + className={cn( + 'group relative grid w-full grid-cols-[minmax(0,1fr)_auto] gap-2 border-b border-border px-3 text-left transition-colors hover:bg-accent/40', + compact ? 'py-1.5' : 'py-2.5', + 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)]', + thread.unread && 'bg-primary/[0.045] dark:bg-primary/[0.08]' + )} + > + {thread.unread ? ( + + ) : null} + + + + {thread.worktree.displayName} + + + + + {eventTitle(latest)} + + {!compact ? ( + + {eventSummary(latest)} + + ) : null} + + + + + + + + + + + + + {toggleLabel} + + + + + {thread.unread ? ( + + New + + ) : null} + + {thread.events.length} + + + + +
+ ) +} + +export default function ActivityPrototypePage(): React.JSX.Element { + const [readFilter, setReadFilter] = useState('all') + const [leftSidebarCompact, setLeftSidebarCompact] = useState(true) + const leftSidebarDensity: ActivityDensity = leftSidebarCompact ? 'compact' : 'comfortable' + const [query, setQuery] = useState('') + const [locallyReadEventIds, setLocallyReadEventIds] = useState>(() => new Set()) + const [selectedWorktreeId, setSelectedWorktreeId] = useState(null) + const [threadListWidth, setThreadListWidth] = useState(340) + const { + containerRef: threadListRef, + isResizing: isThreadListResizing, + onResizeStart + } = useSidebarResize({ + isOpen: true, + width: threadListWidth, + minWidth: 280, + maxWidth: 560, + deltaSign: 1, + setWidth: setThreadListWidth + }) + + const storeData = useAppStore( + useShallow((s) => ({ + agentStatusByPaneKey: s.agentStatusByPaneKey, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + tabsByWorktree: s.tabsByWorktree, + worktreeMap: getWorktreeMapFromState(s), + repoMap: getRepoMapFromState(s), + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, + acknowledgeAgents: s.acknowledgeAgents, + unacknowledgeAgents: s.unacknowledgeAgents, + markWorktreeUnread: s.markWorktreeUnread, + clearWorktreeUnread: s.clearWorktreeUnread + })) + ) + + const allEvents = useMemo( + () => + buildActivityEvents({ + ...storeData, + locallyReadEventIds + }), + [storeData, locallyReadEventIds] + ) + + const allThreads = useMemo(() => buildWorktreeThreads(allEvents), [allEvents]) + + const visibleThreads = useMemo(() => { + const trimmedQuery = query.trim().toLowerCase() + return allThreads.filter((thread) => { + if (readFilter === 'unread' && !thread.unread) { + return false + } + if (!trimmedQuery) { + return true + } + const latest = thread.latestEvent + const text = + `${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${eventTitle(latest)} ${eventSummary(latest)} ${eventMeta(latest)}`.toLowerCase() + return text.includes(trimmedQuery) + }) + }, [allThreads, readFilter, query]) + + useEffect(() => { + if (visibleThreads.length === 0) { + setSelectedWorktreeId(null) + return + } + if ( + !selectedWorktreeId || + !visibleThreads.some((thread) => thread.worktree.id === selectedWorktreeId) + ) { + setSelectedWorktreeId(visibleThreads[0].worktree.id) + } + }, [selectedWorktreeId, visibleThreads]) + + const selectedThread = + visibleThreads.find((thread) => thread.worktree.id === selectedWorktreeId) ?? + visibleThreads[0] ?? + null + + const selectedGroupedEvents = useMemo(() => { + const groups: Record<'Today' | 'Yesterday' | 'Earlier', ActivityEvent[]> = { + Today: [], + Yesterday: [], + Earlier: [] + } + if (!selectedThread) { + return groups + } + for (const event of selectedThread.events) { + groups[groupForTimestamp(event.timestamp)].push(event) + } + return groups + }, [selectedThread]) + + const selectThread = (thread: WorktreeThread): void => { + setSelectedWorktreeId(thread.worktree.id) + markThreadRead(thread) + } + + const markThreadRead = (thread: WorktreeThread): void => { + const agentPaneKeys = thread.events.flatMap((event) => + event.kind === 'agent' ? [event.entry.paneKey] : [] + ) + storeData.acknowledgeAgents(agentPaneKeys) + if (thread.events.some((event) => event.kind === 'worktree-created')) { + storeData.clearWorktreeUnread(thread.worktree.id) + } + setLocallyReadEventIds((current) => { + const next = new Set(current) + for (const event of thread.events) { + next.add(event.id) + } + return next + }) + } + + const markThreadUnread = (thread: WorktreeThread): void => { + const agentPaneKeys = thread.events.flatMap((event) => + event.kind === 'agent' ? [event.entry.paneKey] : [] + ) + storeData.unacknowledgeAgents(agentPaneKeys) + if (thread.events.some((event) => event.kind === 'worktree-created')) { + storeData.markWorktreeUnread(thread.worktree.id) + } + setLocallyReadEventIds((current) => { + const next = new Set(current) + for (const event of thread.events) { + next.delete(event.id) + } + return next + }) + } + + const toggleThreadRead = (thread: WorktreeThread): void => { + if (thread.unread) { + markThreadRead(thread) + return + } + markThreadUnread(thread) + } + + const markRead = (event: ActivityEvent): void => { + if (event.kind === 'agent') { + storeData.acknowledgeAgents([event.entry.paneKey]) + return + } + setLocallyReadEventIds((current) => new Set(current).add(event.id)) + storeData.clearWorktreeUnread(event.worktree.id) + } + + return ( +
+
+ + +
+ {selectedThread ? ( +
+
+
+
+

+ {selectedThread.worktree.displayName} +

+ +
+
+ Complete worktree history, from creation through latest agent updates +
+
+ +
+
+ {(['Today', 'Yesterday', 'Earlier'] as const).map((group) => + selectedGroupedEvents[group].length > 0 ? ( +
+
+ {group} +
+ {[...selectedGroupedEvents[group]].reverse().map((event) => ( + + ))} +
+ ) : null + )} +
+
+ ) : ( +
+ + No activity yet. +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/activity/ActivityTitlebarControls.tsx b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx new file mode 100644 index 000000000..10f4010c6 --- /dev/null +++ b/src/renderer/src/components/activity/ActivityTitlebarControls.tsx @@ -0,0 +1,81 @@ +import { Bell } from 'lucide-react' + +import { useAppStore } from '@/store' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +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) { + 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 + } + } + 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 + } + } + return count + }) +} + +export function ActivityTitlebarControls(): React.JSX.Element { + const unreadCount = useActivityUnreadCount() + const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents) + const clearWorktreeUnread = useAppStore((s) => s.clearWorktreeUnread) + + const markAllRead = (): void => { + const state = useAppStore.getState() + acknowledgeAgents([ + ...Object.values(state.agentStatusByPaneKey) + .filter( + (entry) => + entry.state === 'done' || entry.state === 'blocked' || entry.state === 'waiting' + ) + .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 ( +
+
+ + Activity + + {unreadCount} unread + +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 5b0655e9a..44eb80e7b 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -36,6 +36,7 @@ export function ExperimentalPane({ const showOrchestration = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_SEARCH_ENTRY.orchestration ]) + const showActivity = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_SEARCH_ENTRY.activity]) const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_SEARCH_ENTRY.symlinks ]) @@ -249,6 +250,45 @@ export function ExperimentalPane({ ) : null} + {showActivity ? ( + +
+
+ +

+ Adds an Activity entry under Tasks with a threaded worktree feed for completed + agents, blocking questions, unread state, and worktree creation events. Experimental + — the event model and UI may change. +

+
+ +
+
+ ) : null} + {showWorktreeSymlinks ? ( s.openTaskPage) + const openActivityPage = useAppStore((s) => s.openActivityPage) const openModal = useAppStore((s) => s.openModal) const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) @@ -19,6 +20,7 @@ const SidebarNav = React.memo(function SidebarNav() { // Why: the setting is opt-out (default true). `!== false` keeps the button // visible for users whose persisted settings predate this field. const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) + const showActivityButton = useAppStore((s) => s.settings?.experimentalActivity === true) // Why: warm the GitHub work-item cache on hover/focus so by the time the // user's click finishes the round-trip has either completed or is already @@ -48,6 +50,37 @@ const SidebarNav = React.memo(function SidebarNav() { }, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, repos]) const tasksActive = activeView === 'tasks' + const activityActive = activeView === 'activity' + const activityUnreadCount = useAppStore((s) => { + if (s.settings?.experimentalActivity !== true) { + return 0 + } + let count = 0 + for (const worktrees of Object.values(s.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.createdAt && worktree.isUnread) { + 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 + } + } + 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 + } + } + return count + }) return (
@@ -109,6 +142,30 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} + {showActivityButton ? ( + + ) : null}