Add experimental Activity page (#1703)

This commit is contained in:
Neil 2026-05-11 15:42:25 -07:00 committed by GitHub
parent c45712cead
commit a81016b82d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1212 additions and 49 deletions

View File

@ -99,6 +99,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalActivity: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',

View File

@ -92,6 +92,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalActivity: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',

View File

@ -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 (
<div
className="flex flex-col h-screen w-screen overflow-hidden"
@ -939,10 +950,14 @@ function App(): React.JSX.Element {
>
{titlebarLeftControls}
</div>
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
{activeView === 'activity' && activityEnabled ? (
<ActivityTitlebarControls />
) : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
@ -1055,6 +1070,9 @@ function App(): React.JSX.Element {
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'activity' && activityEnabled ? (
<ActivityPrototypePage />
) : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
</div>

View File

@ -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<AgentStatusState, 'done' | 'blocked' | 'waiting'>
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<string, AgentStatusEntry>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
tabsByWorktree: Record<string, TerminalTab[]>
worktreeMap: Map<string, Worktree>
repoMap: Map<string, Repo>
acknowledgedAgentsByPaneKey: Record<string, number>
locallyReadEventIds: Set<string>
}): ActivityEvent[] {
const events: ActivityEvent[] = []
const tabContext = new Map<string, { worktree: Worktree; tab: TerminalTab }>()
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<string, WorktreeThread>()
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
aria-label={absolute}
onClick={(event) => event.stopPropagation()}
>
{formatRelativeTime(timestamp)}
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{absolute}
</TooltipContent>
</Tooltip>
)
}
function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null {
if (!repo) {
return null
}
return (
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 py-0.5 dark:border-border/60 dark:bg-accent/50">
<div className="size-1.5 rounded-full" style={{ backgroundColor: repo.badgeColor }} />
<span className="max-w-[6rem] truncate text-[10px] font-semibold leading-none text-foreground lowercase">
{repo.displayName}
</span>
</div>
)
}
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 (
<div
className={cn(
'group grid grid-cols-[2rem_minmax(0,1fr)_7.25rem] gap-3 border-b border-border px-3 transition-colors hover:bg-accent/40',
compact ? 'py-2' : 'py-3.5',
event.unread && 'bg-accent/20'
)}
onClick={() => onMarkRead(event)}
>
<div className="flex justify-center pt-1">
<div className="relative flex size-6 items-center justify-center">
{event.unread ? (
<span className="absolute -left-1 top-1 size-2 rounded-full bg-primary" />
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<AgentStateDot state={dotState} size="md" />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{event.entry.interrupted ? 'Interrupted' : agentStateLabel(dotState)}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('truncate text-sm', event.unread ? 'font-semibold' : 'font-medium')}>
{agentTitle(event)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<AgentIcon agent={agentTypeToIconAgent(event.agentType)} size={14} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{formatAgentTypeLabel(event.agentType)}
</TooltipContent>
</Tooltip>
</div>
<div
className={cn(
'mt-0.5 truncate text-sm text-muted-foreground',
compact ? 'max-w-[760px]' : 'max-w-[920px]'
)}
>
{agentSummary(event)}
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{event.worktree.displayName}</span>
</span>
<span>{agentMeta(event)}</span>
</div>
{!compact && event.agentAlive ? (
<div className="mt-2 flex flex-wrap gap-1.5">
<Button type="button" variant="outline" size="xs" onClick={jumpToAgent}>
Jump to agent
</Button>
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-2 pt-0.5">
<EventTime timestamp={event.timestamp} />
{compact && event.agentAlive ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={jumpToAgent}
className="opacity-0 transition-opacity group-hover:opacity-100"
>
Agent
</Button>
) : null}
</div>
</div>
)
}
function WorktreeEventRow({
event,
density,
onMarkRead
}: {
event: WorktreeActivityEvent
density: ActivityDensity
onMarkRead: (event: ActivityEvent) => void
}): React.JSX.Element {
const compact = density === 'compact'
return (
<div
className={cn(
'group grid grid-cols-[2rem_minmax(0,1fr)_7.25rem] gap-3 border-b border-border px-3 transition-colors hover:bg-accent/40',
compact ? 'py-2' : 'py-3.5',
event.unread && 'bg-accent/20'
)}
onClick={() => onMarkRead(event)}
>
<div className="flex justify-center pt-0.5">
<div className="relative flex size-6 items-center justify-center text-muted-foreground">
{event.unread ? (
<span className="absolute -left-1 top-1 size-2 rounded-full bg-primary" />
) : null}
<Plus className="size-4" />
</div>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('truncate text-sm', event.unread ? 'font-semibold' : 'font-medium')}>
Worktree created
</span>
</div>
<div className="mt-0.5 truncate text-sm text-muted-foreground">
{event.worktree.displayName}
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{event.worktree.branch}</span>
</span>
<span>{event.worktree.path}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2 pt-0.5">
<EventTime timestamp={event.timestamp} />
</div>
</div>
)
}
function ActivityRow({
event,
density,
onMarkRead
}: {
event: ActivityEvent
density: ActivityDensity
onMarkRead: (event: ActivityEvent) => void
}): React.JSX.Element {
return event.kind === 'agent' ? (
<AgentEventRow event={event} density={density} onMarkRead={onMarkRead} />
) : (
<WorktreeEventRow event={event} density={density} onMarkRead={onMarkRead} />
)
}
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 (
<div
data-current={selected ? 'true' : undefined}
onClick={onSelect}
role="button"
tabIndex={0}
onKeyDown={(event) => {
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 ? (
<span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r-full bg-primary" />
) : null}
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'truncate text-sm',
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
)}
>
{thread.worktree.displayName}
</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5">
<EventRepoBadge repo={thread.repo} />
<span className="truncate text-xs text-muted-foreground">{eventTitle(latest)}</span>
</span>
{!compact ? (
<span className="mt-1 block truncate text-xs text-muted-foreground">
{eventSummary(latest)}
</span>
) : null}
</span>
<span className={cn('flex flex-col items-end pt-0.5', compact ? 'gap-1' : 'gap-2')}>
<span className="flex min-w-16 flex-col items-end gap-1">
<span className="relative flex h-6 min-w-16 items-start justify-end">
<span className="transition-opacity group-hover:opacity-0">
<EventTime timestamp={latest.timestamp} />
</span>
<span className="absolute right-0 top-0 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label={toggleLabel}
onClick={(event) => {
event.stopPropagation()
onToggleRead()
}}
onMouseDown={(event) => event.stopPropagation()}
>
{thread.unread ? <BellOff className="size-3" /> : <Bell className="size-3" />}
</Button>
</TooltipTrigger>
<TooltipContent side="left">{toggleLabel}</TooltipContent>
</Tooltip>
</span>
</span>
<span className="inline-flex items-center gap-1">
{thread.unread ? (
<span className="rounded-full bg-primary px-1.5 py-0.5 text-[9px] font-semibold leading-none text-primary-foreground">
New
</span>
) : null}
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-normal">
{thread.events.length}
</Badge>
</span>
</span>
</span>
</div>
)
}
export default function ActivityPrototypePage(): React.JSX.Element {
const [readFilter, setReadFilter] = useState<ThreadReadFilter>('all')
const [leftSidebarCompact, setLeftSidebarCompact] = useState(true)
const leftSidebarDensity: ActivityDensity = leftSidebarCompact ? 'compact' : 'comfortable'
const [query, setQuery] = useState('')
const [locallyReadEventIds, setLocallyReadEventIds] = useState<Set<string>>(() => new Set())
const [selectedWorktreeId, setSelectedWorktreeId] = useState<string | null>(null)
const [threadListWidth, setThreadListWidth] = useState(340)
const {
containerRef: threadListRef,
isResizing: isThreadListResizing,
onResizeStart
} = useSidebarResize<HTMLDivElement>({
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 (
<div className="flex h-full min-h-0 flex-col bg-background px-4 py-3">
<main className="flex min-h-0 flex-1 overflow-hidden">
<aside
ref={threadListRef}
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 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Worktrees
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Activity list display options"
className="text-muted-foreground hover:text-foreground"
>
<Settings className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="min-w-44">
<DropdownMenuLabel>Display style</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={leftSidebarCompact}
onCheckedChange={(checked) => setLeftSidebarCompact(checked === true)}
>
Compact list
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<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" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter..."
className="h-8 w-full pl-7 text-xs"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Toggle
pressed={readFilter === 'unread'}
onPressedChange={(pressed) => setReadFilter(pressed ? 'unread' : 'all')}
variant="outline"
size="sm"
className={cn(
'size-8 shrink-0 p-0',
readFilter === 'unread'
? '!border-primary !bg-primary !text-primary-foreground shadow-xs ring-2 ring-primary/35 hover:!bg-primary/90 hover:!text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label="Show unread threads only"
>
<BellDot className="size-3.5" />
</Toggle>
</TooltipTrigger>
<TooltipContent side="bottom">Show unread threads only</TooltipContent>
</Tooltip>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto scrollbar-sleek">
{visibleThreads.map((thread) => (
<ThreadRow
key={thread.worktree.id}
thread={thread}
density={leftSidebarDensity}
selected={thread.worktree.id === selectedThread?.worktree.id}
onSelect={() => selectThread(thread)}
onToggleRead={() => toggleThreadRead(thread)}
/>
))}
{visibleThreads.length === 0 ? (
<div className="px-3 py-8 text-sm text-muted-foreground">
No worktree threads match these filters.
</div>
) : null}
</div>
<div
aria-label="Resize activity thread list"
title="Drag to resize"
className={cn(
'group absolute -right-1.5 top-0 z-20 flex h-full w-3 cursor-col-resize items-stretch justify-center',
isThreadListResizing && 'bg-ring/10'
)}
onMouseDown={onResizeStart}
role="separator"
>
<div
className={cn(
'h-full w-px bg-border transition-colors group-hover:bg-ring/50',
isThreadListResizing && 'bg-ring'
)}
/>
</div>
</aside>
<section className="min-w-0 flex-1 overflow-hidden pt-2">
{selectedThread ? (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-border px-4 pb-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold">
{selectedThread.worktree.displayName}
</h2>
<EventRepoBadge repo={selectedThread.repo} />
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
Complete worktree history, from creation through latest agent updates
</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => {
markThreadRead(selectedThread)
activateAndRevealWorktree(selectedThread.worktree.id)
}}
>
Jump to worktree
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto scrollbar-sleek">
{(['Today', 'Yesterday', 'Earlier'] as const).map((group) =>
selectedGroupedEvents[group].length > 0 ? (
<section key={group}>
<div className="border-b border-border px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group}
</div>
{[...selectedGroupedEvents[group]].reverse().map((event) => (
<ActivityRow
key={event.id}
event={event}
density="comfortable"
onMarkRead={markRead}
/>
))}
</section>
) : null
)}
</div>
</div>
) : (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<MessageSquareText className="size-7" />
No activity yet.
</div>
)}
</section>
</main>
</div>
)
}

View File

@ -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 (
<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">
<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">
{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>
)
}

View File

@ -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({
</SearchableSetting>
) : null}
{showActivity ? (
<SearchableSetting
title="Activity Page"
description="Slack-style worktree activity feed for agent completions and blocking states."
keywords={EXPERIMENTAL_SEARCH_ENTRY.activity.keywords}
className="space-y-3 px-1 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Activity Page</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalActivity}
onClick={() =>
updateSettings({
experimentalActivity: !settings.experimentalActivity
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalActivity ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalActivity ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</SearchableSetting>
) : null}
{showWorktreeSymlinks ? (
<SearchableSetting
title="Symlinks on worktrees"

View File

@ -50,6 +50,20 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'coordinator'
]
},
{
title: 'Activity Page',
description: 'Slack-style worktree activity feed for agent completions and blocking states.',
keywords: [
'experimental',
'activity',
'notifications',
'agents',
'worktrees',
'timeline',
'unread',
'bell'
]
},
{
title: 'Symlinks on worktrees',
description:
@ -84,5 +98,6 @@ export const EXPERIMENTAL_SEARCH_ENTRY = {
mobile: findEntry('Mobile Pairing'),
pet: findEntry('Pet'),
orchestration: findEntry('Agent Orchestration'),
activity: findEntry('Activity Page'),
symlinks: findEntry('Symlinks on worktrees')
} as const

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Github, List, Search } from 'lucide-react'
import { Bell, Github, List, Search } from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
@ -11,6 +11,7 @@ const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('
const SidebarNav = React.memo(function SidebarNav() {
const openTaskPage = useAppStore((s) => 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 (
<div className="flex flex-col gap-0.5 px-2 pt-2 pb-1">
@ -109,6 +142,30 @@ const SidebarNav = React.memo(function SidebarNav() {
</span>
</button>
) : null}
{showActivityButton ? (
<button
type="button"
onClick={openActivityPage}
aria-current={activityActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
activityActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<Bell
className={cn('size-4 shrink-0', !activityActive && 'text-sidebar-foreground/30')}
strokeWidth={activityActive ? 2.25 : 1.75}
/>
<span className="flex-1">Activity</span>
{activityUnreadCount > 0 ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
{activityUnreadCount}
</span>
) : null}
</button>
) : null}
<button
type="button"
onClick={() => openModal('worktree-palette')}

View File

@ -799,9 +799,10 @@ const WorktreeList = React.memo(function WorktreeList() {
.map((r) => r.worktree),
[rows]
)
// Why: when the tasks page is active, no sidebar card should appear selected
// — the user hasn't picked a worktree yet.
const selectedSidebarWorktreeId = activeView === 'tasks' ? null : activeWorktreeId
// Why: full-page navigation views are not scoped to one worktree, so no
// sidebar card should appear selected while one of them is active.
const selectedSidebarWorktreeId =
activeView === 'tasks' || activeView === 'activity' ? null : activeWorktreeId
// Why layout effect instead of effect: the global Cmd/Ctrl+19 key handler
// can fire immediately after React commits the new grouped/collapsed order.

View File

@ -3,7 +3,7 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
activeView: 'terminal' | 'settings' | 'tasks'
activeView: 'terminal' | 'settings' | 'tasks' | 'activity'
activeTabType: 'terminal' | 'editor' | 'browser'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {

View File

@ -79,11 +79,7 @@ describe('getWorktreeStatus', () => {
})
describe('resolveWorktreeStatus', () => {
// Why: WorktreeCard layers permission > working > done > heuristic on top
// of the title-heuristic base. The slept-with-retained-done case is the
// specific UX rule: retained `done` rows survive in the inline agents
// list, but the worktree dot must be grey when nothing is alive.
it('returns inactive when no tab has a live pty, even if a retained-done row exists', () => {
it('returns inactive when no tab has a live pty and no explicit agent row exists', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [done]' }],
browserTabs: [],
@ -91,13 +87,26 @@ describe('resolveWorktreeStatus', () => {
ptyIdsByTabId: { 'tab-1': [] },
hasPermission: false,
hasLiveDone: false,
hasRetainedDone: true
hasRetainedDone: false
})
expect(status).toBe('inactive')
})
it('returns inactive on slept worktree even when hasPermission is true (precondition wins)', () => {
it('promotes to done when a retained done row is visible, even without a live pty', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'bash' }],
browserTabs: [],
ptyIdsByTabId: { 'tab-1': [] },
hasPermission: false,
hasLiveDone: false,
hasRetainedDone: true
})
expect(status).toBe('done')
})
it('promotes to permission when an explicit agent row needs input, even without a live pty', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'tab-1', title: 'claude [permission]' }],
browserTabs: [],
@ -107,7 +116,7 @@ describe('resolveWorktreeStatus', () => {
hasRetainedDone: false
})
expect(status).toBe('inactive')
expect(status).toBe('permission')
})
it('promotes to permission when live and hasPermission', () => {

View File

@ -76,16 +76,11 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string {
/**
* Apply the WorktreeCard priority overlay (permission > working > done >
* heuristic) on top of the title-heuristic base. The live-pty precondition is
* inherited via getWorktreeStatus: when no tab in this worktree has a live
* PTY and no browser tab exists, getWorktreeStatus returns 'inactive' and
* none of the promotion paths fire so the worktree dot stays grey across
* sleep, renderer crash + rehydration, and any other path where wake-hint
* sessionIds outlive the actual PTY. On sleep specifically,
* `dropAgentStatusByWorktree` also clears retained rows for the worktree, so
* this precondition is the second line of defense; the rehydration-from-disk
* path is where retained 'done' rows can outlive the live PTY and the
* precondition does the load-bearing work.
* heuristic) on top of the title-heuristic base. Live PTY liveness still gates
* title-derived working/permission, but explicit agent rows are allowed to
* promote the dot: if the sidebar shows a completed/blocking inline agent row,
* the worktree status must agree with that visible row. Sleep cleanup owns
* removing stale retained rows; once they are gone, no promotion occurs.
*
* Argument semantics (sourced by the WorktreeCard caller from the store):
* - `tabs`, `browserTabs`: the worktree's terminal and browser tabs.
@ -113,12 +108,6 @@ export function resolveWorktreeStatus(args: {
args.ptyIdsByTabId,
args.runtimePaneTitlesByTabId ?? {}
)
// Why: liveness precondition. Without any live PTY (and no browser tab),
// agent-state hooks and retained-done snapshots must not promote the dot
// off grey — the agent process is gone the instant pty.kill fires.
if (heuristic === 'inactive') {
return 'inactive'
}
if (args.hasPermission) {
return 'permission'
}

View File

@ -193,15 +193,17 @@ export type UISlice = {
* without this, rows you'd already visited come back bold on relaunch. */
acknowledgedAgentsByPaneKey: Record<string, number>
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
/** Per-worktree collapsed state for the inline agents section shown inside
* each workspace card. Session-only a restart defaults back to expanded,
* which matches the expected default (people rarely want agents hidden
* across launches). */
collapsedInlineAgentsByWorktreeId: Record<string, boolean>
toggleInlineAgentsCollapsed: (worktreeId: string) => void
activeView: 'terminal' | 'settings' | 'tasks'
previousViewBeforeTasks: 'terminal' | 'settings'
previousViewBeforeSettings: 'terminal' | 'tasks'
activeView: 'terminal' | 'settings' | 'tasks' | 'activity'
previousViewBeforeTasks: 'terminal' | 'settings' | 'activity'
previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity'
previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks'
setActiveView: (view: UISlice['activeView']) => void
taskPageData: {
preselectedRepoId?: string
@ -231,6 +233,8 @@ export type UISlice = {
} | null
openTaskPage: (data?: UISlice['taskPageData']) => void
closeTaskPage: () => void
openActivityPage: () => void
closeActivityPage: () => void
setNewWorkspaceDraft: (draft: NonNullable<UISlice['newWorkspaceDraft']>) => void
clearNewWorkspaceDraft: () => void
openSettingsPage: () => void
@ -388,6 +392,22 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
}
return next ? { acknowledgedAgentsByPaneKey: next } : s
}),
unacknowledgeAgents: (paneKeys) =>
set((s) => {
if (paneKeys.length === 0) {
return s
}
let next: Record<string, number> | null = null
for (const key of paneKeys) {
if (s.acknowledgedAgentsByPaneKey[key] !== undefined) {
if (next === null) {
next = { ...s.acknowledgedAgentsByPaneKey }
}
delete next[key]
}
}
return next ? { acknowledgedAgentsByPaneKey: next } : s
}),
collapsedInlineAgentsByWorktreeId: {},
toggleInlineAgentsCollapsed: (worktreeId) =>
set((s) => {
@ -404,6 +424,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeView: 'terminal',
previousViewBeforeTasks: 'terminal',
previousViewBeforeSettings: 'terminal',
previousViewBeforeActivity: 'terminal',
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
@ -495,6 +516,16 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
worktreeNavHistoryIndex: nextHistoryIndex
}
}),
openActivityPage: () =>
set((state) => ({
activeView: 'activity',
previousViewBeforeActivity:
state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView
})),
closeActivityPage: () =>
set((state) => ({
activeView: state.previousViewBeforeActivity
})),
setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }),
clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }),
openSettingsPage: () =>

View File

@ -233,6 +233,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// Why: off by default — opt-in cosmetic joke feature. Leaving the default
// false keeps the overlay unmounted for users who never enable it.
experimentalPet: false,
experimentalActivity: false,
experimentalWorktreeSymlinks: false,
// Why: hydrate an empty default so the renderer's optional-chained reads
// (`settings?.githubProjects?.activeProject`) land on a stable shape

View File

@ -174,6 +174,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'openLinksInApp',
'experimentalMobile',
'experimentalPet',
'experimentalActivity',
'experimentalWorktreeSymlinks',
'geminiCliOAuthEnabled'
] as const satisfies readonly BooleanGlobalSettingsKey[]

View File

@ -1269,6 +1269,10 @@ export type GlobalSettings = {
/** Legacy persisted key from before the sidekick -> pet rename. Read only
* during migration; new writes use experimentalPet. */
experimentalSidekick?: boolean
/** Experimental: Slack-style Activity page that groups agent/worktree status
* events by worktree. Opt-in while the UI and backend event model are still
* being refined. */
experimentalActivity: boolean
/** Experimental: when creating a worktree, automatically symlink a
* user-configured set of files/folders from the primary checkout (e.g.
* `.env`, `node_modules`) into the new worktree. Opt-in while the