Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { focusNextTerminalSearchMatch, focusPreviousTerminalSearchMatch, loadTer
import { isFatalConnectionErrorCode } from '@/store/connectionSlice'
import { buildTerminalDurableSessionRefUpdate, flushPersistedLayoutNow } from '@/store/persistControl'
import { getWsClient } from '@/lib/ws-client'
import { bucketTabRecencyAt } from '@/lib/tab-recency'
import { getTerminalTheme } from '@/lib/terminal-themes'
import { getCreateSessionStateFromRef } from '@/components/terminal-view-utils'
import { copyText, readText } from '@/lib/clipboard'
Expand Down Expand Up @@ -105,6 +106,7 @@ import {
} from '@/lib/terminal-behavior'
import { buildRestoreError } from '@shared/session-contract'
import type { CodingCliProviderName } from '@/store/types'
import { recordPaneTabActivity } from '@/store/tabRecencySlice'

const log = createLogger('TerminalView')

Expand Down Expand Up @@ -323,6 +325,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
const hasAttentionRef = useRef(hasAttention)
const hasPaneAttention = useAppSelector((s) => !!s.turnCompletion?.attentionByPane?.[paneId])
const hasPaneAttentionRef = useRef(hasPaneAttention)
const paneTabRecencyBucket = useAppSelector((s) => s.tabRecency?.paneLastInputAt?.[paneId])

// All hooks MUST be called before any conditional returns
const ws = useMemo(() => getWsClient(), [])
Expand Down Expand Up @@ -359,6 +362,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
const hiddenRef = useRef(hidden)
const hydrationRegisteredRef = useRef(false)
const lastSessionActivityAtRef = useRef(0)
const lastPaneTabRecencyBucketRef = useRef<number | undefined>(paneTabRecencyBucket)
const rateLimitRetryRef = useRef<{ count: number; timer: ReturnType<typeof setTimeout> | null }>({ count: 0, timer: null })
const restoreRequestIdRef = useRef<string | null>(null)
const restoreFlagRef = useRef(false)
Expand Down Expand Up @@ -504,6 +508,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
}
}, [terminalContent, paneId, applySeqState])

useEffect(() => {
lastPaneTabRecencyBucketRef.current = paneTabRecencyBucket
}, [paneId, paneTabRecencyBucket])

// Register terminal buffer accessor with test harness (for E2E tests).
// Uses xterm.js Terminal.buffer.active API which works with all renderers
// (WebGL, canvas, DOM) — unlike DOM scraping via .xterm-rows which only
Expand Down Expand Up @@ -1357,7 +1365,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps)
const currentContent = contentRef.current
if (currentTab) {
const now = Date.now()
dispatch(updateTab({ id: currentTab.id, updates: { lastInputAt: now } }))
const bucket = bucketTabRecencyAt(now)
const previousBucket = lastPaneTabRecencyBucketRef.current
if (bucket !== undefined && (previousBucket === undefined || bucket > previousBucket)) {
lastPaneTabRecencyBucketRef.current = bucket
dispatch(recordPaneTabActivity({ paneId, at: now }))
}
const resumeSessionId = currentContent?.resumeSessionId
if (resumeSessionId && currentContent?.mode && currentContent.mode !== 'shell') {
if (now - lastSessionActivityAtRef.current >= SESSION_ACTIVITY_THROTTLE_MS) {
Expand Down
21 changes: 16 additions & 5 deletions src/components/context-menu/ContextMenuProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { ClientExtensionEntry } from '@shared/extension-types'
import { buildResumeContent } from '@/lib/session-type-utils'
import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils'
import { mergeSessionMetadataByKey } from '@/lib/session-metadata'
import { deriveTabRecencyAt } from '@/lib/tab-recency'
import { ConfirmModal } from '@/components/ui/confirm-modal'
import type { AppView } from '@/components/Sidebar'
import type { CodingCliProviderName, CodingCliSession, ProjectGroup } from '@/store/types'
Expand All @@ -51,6 +52,8 @@ import { nanoid } from 'nanoid'

const CONTEXT_MENU_KEYS = ['ContextMenu']
const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = []
const EMPTY_PANE_LAST_INPUT_AT: Record<string, number | undefined> = {}
const EMPTY_FEATURE_FLAGS: Record<string, boolean> = {}


type MenuState = {
Expand Down Expand Up @@ -107,9 +110,10 @@ export function ContextMenuProvider({
const historySessions = useAppSelector((s) => s.sessions.windows?.history?.projects ?? s.sessions.projects)
const expandedProjects = useAppSelector((s) => s.sessions.expandedProjects)
const platform = useAppSelector((s) => s.connection?.platform ?? null)
const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? {})
const featureFlags = useAppSelector((s) => s.connection?.featureFlags ?? EMPTY_FEATURE_FLAGS)
const appSettings = useAppSelector((s) => s.settings.settings)
const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES)
const paneLastInputAt = useAppSelector((s) => s.tabRecency?.paneLastInputAt ?? EMPTY_PANE_LAST_INPUT_AT)

const [menuState, setMenuState] = useState<MenuState | null>(null)
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
Expand Down Expand Up @@ -523,7 +527,14 @@ export function ContextMenuProvider({
return refs.some((ref) => ref.provider === keyProvider && ref.sessionId === sessionId)
})
const hasTab = relatedTabs.length > 0
const tabLastInputAt = relatedTabs.reduce((max, tab) => Math.max(max, tab.lastInputAt ?? 0), 0) || undefined
const tabLastInputAt = relatedTabs.reduce((max, tab) => {
const layout = panes[tab.id]
return Math.max(max, deriveTabRecencyAt({
tab,
layout,
paneLastInputAt,
}))
}, 0)
const runningTerminalId =
menuState?.target.kind === 'sidebar-session' && menuState?.target.sessionId === sessionId
? menuState?.target.runningTerminalId
Expand All @@ -544,14 +555,14 @@ export function ContextMenuProvider({
archived: session.archived,
sourceFile: session.sourceFile,
hasTab,
tabLastInputAt,
tabLastInputAtIso: tabLastInputAt ? new Date(tabLastInputAt).toISOString() : null,
tabLastInputAt: hasTab ? tabLastInputAt : undefined,
tabLastInputAtIso: hasTab ? new Date(tabLastInputAt).toISOString() : null,
isRunning: !!runningTerminalId,
runningTerminalId: runningTerminalId || null,
projectColor: project.color,
}
await copyText(JSON.stringify(metadata, null, 2))
}, [getSessionInfo, tabsState.tabs, panes, menuState?.target])
}, [getSessionInfo, tabsState.tabs, panes, paneLastInputAt, menuState?.target])

const copyResumeCommand = useCallback(async (provider: ResumeCommandProvider, sessionId: string) => {
const command = buildResumeCommand(provider, sessionId, extensionEntries)
Expand Down
39 changes: 39 additions & 0 deletions src/lib/tab-recency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PaneNode } from '@/store/paneTypes'
import type { Tab } from '@/store/types'

export const TAB_RECENCY_RESOLUTION_MS = 60 * 1000

type TimestampCandidate = number | null | undefined

export function bucketTabRecencyAt(at: TimestampCandidate): number | undefined {
if (typeof at !== 'number' || !Number.isFinite(at) || at < 0) return undefined
return Math.floor(at / TAB_RECENCY_RESOLUTION_MS) * TAB_RECENCY_RESOLUTION_MS
}

export function collectTerminalPaneIds(node: PaneNode | undefined): string[] {
if (!node) return []
if (node.type === 'leaf') {
return node.content.kind === 'terminal' ? [node.id] : []
}
return [
...collectTerminalPaneIds(node.children[0]),
...collectTerminalPaneIds(node.children[1]),
]
}

export function deriveTabRecencyAt(input: {
tab: Pick<Tab, 'createdAt' | 'lastInputAt'>
layout: PaneNode | undefined
paneLastInputAt: Record<string, number | undefined>
}): number {
const candidates: number[] = []
for (const raw of [input.tab.createdAt, input.tab.lastInputAt]) {
const bucket = bucketTabRecencyAt(raw)
if (bucket !== undefined) candidates.push(bucket)
}
for (const paneId of collectTerminalPaneIds(input.layout)) {
const bucket = bucketTabRecencyAt(input.paneLastInputAt[paneId])
if (bucket !== undefined) candidates.push(bucket)
}
return candidates.length > 0 ? Math.max(...candidates) : 0
}
37 changes: 27 additions & 10 deletions src/store/crossTabSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { getPendingBrowserPreferencesWriteState } from './browserPreferencesPers
import { parsePersistedLayoutRaw, LAYOUT_STORAGE_KEY } from './persistedState'
import { getPersistBroadcastSourceId, onPersistBroadcast, PERSIST_BROADCAST_CHANNEL_NAME } from './persistBroadcast'
import { shouldPreserveLocalCanonicalResumeSessionId } from './persistControl'
import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys'
import { BROWSER_PREFERENCES_STORAGE_KEY, TAB_RECENCY_STORAGE_KEY } from './storage-keys'
import { collectLiveTerminalPaneIds } from './tabRecencyPruneMiddleware'
import {
loadPersistedTabRecency,
mergeHydratedTabRecency,
prunePaneTabActivityToLiveTerminalPanes,
} from './tabRecencySlice'
import { parseBrowserPreferencesRaw, resolveBrowserPreferenceSettings } from '@/lib/browser-preferences'

type StoreLike = {
Expand All @@ -25,6 +31,12 @@ const zPersistBroadcastMsg = z.object({
sourceId: z.string(),
})

const CROSS_TAB_SYNC_STORAGE_KEYS = [
LAYOUT_STORAGE_KEY,
BROWSER_PREFERENCES_STORAGE_KEY,
TAB_RECENCY_STORAGE_KEY,
] as const

function collectPaneIdsSafe(node: unknown): string[] {
const ids: string[] = []

Expand Down Expand Up @@ -260,6 +272,17 @@ function handleIncomingRaw(
dispatchHydrateLayoutFromPersisted(store, raw, localLayoutPersistedAt)
} else if (key === BROWSER_PREFERENCES_STORAGE_KEY) {
dispatchHydrateBrowserPreferencesFromPersisted(store, raw, previousRaw)
} else if (key === TAB_RECENCY_STORAGE_KEY) {
store.dispatch({
...mergeHydratedTabRecency(loadPersistedTabRecency(raw)),
meta: { skipPersist: true, source: 'cross-tab' },
})
store.dispatch({
...prunePaneTabActivityToLiveTerminalPanes({
paneIds: collectLiveTerminalPaneIds(store.getState()),
}),
meta: { source: 'cross-tab' },
})
Comment on lines +280 to +285
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip persisting cross-tab recency prune echoes

When a TAB_RECENCY_STORAGE_KEY event arrives, we merge the remote payload and then dispatch prunePaneTabActivityToLiveTerminalPanes without meta.skipPersist. In multi-tab use where each tab has different live pane IDs, this causes each tab to immediately rewrite the sidecar to its own pane set, broadcasting a new storage value that the other tab then rewrites back, creating an ongoing ping-pong of localStorage writes/broadcasts and unnecessary sync traffic.

Useful? React with 👍 / 👎.

}
}

Expand All @@ -270,7 +293,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
// Dedupe by exact raw value so we don't hydrate twice.
const lastProcessedRawByKey = new Map<string, string>()
let currentLocalLayoutPersistedAt: number | undefined
for (const key of [LAYOUT_STORAGE_KEY, BROWSER_PREFERENCES_STORAGE_KEY]) {
for (const key of CROSS_TAB_SYNC_STORAGE_KEYS) {
const existingRaw = localStorage.getItem(key)
if (typeof existingRaw === 'string') {
lastProcessedRawByKey.set(key, existingRaw)
Expand Down Expand Up @@ -307,10 +330,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
// then diverge locally (persisted raw changes), a later remote event with the original raw
// could be incorrectly ignored.
const unsubscribeLocal = onPersistBroadcast((msg) => {
if (
msg.key !== LAYOUT_STORAGE_KEY
&& msg.key !== BROWSER_PREFERENCES_STORAGE_KEY
) {
if (!CROSS_TAB_SYNC_STORAGE_KEYS.includes(msg.key as any)) {
return
}
lastProcessedRawByKey.set(msg.key, msg.raw)
Expand All @@ -322,10 +342,7 @@ export function installCrossTabSync(store: StoreLike): () => void {
const onStorage = (e: StorageEvent) => {
if (e.storageArea && e.storageArea !== localStorage) return
const key = e.key
if (
key !== LAYOUT_STORAGE_KEY
&& key !== BROWSER_PREFERENCES_STORAGE_KEY
) {
if (typeof key !== 'string' || !CROSS_TAB_SYNC_STORAGE_KEYS.includes(key as any)) {
return
}
if (typeof e.newValue !== 'string') return
Expand Down
Loading
Loading