Add experimental Activity page (#1703)
This commit is contained in:
parent
c45712cead
commit
a81016b82d
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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+1–9 key handler
|
||||
// can fire immediately after React commits the new grouped/collapsed order.
|
||||
|
|
|
|||
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
|
|||
'openLinksInApp',
|
||||
'experimentalMobile',
|
||||
'experimentalPet',
|
||||
'experimentalActivity',
|
||||
'experimentalWorktreeSymlinks',
|
||||
'geminiCliOAuthEnabled'
|
||||
] as const satisfies readonly BooleanGlobalSettingsKey[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue