mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
fix(activity): tighten portal target, unread count, and selection edges
- activity-terminal-portal: replace document.body MutationObserver with a module-level pub/sub registry; the page publishes the target via a ref callback, Terminal subscribes. Removes body-wide DOM observation. - Terminal: memoize activityTerminalPortal so WorktreeSplitSurface's React.memo bail-out is preserved across unrelated Terminal re-renders. - ActivityPrototypePage: cap events per-pane (5) so a chatty pane can't push quiet panes off the left list; render an empty-state when a retained thread's tab is gone; skip workspace mutation in activateThreadTerminal when the tab is no longer live; keep the selected thread visible in unread-only mode after auto-mark-read. - ActivityTitlebarControls: walk stateHistory in the unread accumulator (mirrors the new feed semantics); drop worktree-created counting since the new feed has no surface to dismiss them. - AgentStateDot: remove the dead 'lg' size variant. - App: hide worktree sidebar on the Activity view and include it in the back/forward shortcut + button cluster. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -572,7 +572,10 @@ function App(): React.JSX.Element {
|
||||
activeWorktreeId !== null &&
|
||||
!hasTabBar &&
|
||||
effectiveActiveTabExpanded
|
||||
const showSidebar = activeView !== 'settings'
|
||||
// Why: Activity is a full-page navigation surface — same treatment as
|
||||
// Settings — so the worktree sidebar is removed for that view, letting the
|
||||
// thread list + agent terminal span edge-to-edge.
|
||||
const showSidebar = activeView !== 'settings' && activeView !== 'activity'
|
||||
// Why: only the terminal workspace replaces the full-width titlebar with
|
||||
// split-column chrome. Full-page navigation views keep the draggable app
|
||||
// titlebar so their page-level controls can live in that window strip.
|
||||
@@ -635,7 +638,7 @@ function App(): React.JSX.Element {
|
||||
// Why: Back/Forward traverse mixed worktree + Tasks visits, so the
|
||||
// shortcut is active wherever the titlebar button cluster is (terminal
|
||||
// or tasks). Still suppressed in Settings to keep that view modal-ish.
|
||||
if (activeView !== 'terminal' && activeView !== 'tasks') {
|
||||
if (activeView !== 'terminal' && activeView !== 'tasks' && activeView !== 'activity') {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
@@ -840,9 +843,10 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
{/* Why: Back/Forward traverse mixed worktree + Tasks history, so the
|
||||
cluster is shown wherever the history shortcut is live (terminal or
|
||||
tasks). Hidden in Settings to keep that view modal-ish. */}
|
||||
{(activeView === 'terminal' || activeView === 'tasks') && (
|
||||
cluster is shown wherever the history shortcut is live (terminal,
|
||||
tasks, or activity — selecting an agent there records a worktree
|
||||
visit). Hidden in Settings to keep that view modal-ish. */}
|
||||
{(activeView === 'terminal' || activeView === 'tasks' || activeView === 'activity') && (
|
||||
<div className="ml-auto mr-3 flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
|
||||
type Props = {
|
||||
state: AgentDotState
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -53,12 +53,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
size = 'sm',
|
||||
className
|
||||
}: Props): React.JSX.Element {
|
||||
// Why ('lg' size): the Activity page renders this in its own 32px column
|
||||
// alongside text rows, where 'md' (12px) reads small and floats. 'lg' (18px)
|
||||
// sits center of mass in that cell.
|
||||
const box = size === 'lg' ? 'h-[18px] w-[18px]' : size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
|
||||
const inner = size === 'lg' ? 'size-3' : size === 'md' ? 'size-2' : 'size-1.5'
|
||||
const icon = size === 'lg' ? 'size-[18px]' : size === 'md' ? 'size-3' : 'size-2.5'
|
||||
const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
|
||||
const inner = size === 'md' ? 'size-2' : 'size-1.5'
|
||||
const icon = size === 'md' ? 'size-3' : 'size-2.5'
|
||||
|
||||
if (state === 'working') {
|
||||
return (
|
||||
|
||||
@@ -107,18 +107,23 @@ function Terminal(): React.JSX.Element | null {
|
||||
const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree)
|
||||
const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined
|
||||
const activityTerminalPortalTarget = useActivityTerminalPortalTarget(activeView === 'activity')
|
||||
const activityTerminalPortal: ActivityTerminalPortalTarget | null =
|
||||
activeView === 'activity' &&
|
||||
activeTabType === 'terminal' &&
|
||||
activeWorktreeId &&
|
||||
activeTabId &&
|
||||
activityTerminalPortalTarget
|
||||
? {
|
||||
target: activityTerminalPortalTarget,
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: activeTabId
|
||||
}
|
||||
: null
|
||||
// Why: stable identity preserves WorktreeSplitSurface's React.memo bail-out across unrelated Terminal re-renders.
|
||||
const activityTerminalPortal: ActivityTerminalPortalTarget | null = useMemo(() => {
|
||||
if (
|
||||
activeView !== 'activity' ||
|
||||
activeTabType !== 'terminal' ||
|
||||
!activeWorktreeId ||
|
||||
!activeTabId ||
|
||||
!activityTerminalPortalTarget
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
target: activityTerminalPortalTarget,
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: activeTabId
|
||||
}
|
||||
}, [activeView, activeTabType, activeWorktreeId, activeTabId, activityTerminalPortalTarget])
|
||||
|
||||
const tabs = useMemo(
|
||||
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
@@ -34,7 +35,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ACTIVITY_TERMINAL_PORTAL_TARGET_ID } from './activity-terminal-portal'
|
||||
import { setActivityTerminalPortalTarget } from './activity-terminal-portal'
|
||||
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import type {
|
||||
AgentStateHistoryEntry,
|
||||
@@ -161,6 +162,9 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt
|
||||
return state === 'done' || state === 'blocked' || state === 'waiting'
|
||||
}
|
||||
|
||||
// Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history.
|
||||
const EVENTS_PER_PANE_CAP = 5
|
||||
|
||||
function historyEntrySnapshot(
|
||||
entry: AgentStatusEntry,
|
||||
history: AgentStateHistoryEntry
|
||||
@@ -309,7 +313,22 @@ export function buildActivityEvents(args: {
|
||||
})
|
||||
}
|
||||
|
||||
return events.sort((a, b) => b.timestamp - a.timestamp).slice(0, 80)
|
||||
const sorted = events.sort((a, b) => b.timestamp - a.timestamp)
|
||||
const perPaneCount = new Map<string, number>()
|
||||
const capped: ActivityEvent[] = []
|
||||
for (const event of sorted) {
|
||||
const paneKey = event.entry.paneKey
|
||||
const count = perPaneCount.get(paneKey) ?? 0
|
||||
if (count >= EVENTS_PER_PANE_CAP) {
|
||||
continue
|
||||
}
|
||||
perPaneCount.set(paneKey, count + 1)
|
||||
capped.push(event)
|
||||
if (capped.length >= 80) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return capped
|
||||
}
|
||||
|
||||
function buildAgentPaneThreads(events: ActivityEvent[]): AgentPaneThread[] {
|
||||
@@ -543,7 +562,10 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
const visibleThreads = useMemo(() => {
|
||||
const trimmedQuery = query.trim().toLowerCase()
|
||||
return allThreads.filter((thread) => {
|
||||
if (readFilter === 'unread' && !thread.unread) {
|
||||
// Why: keep the just-selected thread visible even after auto-mark-read
|
||||
// flips it to read, otherwise clicking a row in unread-only mode makes it
|
||||
// vanish from the left list while staying selected on the right.
|
||||
if (readFilter === 'unread' && !thread.unread && thread.paneKey !== selectedPaneKey) {
|
||||
return false
|
||||
}
|
||||
if (!trimmedQuery) {
|
||||
@@ -554,7 +576,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
`${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`.toLowerCase()
|
||||
return text.includes(trimmedQuery)
|
||||
})
|
||||
}, [allThreads, readFilter, query])
|
||||
}, [allThreads, readFilter, query, selectedPaneKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPaneKey && !allThreads.some((thread) => thread.paneKey === selectedPaneKey)) {
|
||||
@@ -576,6 +598,14 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
|
||||
const activateThreadTerminal = (thread: AgentPaneThread): void => {
|
||||
const state = useAppStore.getState()
|
||||
// Why: retained-agent threads can outlive their tab. With no live tab, the
|
||||
// right pane shows the empty-state placeholder; reorienting the workspace
|
||||
// and dispatching focus to a dead tab id would just confuse the user.
|
||||
const liveTabs = state.tabsByWorktree[thread.worktree.id] ?? []
|
||||
const hasLiveTab = liveTabs.some((t) => t.id === thread.latestEvent.tab.id)
|
||||
if (!hasLiveTab) {
|
||||
return
|
||||
}
|
||||
if (state.activeRepoId !== thread.worktree.repoId) {
|
||||
state.setActiveRepo(thread.worktree.repoId)
|
||||
}
|
||||
@@ -713,7 +743,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
<section className="min-w-0 flex-1 overflow-hidden">
|
||||
{selectedThread ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 border-b border-border px-4 pt-2 pb-3">
|
||||
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-4 pt-2 pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="inline-flex shrink-0 pt-[3px]">
|
||||
@@ -734,15 +764,42 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
markThreadRead(selectedThread)
|
||||
activateAndRevealWorktree(selectedThread.worktree.id)
|
||||
}}
|
||||
>
|
||||
Jump to workspace
|
||||
</Button>
|
||||
</div>
|
||||
{/* Why: Terminal stays mounted in the hidden workspace tree while
|
||||
Activity is open. This target lets that existing TerminalPane
|
||||
move here instead of creating a second PTY/xterm owner. */}
|
||||
<div
|
||||
id={ACTIVITY_TERMINAL_PORTAL_TARGET_ID}
|
||||
className="relative min-h-0 flex-1 overflow-hidden bg-editor-surface"
|
||||
data-activity-terminal-tab-id={selectedThread.latestEvent.tab.id}
|
||||
/>
|
||||
{(() => {
|
||||
// Why: retained threads can outlive their tab; portal needs a live TerminalPane to render into.
|
||||
const liveTabs = storeData.tabsByWorktree[selectedThread.worktree.id] ?? []
|
||||
const hasLiveTab = liveTabs.some((t) => t.id === selectedThread.latestEvent.tab.id)
|
||||
if (!hasLiveTab) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
<TerminalSquare className="size-7" />
|
||||
Agent terminal closed. Open a new terminal in this workspace to continue.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={setActivityTerminalPortalTarget}
|
||||
className="relative min-h-0 flex-1 overflow-hidden bg-editor-surface"
|
||||
data-activity-terminal-tab-id={selectedThread.latestEvent.tab.id}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { Bell } from 'lucide-react'
|
||||
import { ArrowLeft, Bell } from 'lucide-react'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
|
||||
function useActivityUnreadCount(): number {
|
||||
return useAppStore((s) => {
|
||||
let count = 0
|
||||
for (const worktrees of Object.values(s.worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.createdAt && worktree.isUnread) {
|
||||
const isUnreadState = (state: AgentStatusState): boolean =>
|
||||
state === 'done' || state === 'blocked' || state === 'waiting'
|
||||
// Why: Activity feed surfaces every historical done/blocked/waiting event from
|
||||
// stateHistory plus the live state, so the badge must mirror the same walk —
|
||||
// counting only on entry.state under-counts panes that started a new turn.
|
||||
const accumulate = (entry: AgentStatusEntry, ackAt: number): void => {
|
||||
for (const history of entry.stateHistory) {
|
||||
if (isUnreadState(history.state) && ackAt < history.startedAt) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
if (isUnreadState(entry.state) && ackAt < entry.stateStartedAt) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
|
||||
if (entry.state !== 'done' && entry.state !== 'blocked' && entry.state !== 'waiting') {
|
||||
continue
|
||||
}
|
||||
if ((s.acknowledgedAgentsByPaneKey[paneKey] ?? 0) < entry.stateStartedAt) {
|
||||
count += 1
|
||||
}
|
||||
accumulate(entry, s.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
|
||||
}
|
||||
for (const [paneKey, retained] of Object.entries(s.retainedAgentsByPaneKey)) {
|
||||
if (retained.entry.state !== 'done') {
|
||||
continue
|
||||
}
|
||||
if ((s.acknowledgedAgentsByPaneKey[paneKey] ?? 0) < retained.entry.stateStartedAt) {
|
||||
count += 1
|
||||
}
|
||||
accumulate(retained.entry, s.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
|
||||
}
|
||||
return count
|
||||
})
|
||||
@@ -37,31 +37,43 @@ function useActivityUnreadCount(): number {
|
||||
export function ActivityTitlebarControls(): React.JSX.Element {
|
||||
const unreadCount = useActivityUnreadCount()
|
||||
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
|
||||
const clearWorktreeUnread = useAppStore((s) => s.clearWorktreeUnread)
|
||||
const closeActivityPage = useAppStore((s) => s.closeActivityPage)
|
||||
|
||||
const markAllRead = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
// Why: ack every pane (live + retained) — Date.now() exceeds all historical
|
||||
// startedAt values, clearing the per-history-event unread flags too.
|
||||
acknowledgeAgents([
|
||||
...Object.values(state.agentStatusByPaneKey)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.state === 'done' || entry.state === 'blocked' || entry.state === 'waiting'
|
||||
)
|
||||
.map((entry) => entry.paneKey),
|
||||
...Object.values(state.agentStatusByPaneKey).map((entry) => entry.paneKey),
|
||||
...Object.values(state.retainedAgentsByPaneKey).map((retained) => retained.entry.paneKey)
|
||||
])
|
||||
for (const worktrees of Object.values(state.worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.createdAt && worktree.isUnread) {
|
||||
clearWorktreeUnread(worktree.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* Why: Activity hides the worktree sidebar (full-page surface), so the
|
||||
sidebar's nav row isn't available as the back path. This Back button
|
||||
is the dedicated exit, mirroring Settings' onBack pattern. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={closeActivityPage}
|
||||
aria-label="Close activity"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Close activity
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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">
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
|
||||
export const ACTIVITY_TERMINAL_PORTAL_TARGET_ID = 'activity-terminal-portal-target'
|
||||
|
||||
export type ActivityTerminalPortalTarget = {
|
||||
target: HTMLElement
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
}
|
||||
|
||||
let currentTarget: HTMLElement | null = null
|
||||
const subscribers = new Set<(target: HTMLElement | null) => void>()
|
||||
|
||||
// Why: replaces a body-wide MutationObserver. ActivityPrototypePage owns the
|
||||
// target div's lifecycle and publishes here on mount/unmount; Terminal
|
||||
// subscribes. Avoids global DOM observation while activity is open.
|
||||
export function setActivityTerminalPortalTarget(target: HTMLElement | null): void {
|
||||
if (currentTarget === target) {
|
||||
return
|
||||
}
|
||||
currentTarget = target
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(target)
|
||||
}
|
||||
}
|
||||
|
||||
export function useActivityTerminalPortalTarget(enabled: boolean): HTMLElement | null {
|
||||
const [target, setTarget] = useState<HTMLElement | null>(null)
|
||||
const [target, setTarget] = useState<HTMLElement | null>(enabled ? currentTarget : null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || typeof document === 'undefined') {
|
||||
if (!enabled) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
let animationFrame: number | null = null
|
||||
const syncTarget = (): void => {
|
||||
const nextTarget = document.getElementById(ACTIVITY_TERMINAL_PORTAL_TARGET_ID)
|
||||
setTarget((currentTarget) => (currentTarget === nextTarget ? currentTarget : nextTarget))
|
||||
}
|
||||
const scheduleSync = (): void => {
|
||||
if (animationFrame !== null) {
|
||||
return
|
||||
}
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = null
|
||||
syncTarget()
|
||||
})
|
||||
}
|
||||
|
||||
syncTarget()
|
||||
// Why: ActivityPrototypePage is a lazy sibling of Terminal, so the portal
|
||||
// target can appear/disappear without Terminal itself remounting.
|
||||
const observer = new MutationObserver(scheduleSync)
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
setTarget(currentTarget)
|
||||
const subscriber = (next: HTMLElement | null): void => setTarget(next)
|
||||
subscribers.add(subscriber)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
subscribers.delete(subscriber)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user