Revert "Reduce renderer and terminal performance churn" (#2579)

This commit is contained in:
Neil
2026-05-21 18:22:25 -07:00
committed by GitHub
parent ded5fda98d
commit 19bfc56cc3
97 changed files with 795 additions and 4326 deletions
@@ -523,37 +523,6 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it('clears a pending checkpoint timer when the last dirty session closes', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
adapterClass.CHECKPOINT_INTERVAL_MS = 10_000
try {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const { id } = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: 'close-dirty-checkpoint'
})
const internals = historyAdapter as unknown as {
dirtySessionVersions: Map<string, number>
}
lastSubprocess._simulateData('dirty before close\r\n')
await waitFor(() => internals.dirtySessionVersions.has(id))
const callsBeforeClose = clearTimeoutSpy.mock.calls.length
await historyAdapter.shutdown(id, { immediate: true })
expect(clearTimeoutSpy.mock.calls.length).toBeGreaterThan(callsBeforeClose)
} finally {
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
clearTimeoutSpy.mockRestore()
}
})
it('writes meta.json with endedAt on exit', async () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+16 -41
View File
@@ -66,7 +66,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
private coldRestoreCache = new Map<string, { scrollback: string; cwd: string }>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
private checkpointInterval: ReturnType<typeof setInterval> | null = null
private checkpointInFlight: Promise<void> | null = null
// Why: checkpoint-based persistence requires the getSnapshot RPC (v4+).
// Legacy daemons reject it, causing noisy log spam every 5 seconds.
@@ -246,7 +246,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.client.request('kill', { sessionId: id })
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
// Why: history removal is for the "user explicitly closed this terminal"
// path. Sleep also calls shutdown but expects scrollback to survive — wake
@@ -408,7 +407,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
const ids = [...this.activeSessionIds]
this.activeSessionIds.clear()
this.dirtySessionVersions.clear()
this.stopCheckpointTimer()
for (const id of ids) {
// Why: listener throws are intentionally *not* caught — matches the
// natural onExit fanout in setupEventRouting, so synthetic exits don't
@@ -464,7 +462,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
dispose(): void {
this.stopCheckpointTimer()
if (this.checkpointInterval) {
clearInterval(this.checkpointInterval)
this.checkpointInterval = null
}
this.dirtySessionVersions.clear()
this.removeEventListener?.()
this.removeEventListener = null
@@ -486,7 +487,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
// We write a final checkpoint before disconnecting so that if the daemon
// later crashes while Orca is closed, checkpoint.json has recovery data.
async disconnectOnly(): Promise<void> {
this.stopCheckpointTimer()
if (this.checkpointInterval) {
clearInterval(this.checkpointInterval)
this.checkpointInterval = null
}
// Why: wait for any in-flight timer pass to finish before starting
// the final checkpoint. Otherwise both passes race on the shared tmp
// file, risking ENOENT on rename and disabling future writes.
@@ -508,47 +512,23 @@ export class DaemonPtyAdapter implements IPtyProvider {
private async ensureConnected(): Promise<void> {
await this.client.ensureConnected()
this.setupEventRouting()
this.scheduleCheckpointTimer()
this.startCheckpointTimer()
}
private stopCheckpointTimer(): void {
if (!this.checkpointTimer) {
private startCheckpointTimer(): void {
if (this.checkpointInterval || !this.historyManager || !this.supportsCheckpoints) {
return
}
clearTimeout(this.checkpointTimer)
this.checkpointTimer = null
}
private stopCheckpointTimerIfIdle(): void {
if (this.dirtySessionVersions.size === 0) {
this.stopCheckpointTimer()
}
}
private scheduleCheckpointTimer(): void {
if (
this.checkpointTimer ||
!this.historyManager ||
!this.supportsCheckpoints ||
this.dirtySessionVersions.size === 0
) {
return
}
// Why: checkpointing is only needed after terminal data/resize/write marks
// a session dirty. A permanent interval woke the main process every 5s for
// idle daemon-backed terminals just to discover there was nothing to write.
this.checkpointTimer = setTimeout(() => {
this.checkpointTimer = null
this.checkpointInterval = setInterval(() => {
// Why: if the previous pass is still in-flight (slow RPC or disk),
// retry later instead of overlapping checkpoint() writes to the same tmp
// file, which can lose a rename and disable future history writes.
// skip this tick. Overlapping passes race on the shared tmp file
// in checkpoint(), and a lost rename triggers handleWriteError which
// permanently disables the session's history writes.
if (this.checkpointInFlight) {
this.scheduleCheckpointTimer()
return
}
this.checkpointInFlight = this.checkpointDirtySessions().finally(() => {
this.checkpointInFlight = null
this.scheduleCheckpointTimer()
})
}, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS)
}
@@ -558,7 +538,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
return
}
this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1)
this.scheduleCheckpointTimer()
}
private async checkpointDirtySessions(): Promise<void> {
@@ -573,8 +552,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
[...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId))
)
if (versions.size === 0) {
this.dirtySessionVersions.clear()
this.stopCheckpointTimer()
return
}
const completed = await this.checkpointSessions(versions.keys())
@@ -583,7 +560,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.dirtySessionVersions.delete(sessionId)
}
}
this.stopCheckpointTimerIfIdle()
}
// Why: the adapter runs in the Electron main process and does not have direct
@@ -674,7 +650,6 @@ export class DaemonPtyAdapter implements IPtyProvider {
} else if (event.event === 'exit') {
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
this.stopCheckpointTimerIfIdle()
if (this.historyManager) {
void this.historyManager
.closeSession(event.sessionId, event.payload.code)
-52
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: daemon server RPC, auth, stream batching, and shutdown behavior share one socket/client harness; splitting would duplicate setup. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type Socket } from 'net'
import { tmpdir } from 'os'
@@ -16,7 +15,6 @@ function createTestDir(): string {
function createMockSubprocess(): SubprocessHandle & {
_simulateData: (data: string) => void
_simulateExit: (code: number) => void
} {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
@@ -36,9 +34,6 @@ function createMockSubprocess(): SubprocessHandle & {
dispose: vi.fn(),
_simulateData(data: string) {
onDataCb?.(data)
},
_simulateExit(code: number) {
onExitCb?.(code)
}
}
}
@@ -285,53 +280,6 @@ describe('DaemonServer', () => {
vi.useRealTimers()
}
})
it('flushes pending batched stream output before the exit event', async () => {
vi.useFakeTimers()
try {
let subprocess: ReturnType<typeof createMockSubprocess>
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => {
subprocess = createMockSubprocess()
return subprocess
}
})
const daemon = server as unknown as DaemonServerPrivate
const controlSocket = { destroy: vi.fn() } as unknown as Socket
const streamSocket = {
destroyed: false,
destroy: vi.fn(),
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
daemon.clients.set('client-1', {
clientId: 'client-1',
controlSocket,
streamSocket
})
await daemon.routeRequest('client-1', {
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'test-session', cols: 80, rows: 24 }
})
subprocess!._simulateData('final-output')
subprocess!._simulateExit(42)
expect(streamSocket.write).toHaveBeenCalledTimes(2)
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"event":"data"')
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"final-output"')
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain('"event":"exit"')
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain('"code":42')
vi.advanceTimersByTime(8)
expect(streamSocket.write).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
describe('authentication', () => {
@@ -71,33 +71,4 @@ describe('DaemonStreamDataBatcher', () => {
vi.useRealTimers()
}
})
it('flushes interactive output for one session while another session has large pending output', () => {
vi.useFakeTimers()
try {
const { batcher, streamSocket } = createBatcher()
const background = 'x'.repeat(2048)
batcher.enqueue('client-1', 'session-background', background)
batcher.enqueue('client-1', 'session-interactive', 'echo', {
flushImmediately: true,
flushMaxChars: 1024
})
expect(streamSocket.write).toHaveBeenCalledTimes(1)
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain(
'"sessionId":"session-interactive"'
)
expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"')
vi.advanceTimersByTime(8)
expect(streamSocket.write).toHaveBeenCalledTimes(2)
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain(
'"sessionId":"session-background"'
)
expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain(`"data":"${background}"`)
} finally {
vi.useRealTimers()
}
})
})
+2 -61
View File
@@ -50,10 +50,9 @@ export class DaemonStreamDataBatcher {
if (
options.flushImmediately === true &&
this.queuedCharsForSession(batch, sessionId) <=
(options.flushMaxChars ?? Number.POSITIVE_INFINITY)
batch.queuedChars <= (options.flushMaxChars ?? Number.POSITIVE_INFINITY)
) {
this.flushSession(clientId, sessionId)
this.flush(clientId)
return
}
if (!batch.timer) {
@@ -90,64 +89,6 @@ export class DaemonStreamDataBatcher {
}
}
private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number {
let chars = 0
for (const entry of batch.queue) {
if (entry.sessionId === sessionId) {
chars += entry.data.length
}
}
return chars
}
private flushSession(clientId: string, sessionId: string): void {
const batch = this.pendingByClient.get(clientId)
if (!batch) {
return
}
const flushed: PendingStreamDataBatch['queue'] = []
const retained: PendingStreamDataBatch['queue'] = []
let flushedChars = 0
for (const entry of batch.queue) {
if (entry.sessionId === sessionId) {
flushed.push(entry)
flushedChars += entry.data.length
} else {
retained.push(entry)
}
}
if (flushed.length === 0) {
return
}
batch.queue = retained
batch.queuedChars -= flushedChars
if (batch.queue.length === 0) {
if (batch.timer) {
clearTimeout(batch.timer)
batch.timer = null
}
this.pendingByClient.delete(clientId)
}
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
return
}
for (const entry of flushed) {
client.streamSocket.write(
encodeNdjson({
type: 'event',
event: 'data',
sessionId: entry.sessionId,
payload: { data: entry.data }
})
)
}
}
clear(clientId?: string): void {
const batches =
clientId === undefined
+36 -90
View File
@@ -87,11 +87,6 @@ import {
shouldRecoverRendererAfterProcessGone,
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { isCrashReportReason } from '../shared/crash-reporting'
let mainWindow: BrowserWindow | null = null
@@ -420,11 +415,14 @@ function openMainWindow(): BrowserWindow {
// window recreations instead of stacking on top of stale listeners.
agentHookServer.setListener(null)
setMigrationUnsupportedPtyListener(null)
// Why: any running synthesized-title spinner timer would fire into a
// destroyed webContents; stop it here instead of deferring to per-pane
// teardown, which may never run for restored-but-never-torn-down panes
// when the window goes away.
stopAllSyntheticTitleSpinners()
// Why: any running synthesized-title spinner intervals would fire into a
// destroyed webContents; stop them all here instead of deferring to
// per-pane teardown, which may never run for restored-but-never-torn-down
// panes when the window goes away. stopSyntheticTitleSpinner deletes only
// the current entry, which the Map iterator handles safely.
for (const paneKey of syntheticTitleSpinnerByPaneKey.keys()) {
stopSyntheticTitleSpinner(paneKey)
}
})
mainWindow = window
agentHookServer.setListener(
@@ -629,9 +627,8 @@ const SYNTHETIC_TITLE_PROFILES: Record<string, SyntheticTitleProfile> = {
const syntheticTitleSpinnerByPaneKey = new Map<
string,
SyntheticTitleSpinnerEntry<SyntheticTitleProfile>
{ timer: ReturnType<typeof setInterval>; frame: number; profile: SyntheticTitleProfile }
>()
let syntheticTitleSpinnerTimer: ReturnType<typeof setInterval> | null = null
type ServeOptions = {
json: boolean
@@ -744,95 +741,29 @@ function installServeSignalHandlers(): void {
process.once('SIGTERM', quit)
}
// Why: on PTY teardown the paneKey mapping is dropped, so the spinner tick
// would keep firing but sendSyntheticTitle would no-op forever. Drop the
// entry explicitly so the shared timer shuts down once no panes are active.
// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner
// interval would keep firing but sendSyntheticTitle would no-op forever.
// Stop the interval explicitly so the process doesn't carry a timer per dead
// pane.
registerPaneKeyTeardownListener((paneKey) => {
stopSyntheticTitleSpinner(paneKey)
})
function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void {
function sendSyntheticTitle(ptyId: string, data: string): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Why: repeated working-spinner frames are decorative and can arrive every
// 80ms per agent. Final/permission frames are forced because they drive BEL.
if (
!shouldSendSyntheticTitleFrame({
force: options.force === true,
windowVisible: mainWindow.isVisible(),
windowFocused: mainWindow.isFocused()
})
) {
return
}
mainWindow.webContents.send('pty:data', { id: ptyId, data })
}
function canSendDecorativeSyntheticTitle(): boolean {
return (
mainWindow !== null &&
!mainWindow.isDestroyed() &&
shouldSendSyntheticTitleFrame({
force: false,
windowVisible: mainWindow.isVisible(),
windowFocused: mainWindow.isFocused()
})
)
}
function stopSyntheticTitleSpinner(paneKey: string): void {
if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) {
stopSyntheticTitleSpinnerTimerIfIdle()
const entry = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (entry) {
clearInterval(entry.timer)
syntheticTitleSpinnerByPaneKey.delete(paneKey)
}
}
function stopAllSyntheticTitleSpinners(): void {
syntheticTitleSpinnerByPaneKey.clear()
stopSyntheticTitleSpinnerTimer()
}
function stopSyntheticTitleSpinnerTimer(): void {
if (!syntheticTitleSpinnerTimer) {
return
}
clearInterval(syntheticTitleSpinnerTimer)
syntheticTitleSpinnerTimer = null
}
function stopSyntheticTitleSpinnerTimerIfIdle(): void {
if (syntheticTitleSpinnerByPaneKey.size === 0) {
stopSyntheticTitleSpinnerTimer()
}
}
function tickSyntheticTitleSpinners(): void {
if (!canSendDecorativeSyntheticTitle()) {
return
}
const ticks = advanceSyntheticTitleSpinnerEntries({
entries: syntheticTitleSpinnerByPaneKey,
frameCount: SPINNER_FRAMES.length,
getPtyIdForPaneKey
})
for (const tick of ticks) {
sendSyntheticTitle(
tick.ptyId,
`\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07`
)
}
stopSyntheticTitleSpinnerTimerIfIdle()
}
function ensureSyntheticTitleSpinnerTimer(): void {
if (syntheticTitleSpinnerTimer) {
return
}
// Why: a single process timer covers all synthesized title spinners; per-pane
// intervals multiplied idle wakeups when several retained agents were working.
syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS)
}
function driveSyntheticTitleFromHook(
paneKey: string,
state: string,
@@ -856,8 +787,23 @@ function driveSyntheticTitleFromHook(
existing.profile = profile
return
}
syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile })
ensureSyntheticTitleSpinnerTimer()
const timer = setInterval(() => {
const ptyIdNow = getPtyIdForPaneKey(paneKey)
if (!ptyIdNow) {
stopSyntheticTitleSpinner(paneKey)
return
}
const cur = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (!cur) {
return
}
cur.frame = (cur.frame + 1) % SPINNER_FRAMES.length
sendSyntheticTitle(
ptyIdNow,
`\x1b]0;${SPINNER_FRAMES[cur.frame]} ${cur.profile.workingLabel}\x07`
)
}, SPINNER_INTERVAL_MS)
syntheticTitleSpinnerByPaneKey.set(paneKey, { timer, frame, profile })
return
}
// Why: leaving the spinner running after a `blocked`/`waiting`/`done` event
@@ -873,7 +819,7 @@ function driveSyntheticTitleFromHook(
stopSyntheticTitleSpinner(paneKey)
const label =
state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`, { force: true })
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`)
}
app.whenReady().then(async () => {
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
describe('advanceSyntheticTitleSpinnerEntries', () => {
it('advances live panes in one shared tick and wraps frames', () => {
const entries = new Map<string, SyntheticTitleSpinnerEntry<{ label: string }>>([
['pane-a', { frame: 1, profile: { label: 'A' } }],
['pane-b', { frame: 2, profile: { label: 'B' } }]
])
const ticks = advanceSyntheticTitleSpinnerEntries({
entries,
frameCount: 3,
getPtyIdForPaneKey: (paneKey) => `pty-${paneKey}`
})
expect(ticks).toEqual([
{ paneKey: 'pane-a', ptyId: 'pty-pane-a', frame: 2, profile: { label: 'A' } },
{ paneKey: 'pane-b', ptyId: 'pty-pane-b', frame: 0, profile: { label: 'B' } }
])
expect(entries.get('pane-a')?.frame).toBe(2)
expect(entries.get('pane-b')?.frame).toBe(0)
})
it('drops panes whose pty mapping disappeared', () => {
const entries = new Map<string, SyntheticTitleSpinnerEntry<{ label: string }>>([
['live-pane', { frame: 0, profile: { label: 'live' } }],
['stale-pane', { frame: 0, profile: { label: 'stale' } }]
])
const ticks = advanceSyntheticTitleSpinnerEntries({
entries,
frameCount: 4,
getPtyIdForPaneKey: (paneKey) => (paneKey === 'stale-pane' ? null : `pty-${paneKey}`)
})
expect(ticks).toEqual([
{ paneKey: 'live-pane', ptyId: 'pty-live-pane', frame: 1, profile: { label: 'live' } }
])
expect(entries.has('live-pane')).toBe(true)
expect(entries.has('stale-pane')).toBe(false)
})
})
-33
View File
@@ -1,33 +0,0 @@
export type SyntheticTitleSpinnerEntry<TProfile> = {
frame: number
profile: TProfile
}
export type SyntheticTitleSpinnerTick<TProfile> = {
paneKey: string
ptyId: string
frame: number
profile: TProfile
}
export function advanceSyntheticTitleSpinnerEntries<TProfile>(args: {
entries: Map<string, SyntheticTitleSpinnerEntry<TProfile>>
frameCount: number
getPtyIdForPaneKey: (paneKey: string) => string | null | undefined
}): SyntheticTitleSpinnerTick<TProfile>[] {
if (args.frameCount <= 0) {
return []
}
const ticks: SyntheticTitleSpinnerTick<TProfile>[] = []
for (const [paneKey, entry] of args.entries) {
const ptyId = args.getPtyIdForPaneKey(paneKey)
if (!ptyId) {
args.entries.delete(paneKey)
continue
}
entry.frame = (entry.frame + 1) % args.frameCount
ticks.push({ paneKey, ptyId, frame: entry.frame, profile: entry.profile })
}
return ticks
}
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
describe('shouldSendSyntheticTitleFrame', () => {
it('skips decorative spinner frames only while the window is hidden', () => {
expect(
shouldSendSyntheticTitleFrame({ force: false, windowVisible: false, windowFocused: true })
).toBe(false)
expect(
shouldSendSyntheticTitleFrame({ force: false, windowVisible: true, windowFocused: false })
).toBe(true)
expect(
shouldSendSyntheticTitleFrame({ force: false, windowVisible: true, windowFocused: true })
).toBe(true)
})
it('always sends forced terminal-state frames', () => {
expect(
shouldSendSyntheticTitleFrame({ force: true, windowVisible: false, windowFocused: false })
).toBe(true)
})
})
-8
View File
@@ -1,8 +0,0 @@
export function shouldSendSyntheticTitleFrame(args: {
force: boolean
windowVisible: boolean
windowFocused: boolean
}): boolean {
void args.windowFocused
return args.force || args.windowVisible
}
+20 -4
View File
@@ -33,7 +33,6 @@ import {
} from '@/components/ui/context-menu'
import { useAppStore } from './store'
import { useShallow } from 'zustand/react/shallow'
import { useActiveTerminalTabs } from './store/selectors'
import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/useIpcEvents'
import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents'
import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate'
@@ -77,7 +76,6 @@ import {
usePrimarySelectionPaste
} from './hooks/usePrimarySelectionPaste'
import {
canSkipRuntimeMobileSessionSyncKeyBuild,
getRuntimeMobileSessionSyncKey,
runtimeMobileSessionSyncKeysEqual,
scheduleRuntimeGraphSync,
@@ -292,7 +290,7 @@ function App(): React.JSX.Element {
// that remount so the left workspace list doesn't restart at scrollTop 0.
const worktreeSidebarScrollOffsetRef = useRef(0)
const worktreeSidebarScrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
const tabs = useActiveTerminalTabs()
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const floatingUnifiedTabCount = useAppStore(
(s) => s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0
)
@@ -757,7 +755,24 @@ function App(): React.JSX.Element {
// by reference. Mirrors every field used by
// getRuntimeMobileSessionSyncKey so this gate covers every "could the
// key have changed?" case.
if (canSkipRuntimeMobileSessionSyncKeyBuild(state, previousState)) {
if (
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.layoutByWorktree === previousState.layoutByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
state.browserTabsByWorktree === previousState.browserTabsByWorktree &&
state.browserPagesByWorkspace === previousState.browserPagesByWorkspace &&
state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree &&
state.openFiles === previousState.openFiles &&
state.editorDrafts === previousState.editorDrafts &&
state.activeTabId === previousState.activeTabId &&
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId
) {
return
}
const nextKey = getRuntimeMobileSessionSyncKey(state, previousState, previousKey)
@@ -942,6 +957,7 @@ function App(): React.JSX.Element {
return () => document.removeEventListener('visibilitychange', handler)
}, [actions])
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const hasTabBar = tabs.length >= 2
const effectiveActiveTabId = activeTabId ?? tabs[0]?.id ?? null
const activeTabCanExpand = effectiveActiveTabId
@@ -1,23 +1,7 @@
import { RefreshCw } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { useMemo } from 'react'
import { useAppStore } from '../store'
const EMPTY_TABS: { id: string }[] = []
export function collectStalePtyIdsForTabs({
tabs,
ptyIdsByTabId,
codexRestartNoticeByPtyId
}: {
tabs: { id: string }[]
ptyIdsByTabId: Record<string, string[]>
codexRestartNoticeByPtyId: Record<string, unknown>
}): string[] {
return tabs.flatMap((tab) =>
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
)
}
export function collectStaleWorktreePtyIds({
tabsByWorktree,
ptyIdsByTabId,
@@ -29,11 +13,9 @@ export function collectStaleWorktreePtyIds({
codexRestartNoticeByPtyId: Record<string, unknown>
worktreeId: string
}): string[] {
return collectStalePtyIdsForTabs({
tabs: tabsByWorktree[worktreeId] ?? EMPTY_TABS,
ptyIdsByTabId,
codexRestartNoticeByPtyId
})
return (tabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
)
}
export function dismissStaleWorktreePtyIds(
@@ -53,18 +35,23 @@ export default function CodexRestartChip({
}: {
worktreeId: string
}): React.JSX.Element | null {
const staleWorktreePtyIds = useAppStore(
useShallow((s) =>
collectStalePtyIdsForTabs({
tabs: s.tabsByWorktree[worktreeId] ?? EMPTY_TABS,
ptyIdsByTabId: s.ptyIdsByTabId,
codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId
})
)
)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
const staleWorktreePtyIds = useMemo(
() =>
collectStaleWorktreePtyIds({
tabsByWorktree,
ptyIdsByTabId,
codexRestartNoticeByPtyId,
worktreeId
}),
[codexRestartNoticeByPtyId, ptyIdsByTabId, tabsByWorktree, worktreeId]
)
if (staleWorktreePtyIds.length === 0) {
return null
}
+125 -128
View File
@@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import React, { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import {
@@ -9,6 +9,7 @@ import {
type BackgroundMountTerminalWorktreeDetail
} from '@/constants/terminal'
import { useAppStore } from '../store'
import { useAllWorktrees } from '../store/selectors'
import { findWorktreeById } from '../store/slices/worktree-helpers'
import { createUntitledMarkdownFile } from '../lib/create-untitled-markdown'
import { getConnectionId } from '../lib/connection-context'
@@ -51,10 +52,6 @@ import {
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
import { getActiveWorktreeOpenFiles } from './terminal/active-worktree-open-files'
import { getTerminalBrowserTabSlices } from './terminal/terminal-browser-tab-slices'
import { getTerminalMountedWorktreeSnapshot } from './terminal/terminal-mounted-worktrees'
import { getTerminalTabSlices } from './terminal/terminal-tab-slices'
import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
@@ -91,8 +88,10 @@ const EditorPanel = lazy(() => import('./editor/EditorPanel'))
const CLOSE_DIALOG_DEBOUNCE_MS = 200
function Terminal(): React.JSX.Element | null {
const allWorktrees = useAllWorktrees()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
@@ -106,37 +105,7 @@ function Terminal(): React.JSX.Element | null {
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
// Track which worktrees have been activated during this app session.
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
// with ptyId: null, and TerminalPane would call connectPanePty → pty:spawn,
// creating a duplicate PTY for the same tab.
if (activeWorktreeId && workspaceSessionReady) {
mountedWorktreeIdsRef.current.add(activeWorktreeId)
}
const terminalWorktreeSnapshot = useAppStore((s) =>
getTerminalMountedWorktreeSnapshot(s.worktreesByRepo, mountedWorktreeIdsRef.current)
)
const terminalTabSlices = useAppStore((s) =>
getTerminalTabSlices(s.tabsByWorktree, mountedWorktreeIdsRef.current, activeWorktreeId)
)
const terminalBrowserTabSlices = useAppStore((s) =>
getTerminalBrowserTabSlices(
s.browserTabsByWorktree,
mountedWorktreeIdsRef.current,
activeWorktreeId
)
)
const worktreeFiles = useAppStore((s) =>
getActiveWorktreeOpenFiles(s.openFiles, activeWorktreeId)
)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const activeTabType = useAppStore((s) => s.activeTabType)
@@ -145,6 +114,7 @@ function Terminal(): React.JSX.Element | null {
const openFile = useAppStore((s) => s.openFile)
const closeFile = useAppStore((s) => s.closeFile)
const pinFile = useAppStore((s) => s.pinFile)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
@@ -168,7 +138,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'activity'
)
const tabs = terminalTabSlices.activeTabs
const tabs = useMemo(
() => (activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, tabsByWorktree]
)
// Why: the TabBar is rendered into the titlebar via a portal so tabs share
// the same row as the "Orca" title. The target element is created by App.tsx.
@@ -188,7 +161,13 @@ function Terminal(): React.JSX.Element | null {
ensureWorktreeRootGroup(activeWorktreeId)
}, [activeWorktreeId, ensureWorktreeRootGroup])
const worktreeBrowserTabs = terminalBrowserTabSlices.activeBrowserTabs
// Filter editor files to only show those belonging to the active worktree
const worktreeFiles = activeWorktreeId
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
: []
const worktreeBrowserTabs = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const getEffectiveLayoutForWorktree = useCallback(
(worktreeId: string) =>
getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree),
@@ -197,13 +176,13 @@ function Terminal(): React.JSX.Element | null {
const effectiveActiveLayout = activeWorktreeId
? getEffectiveLayoutForWorktree(activeWorktreeId)
: undefined
const activeWorktreeBrowserTabIdsKey = worktreeBrowserTabs.map((tab) => tab.id).join(',')
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
// Save confirmation dialog state
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
const saveDialogFile = useAppStore((s) =>
saveDialogFileId ? (s.openFiles.find((file) => file.id === saveDialogFileId) ?? null) : null
)
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
const pendingEditorCloseQueueRef = useRef<string[]>([])
// Why: while a save-and-close is awaiting the file to disappear from
@@ -536,6 +515,13 @@ function Terminal(): React.JSX.Element | null {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId, activeTabType, setActiveTab, tabs])
// Track which worktrees have been activated during this app session.
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const onBackgroundMountTerminalWorktree = (event: Event): void => {
@@ -578,15 +564,23 @@ function Terminal(): React.JSX.Element | null {
timers.clear()
}
}, [])
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
// with ptyId: null, and TerminalPane would call connectPanePty → pty:spawn,
// creating a duplicate PTY for the same tab.
if (activeWorktreeId && workspaceSessionReady) {
mountedWorktreeIdsRef.current.add(activeWorktreeId)
}
// Prune IDs of worktrees that no longer exist (deleted/removed)
const allWorktreeIds = new Set(terminalWorktreeSnapshot.worktreeIds)
const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id))
for (const id of mountedWorktreeIdsRef.current) {
if (!allWorktreeIds.has(id)) {
mountedWorktreeIdsRef.current.delete(id)
}
}
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
terminalWorktreeSnapshot.worktreeIds,
allWorktrees.map((wt) => wt.id),
mountedWorktreeIdsRef.current,
layoutByWorktree,
groupsByWorktree,
@@ -1405,29 +1399,31 @@ function Terminal(): React.JSX.Element | null {
can preserve hidden trees without reflowing the active one. Keep
a relative anchor here so those panes size to the workspace body
rather than some outer ancestor when split groups are enabled. */}
{terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => {
const layout = getEffectiveLayoutForWorktree(worktree.id)
if (!layout) {
return null
}
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
worktreeId={worktree.id}
worktreePath={worktree.path}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
/>
)
})}
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const layout = getEffectiveLayoutForWorktree(worktree.id)
if (!layout) {
return null
}
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
worktreeId={worktree.id}
worktreePath={worktree.path}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
/>
)
})}
</div>
) : null}
@@ -1465,65 +1461,67 @@ function Terminal(): React.JSX.Element | null {
: ''
}`}
>
{terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => {
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
return (
<div
key={worktree.id}
className={
isVisible
? 'absolute inset-0'
: shouldMeasureHiddenWorktree
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
{(terminalTabSlices.mountedTabsByWorktree[worktree.id] ?? []).map((tab) => {
const activityTerminalPortal = findActivityTerminalPortal(
activityTerminalPortals,
{ worktreeId: worktree.id, tabId: tab.id }
)
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
isActive={isActiveTerminalTab || activityTerminalPortal?.active === true}
// Why: the activity page hosts this existing pane via
// portal while the workspace surface remains hidden.
// Keeping `isVisible` true for the portaled tab lets
// xterm fit and stream foreground output in-place.
isVisible={isActiveTerminalTab || isActivityPortalTab}
// Why: when portaled to Activity for a specific agent
// pane, isolate that leaf so split siblings stay
// hidden. Workspace renders pass null → no override.
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
onCloseTab={() => handleCloseTab(tab.id)}
/>
)
if (activityTerminalPortal) {
return createPortal(
terminalPane,
activityTerminalPortal.target,
`activity-terminal-${tab.id}`
)
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
return (
<div
key={worktree.id}
className={
isVisible
? 'absolute inset-0'
: shouldMeasureHiddenWorktree
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'absolute inset-0 hidden'
}
return terminalPane
})}
</div>
)
})}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
{(tabsByWorktree[worktree.id] ?? []).map((tab) => {
const activityTerminalPortal = findActivityTerminalPortal(
activityTerminalPortals,
{ worktreeId: worktree.id, tabId: tab.id }
)
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
isActive={isActiveTerminalTab || activityTerminalPortal?.active === true}
// Why: the activity page hosts this existing pane via
// portal while the workspace surface remains hidden.
// Keeping `isVisible` true for the portaled tab lets
// xterm fit and stream foreground output in-place.
isVisible={isActiveTerminalTab || isActivityPortalTab}
// Why: when portaled to Activity for a specific agent
// pane, isolate that leaf so split siblings stay
// hidden. Workspace renders pass null → no override.
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
onCloseTab={() => handleCloseTab(tab.id)}
/>
)
if (activityTerminalPortal) {
return createPortal(
terminalPane,
activityTerminalPortal.target,
`activity-terminal-${tab.id}`
)
}
return terminalPane
})}
</div>
)
})}
</div>
{/* Browser panes container — all browser panes for the active worktree
@@ -1534,9 +1532,8 @@ function Terminal(): React.JSX.Element | null {
activeTabType !== 'browser' ? 'hidden' : ''
}`}
>
{terminalWorktreeSnapshot.mountedWorktrees.map((worktree) => {
const browserTabs =
terminalBrowserTabSlices.mountedBrowserTabsByWorktree[worktree.id] ?? []
{allWorktrees.map((worktree) => {
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so browser panes also hide on the tasks page.
const isVisibleWorktree =
@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const storeBox = vi.hoisted(() => ({
state: {
activeModal: null as string | null
}
}))
const selectorMocks = vi.hoisted(() => ({
getRepoMapFromState: vi.fn(() => new Map()),
useAllWorktrees: vi.fn(() => [])
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: typeof storeBox.state) => unknown) => selector(storeBox.state),
{
getState: () => storeBox.state
}
)
}))
vi.mock('@/store/selectors', () => ({
getRepoMapFromState: selectorMocks.getRepoMapFromState,
useAllWorktrees: selectorMocks.useAllWorktrees
}))
vi.mock('@/components/ui/command', () => ({
CommandDialog: () => null,
CommandEmpty: () => null,
CommandInput: () => null,
CommandItem: () => null,
CommandList: () => null
}))
vi.mock('@/components/sidebar/StatusIndicator', () => ({
default: () => null
}))
describe('WorktreeJumpPalette', () => {
beforeEach(() => {
vi.clearAllMocks()
storeBox.state.activeModal = null
})
it('does not mount broad workspace selectors while the lazy palette is closed', async () => {
const { default: WorktreeJumpPalette } = await import('./WorktreeJumpPalette')
expect(WorktreeJumpPalette()).toBeNull()
expect(selectorMocks.useAllWorktrees).not.toHaveBeenCalled()
})
})
@@ -78,13 +78,6 @@ type BrowserSelection = {
page: BrowserPage
}
type PaletteFocusSnapshot = {
worktreeId: string | null
tabType: 'browser' | 'editor' | 'terminal'
browserPageId: string | null
browserFocusTarget: 'webview' | 'address-bar'
}
function HighlightedText({
text,
matchRange
@@ -147,40 +140,8 @@ function findBrowserSelection(
return { page, workspace, worktree }
}
function getPaletteFocusSnapshot(): PaletteFocusSnapshot {
const state = useAppStore.getState()
const browserPageId =
state.activeWorktreeId && state.activeTabType === 'browser'
? ((state.browserTabsByWorktree[state.activeWorktreeId] ?? []).find(
(workspace) => workspace.id === state.activeBrowserTabId
)?.activePageId ?? null)
: null
return {
worktreeId: state.activeWorktreeId,
tabType: state.activeTabType,
browserPageId,
// Why: capture during render, before the portaled Dialog moves focus, so
// closing Cmd+J can restore the exact browser target that opened it.
browserFocusTarget:
state.activeTabType === 'browser' &&
typeof document !== 'undefined' &&
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[data-orca-browser-address-bar="true"]')
? 'address-bar'
: 'webview'
}
}
export default function WorktreeJumpPalette(): React.JSX.Element | null {
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
// Why: App keeps lazily loaded modals mounted after first use. The palette's
// search indexes subscribe to broad workspace slices, so drop the heavy
// content while closed to avoid rebuilding hidden jump results on hot ticks.
return visible ? <WorktreeJumpPaletteContent /> : null
}
function WorktreeJumpPaletteContent(): React.JSX.Element {
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
@@ -200,9 +161,10 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeTabType = useAppStore((s) => s.activeTabType)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
@@ -215,20 +177,11 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [selectedItemId, setSelectedItemId] = useState('')
const initialFocusSnapshotRef = useRef<PaletteFocusSnapshot | null>(null)
if (initialFocusSnapshotRef.current === null) {
initialFocusSnapshotRef.current = getPaletteFocusSnapshot()
}
const previousWorktreeIdRef = useRef<string | null>(initialFocusSnapshotRef.current.worktreeId)
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>(
initialFocusSnapshotRef.current.tabType
)
const previousBrowserPageIdRef = useRef<string | null>(
initialFocusSnapshotRef.current.browserPageId
)
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>(
initialFocusSnapshotRef.current.browserFocusTarget
)
const previousWorktreeIdRef = useRef<string | null>(null)
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal')
const previousBrowserPageIdRef = useRef<string | null>(null)
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
const wasVisibleRef = useRef(false)
const skipRestoreFocusRef = useRef(false)
const prevQueryRef = useRef('')
const listRef = useRef<HTMLDivElement>(null)
@@ -293,35 +246,33 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
// Why: typed queries still route through sortWorktreesSmart — switcher
// ranking only diverges from smart-sort on the empty-query branch.
const sortedWorktrees = useMemo(() => {
// Why: same-state prompt/tool pings cannot change smart-sort order; the
// epoch is the cheap invalidation signal for state/freshness changes.
void agentStatusEpoch
const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey
return hasQuery
? sortWorktreesSmart(
visibleWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
)
: switchableWorktreesForRows
}, [
agentStatusEpoch,
hasQuery,
visibleWorktrees,
switchableWorktreesForRows,
tabsByWorktree,
repoMap,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
])
const sortedWorktrees = useMemo(
() =>
hasQuery
? sortWorktreesSmart(
visibleWorktrees,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
)
: switchableWorktreesForRows,
[
hasQuery,
visibleWorktrees,
switchableWorktreesForRows,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
terminalLayoutsByTabId
]
)
const browserSortedWorktrees = useMemo(() => {
// Why: browser-tab search is explicitly cross-worktree, so it must keep
@@ -330,8 +281,6 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
// tab on the default-branch worktree before toggling hide-on should still
// be able to Cmd+J back to it — the setting hides the *workspace row*,
// not the browser tabs that live inside it.
void agentStatusEpoch
const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey
return sortWorktreesSmart(
allWorktrees,
tabsByWorktree,
@@ -344,9 +293,9 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
)
}, [
allWorktrees,
agentStatusEpoch,
tabsByWorktree,
repoMap,
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
migrationUnsupportedByPtyId,
@@ -537,6 +486,37 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
const hasAnyBrowserPages = browserPageEntries.length > 0
useEffect(() => {
if (visible && !wasVisibleRef.current) {
previousWorktreeIdRef.current = activeWorktreeId
previousActiveTabTypeRef.current = activeTabType
previousBrowserPageIdRef.current =
activeWorktreeId && activeTabType === 'browser'
? ((browserTabsByWorktree[activeWorktreeId] ?? []).find(
(workspace) => workspace.id === activeBrowserTabId
)?.activePageId ?? null)
: null
// Why: capture which browser surface had focus *before* Radix Dialog
// steals it. By onOpenAutoFocus time, document.activeElement has already
// moved to the dialog content, so address-bar detection must happen here.
previousBrowserFocusTargetRef.current =
activeTabType === 'browser' &&
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[data-orca-browser-address-bar="true"]')
? 'address-bar'
: 'webview'
skipRestoreFocusRef.current = false
prevQueryRef.current = ''
setQuery('')
setSelectedItemId('')
}
wasVisibleRef.current = visible
}, [activeBrowserTabId, activeTabType, activeWorktreeId, browserTabsByWorktree, visible])
useEffect(() => {
if (!visible) {
return
}
const queryChanged = deferredQuery !== prevQueryRef.current
prevQueryRef.current = deferredQuery
@@ -567,7 +547,7 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
) {
setSelectedItemId(firstSelectableId ?? selectableItems[0].id)
}
}, [deferredQuery, selectedItemId, showCreateAction, selectableItems])
}, [deferredQuery, selectedItemId, showCreateAction, visible, selectableItems])
const focusFallbackSurface = useCallback(() => {
requestAnimationFrame(() => {
@@ -862,7 +842,7 @@ function WorktreeJumpPaletteContent(): React.JSX.Element {
return (
<CommandDialog
open={true}
open={visible}
onOpenChange={handleOpenChange}
shouldFilter={false}
onOpenAutoFocus={handleOpenAutoFocus}
@@ -231,7 +231,7 @@ export default function AutomationsPage(): React.JSX.Element {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const closeAutomationsPage = useAppStore((s) => s.closeAutomationsPage)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const settings = useAppStore((s) => s.settings)
@@ -549,11 +549,6 @@ export default function AutomationsPage(): React.JSX.Element {
}, [refresh])
useEffect(() => {
// Why: completion detection only changes on agent state transitions.
// Same-state prompt/tool pings clone agentStatusByPaneKey frequently but
// cannot mark an automation complete; agentStatusEpoch is the cheap signal.
void agentStatusEpoch
const { agentStatusByPaneKey } = useAppStore.getState()
const inFlight = completionInFlightRef.current
const completedRuns = runs.filter((run) => {
if (run.status !== 'dispatched' || !run.terminalSessionId) {
@@ -609,7 +604,7 @@ export default function AutomationsPage(): React.JSX.Element {
inFlight.delete(run.id)
}
})
}, [agentStatusEpoch, retainedAgentsByPaneKey, refresh, runs])
}, [agentStatusByPaneKey, retainedAgentsByPaneKey, refresh, runs])
useEffect(() => {
if (!draft.projectId) {
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getBrowserPagesForWorkspace, shouldPollChromiumErrorPage } from './BrowserPane'
import type { BrowserPage } from '../../../../shared/types'
describe('shouldPollChromiumErrorPage', () => {
it('runs the fallback chrome-error poll only for the active loading browser pane', () => {
expect(shouldPollChromiumErrorPage({ isActive: true, loading: true })).toBe(true)
expect(shouldPollChromiumErrorPage({ isActive: false, loading: true })).toBe(false)
expect(shouldPollChromiumErrorPage({ isActive: true, loading: false })).toBe(false)
expect(shouldPollChromiumErrorPage({ isActive: false, loading: false })).toBe(false)
})
})
describe('getBrowserPagesForWorkspace', () => {
it('returns only the owning workspace page array so unrelated page updates keep the selector stable', () => {
const pages = [{ id: 'page-1' }] as BrowserPage[]
const browserPagesByWorkspace = {
workspaceA: pages,
workspaceB: [{ id: 'page-2' }] as BrowserPage[]
}
expect(getBrowserPagesForWorkspace(browserPagesByWorkspace, 'workspaceA')).toBe(pages)
expect(
getBrowserPagesForWorkspace(
{ ...browserPagesByWorkspace, workspaceB: [{ id: 'page-3' }] as BrowserPage[] },
'workspaceA'
)
).toBe(pages)
expect(getBrowserPagesForWorkspace(browserPagesByWorkspace, 'missing')).toBe(
getBrowserPagesForWorkspace({}, 'missing')
)
})
})
@@ -247,13 +247,6 @@ const PENDING_ANNOTATION_CARD_HEIGHT = 330
const WHEEL_DELTA_LINE = 1
const WHEEL_DELTA_PAGE = 2
export function getBrowserPagesForWorkspace(
browserPagesByWorkspace: Record<string, BrowserPageState[]>,
workspaceId: string
): BrowserPageState[] {
return browserPagesByWorkspace[workspaceId] ?? EMPTY_BROWSER_PAGES
}
function createBrowserAnnotationId(): string {
return `browser-annotation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
@@ -510,13 +503,6 @@ function isChromiumErrorPage(url: string): boolean {
return url.startsWith('chrome-error://')
}
export function shouldPollChromiumErrorPage(args: {
isActive: boolean
loading: boolean
}): boolean {
return args.isActive && args.loading
}
function fileUrlToAbsolutePath(url: string): string | null {
try {
const parsed = new URL(url)
@@ -794,9 +780,8 @@ export default function BrowserPane({
const activeRuntimeEnvironmentId = useAppStore(
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
)
const browserPages = useAppStore((s) =>
getBrowserPagesForWorkspace(s.browserPagesByWorkspace, browserTab.id)
)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const browserPages = browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES
const activeBrowserPage =
browserPages.find((page) => page.id === browserTab.activePageId) ?? browserPages[0] ?? null
const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState)
@@ -3673,7 +3658,7 @@ function BrowserPagePane({
}, [browserTab.url, focusWebviewNow])
useEffect(() => {
if (!shouldPollChromiumErrorPage({ isActive, loading: browserTab.loading })) {
if (!browserTab.loading) {
return
}
@@ -3706,13 +3691,12 @@ function BrowserPagePane({
// Why: some Electron builds paint Chromium's internal chrome-error page
// without delivering a timely did-fail-load event to the renderer webview.
// Polling only while the active tab is "loading" gives Orca a last-resort
// path to swap the black guest surface without waking every retained
// inactive browser pane on a 250ms loop.
// Polling only while the tab is "loading" gives Orca a last-resort path to
// swap the black guest surface for the explicit unreachable-page overlay.
detectChromiumErrorPage()
const intervalId = window.setInterval(detectChromiumErrorPage, 250)
return () => window.clearInterval(intervalId)
}, [browserTab.id, browserTab.loading, isActive])
}, [browserTab.id, browserTab.loading])
const startGrabIntent = useCallback(
(nextIntent: GrabIntent): void => {
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import {
collectStalePtyIdsForTabs,
collectStaleWorktreePtyIds,
dismissStaleWorktreePtyIds
} from './CodexRestartChip'
import { collectStaleWorktreePtyIds, dismissStaleWorktreePtyIds } from './CodexRestartChip'
describe('CodexRestartChip helpers', () => {
it('collects all stale PTY ids for tabs in a worktree', () => {
@@ -43,22 +39,6 @@ describe('CodexRestartChip helpers', () => {
).toEqual([])
})
it('collects from one worktree tab slice without scanning the whole tab map', () => {
expect(
collectStalePtyIdsForTabs({
tabs: [{ id: 'tab-1' }],
ptyIdsByTabId: {
'tab-1': ['pty-1'],
'tab-2': ['pty-2']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-2': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
}
})
).toEqual(['pty-1'])
})
it('dismisses every stale PTY notice in the worktree prompt', () => {
const clearCodexRestartNotice = vi.fn()
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createSharedNowClock } from './useNow'
describe('createSharedNowClock', () => {
it('shares one timer across subscribers and clears it when idle', () => {
let now = 1_000
const intervalCallbacks: (() => void)[] = []
const handle = {} as ReturnType<typeof setInterval>
const setIntervalMock = vi.fn((callback: () => void) => {
intervalCallbacks.push(callback)
return handle
})
const clearIntervalMock = vi.fn()
const first = vi.fn()
const second = vi.fn()
const clock = createSharedNowClock(30_000, {
now: () => now,
setInterval: setIntervalMock,
clearInterval: clearIntervalMock
})
const unsubscribeFirst = clock.subscribe(first)
const unsubscribeSecond = clock.subscribe(second)
expect(setIntervalMock).toHaveBeenCalledTimes(1)
expect(first).toHaveBeenCalledTimes(1)
expect(second).not.toHaveBeenCalled()
now = 31_000
const intervalCallback = intervalCallbacks[0]
if (!intervalCallback) {
throw new Error('expected shared clock to schedule an interval')
}
intervalCallback()
expect(clock.getSnapshot()).toBe(31_000)
expect(first).toHaveBeenCalledTimes(2)
expect(second).toHaveBeenCalledTimes(1)
unsubscribeFirst()
expect(clearIntervalMock).not.toHaveBeenCalled()
unsubscribeSecond()
expect(clearIntervalMock).toHaveBeenCalledWith(handle)
})
it('refreshes the snapshot when a new subscriber restarts an idle clock', () => {
let now = 1_000
const handle = {} as ReturnType<typeof setInterval>
const setIntervalMock = vi.fn(() => handle)
const clearIntervalMock = vi.fn()
const first = vi.fn()
const second = vi.fn()
const clock = createSharedNowClock(30_000, {
now: () => now,
setInterval: setIntervalMock,
clearInterval: clearIntervalMock
})
const unsubscribeFirst = clock.subscribe(first)
expect(clock.getSnapshot()).toBe(1_000)
unsubscribeFirst()
now = 61_000
clock.subscribe(second)
expect(clock.getSnapshot()).toBe(61_000)
expect(second).toHaveBeenCalledTimes(1)
expect(setIntervalMock).toHaveBeenCalledTimes(2)
})
})
@@ -1,67 +1,4 @@
import { useSyncExternalStore } from 'react'
type ClockDeps = {
now: () => number
setInterval: (callback: () => void, intervalMs: number) => ReturnType<typeof setInterval>
clearInterval: (handle: ReturnType<typeof setInterval>) => void
}
type SharedNowClock = {
getSnapshot: () => number
subscribe: (listener: () => void) => () => void
}
const nowClocks = new Map<number, SharedNowClock>()
export function createSharedNowClock(
intervalMs: number,
deps: ClockDeps = {
now: () => Date.now(),
setInterval: (callback, ms) => setInterval(callback, ms),
clearInterval: (handle) => clearInterval(handle)
}
): SharedNowClock {
let now = deps.now()
let timer: ReturnType<typeof setInterval> | null = null
const listeners = new Set<() => void>()
const tick = (): void => {
now = deps.now()
for (const listener of listeners) {
listener()
}
}
return {
getSnapshot: () => now,
subscribe: (listener) => {
listeners.add(listener)
if (!timer) {
// Why: all mounted relative-time labels at the same cadence can share
// one timer. Refresh immediately on restart so remounted labels don't
// display the stale timestamp left from the previous subscriber set.
tick()
timer = deps.setInterval(tick, intervalMs)
}
return () => {
listeners.delete(listener)
if (listeners.size === 0 && timer) {
deps.clearInterval(timer)
timer = null
}
}
}
}
}
function getSharedNowClock(intervalMs: number): SharedNowClock {
let clock = nowClocks.get(intervalMs)
if (!clock) {
clock = createSharedNowClock(intervalMs)
nowClocks.set(intervalMs, clock)
}
return clock
}
import { useEffect, useState } from 'react'
// Why: relative timestamps drift once mounted. A 30s tick keeps the "Xm
// ago" labels honest without burning a render every second.
@@ -72,6 +9,10 @@ function getSharedNowClock(intervalMs: number): SharedNowClock {
// which meant N timers firing at staggered mount times for N rows on
// screen — turning one logical tick into N independent React commits.
export function useNow(intervalMs: number): number {
const clock = getSharedNowClock(intervalMs)
return useSyncExternalStore(clock.subscribe, clock.getSnapshot, clock.getSnapshot)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
@@ -1,5 +1,4 @@
import { useEffect, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { type DashboardAgentRow } from './useDashboardData'
@@ -149,8 +148,14 @@ export function useRetainedAgentsSync(): void {
const retainAgents = useAppStore((s) => s.retainAgents)
const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents)
const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys)
const [repos, worktreesByRepo, tabsByWorktree, agentStatusEpoch] = useAppStore(
useShallow((s) => [s.repos, s.worktreesByRepo, s.tabsByWorktree, s.agentStatusEpoch] as const)
const retentionSignature = useAppStore((s) =>
buildRetainedAgentsSyncSignature({
repos: s.repos,
worktreesByRepo: s.worktreesByRepo,
tabsByWorktree: s.tabsByWorktree,
agentStatusByPaneKey: s.agentStatusByPaneKey,
agentStatusEpoch: s.agentStatusEpoch
})
)
const prevAgentsRef = useRef<RetainedAgentSnapshot>(new Map())
@@ -165,9 +170,9 @@ export function useRetainedAgentsSync(): void {
now: Date.now()
})
// Why: read retention state via getState() after the cheap ref/epoch gate
// fires. Building the full retention snapshot scans all agents, so do it
// only when live identity/state/freshness/final-done data or worktree
// Why: read retention state via getState() instead of subscribing. This
// effect's driving input is the retention signature — retention decisions
// only need to happen when live identity/state/freshness or worktree
// membership changes. Subscribing to retainedAgentsByPaneKey would create
// a feedback loop because this effect calls retainAgents.
const { retainedAgentsByPaneKey: retainedNow, retentionSuppressedPaneKeys } = state
@@ -189,15 +194,7 @@ export function useRetainedAgentsSync(): void {
if (consumedSuppressedPaneKeys.length > 0) {
clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys)
}
}, [
repos,
worktreesByRepo,
tabsByWorktree,
agentStatusEpoch,
retainAgents,
pruneRetainedAgents,
clearRetentionSuppressedPaneKeys
])
}, [retentionSignature, retainAgents, pruneRetainedAgents, clearRetentionSuppressedPaneKeys])
}
export function collectRetainedAgentsOnDisappear(args: {
@@ -63,7 +63,6 @@ import {
getMaximizedFloatingTerminalBounds,
type FloatingTerminalPanelBounds
} from './floating-terminal-panel-bounds'
import { getFloatingTerminalOpenFiles } from './floating-terminal-open-files'
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
const EMPTY_GROUPS: TabGroup[] = []
@@ -89,19 +88,11 @@ export function FloatingTerminalPanel({
open,
onOpenChange
}: FloatingTerminalPanelProps): React.JSX.Element | null {
const tabs = useAppStore(
(s) => s.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
)
const browserTabs = useAppStore(
(s) => s.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
)
const groups = useAppStore(
(s) => s.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
)
const unifiedTabs = useAppStore(
(s) => s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
)
const floatingFiles = useAppStore((s) => getFloatingTerminalOpenFiles(s.openFiles))
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const createTab = useAppStore((s) => s.createTab)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
@@ -140,6 +131,14 @@ export function FloatingTerminalPanel({
top: number
} | null>(null)
const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
const browserTabs = browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
const groups = groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
const unifiedTabs = unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
const floatingFiles = useMemo(
() => openFiles.filter((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID),
[openFiles]
)
const activeGroup = useMemo(
() =>
groups.find((group) => group.activeTabId != null) ??
@@ -242,7 +241,7 @@ export function FloatingTerminalPanel({
handleSaveDialogSave,
handleSaveDialogDiscard,
handleSaveDialogCancel
} = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile, markFileDirty })
} = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty })
const getNextQueuedEditorClose = useCallback((): string | null => {
while (pendingEditorCloseQueueRef.current.length > 0) {
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { OpenFile } from '@/store/slices/editor'
import { getFloatingTerminalOpenFiles } from './floating-terminal-open-files'
const file = (id: string, worktreeId: string): OpenFile =>
({
id,
filePath: `/tmp/${id}.md`,
relativePath: `${id}.md`,
worktreeId,
language: 'markdown',
content: '',
isDirty: false,
isPinned: false,
mode: 'edit',
mtime: 0,
runtimeEnvironmentId: null
}) as OpenFile
describe('getFloatingTerminalOpenFiles', () => {
it('preserves the filtered array when unrelated worktree files change', () => {
const floating = file('floating', FLOATING_TERMINAL_WORKTREE_ID)
const first = getFloatingTerminalOpenFiles([floating, file('main-a', 'wt-1')])
const second = getFloatingTerminalOpenFiles([floating, file('main-b', 'wt-2')])
expect(second).toBe(first)
expect(second).toEqual([floating])
})
})
@@ -1,27 +0,0 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { OpenFile } from '@/store/slices/editor'
let cachedOpenFiles: OpenFile[] | null = null
let cachedFloatingFiles: OpenFile[] = []
export function getFloatingTerminalOpenFiles(openFiles: OpenFile[]): OpenFile[] {
if (openFiles === cachedOpenFiles) {
return cachedFloatingFiles
}
const nextFloatingFiles = openFiles.filter(
(file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID
)
if (
cachedOpenFiles !== null &&
nextFloatingFiles.length === cachedFloatingFiles.length &&
nextFloatingFiles.every((file, index) => file === cachedFloatingFiles[index])
) {
cachedOpenFiles = openFiles
return cachedFloatingFiles
}
cachedOpenFiles = openFiles
cachedFloatingFiles = nextFloatingFiles
return cachedFloatingFiles
}
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Gauge, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils'
import { installFocusedVisibilityInterval } from '@/lib/focused-visibility-interval'
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types'
@@ -97,10 +96,20 @@ export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }):
if (!autoRefresh) {
return
}
return installFocusedVisibilityInterval({
run: () => void refresh(false),
intervalMs: REFRESH_INTERVAL_MS
})
const fetchIfVisible = (): void => {
if (document.visibilityState === 'visible' && document.hasFocus()) {
void refresh(false)
}
}
void refresh(false)
const handle = window.setInterval(fetchIfVisible, REFRESH_INTERVAL_MS)
window.addEventListener('focus', fetchIfVisible)
document.addEventListener('visibilitychange', fetchIfVisible)
return () => {
window.clearInterval(handle)
window.removeEventListener('focus', fetchIfVisible)
document.removeEventListener('visibilitychange', fetchIfVisible)
}
}, [autoRefresh, refresh])
return { snapshot, hasError, isFetching, refresh }
+13 -15
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { usePetUrl } from './usePetUrl'
import type { DetectedSpriteCacheEntry } from './pet-blob-cache'
import type { CustomPet } from '../../../../shared/types'
@@ -9,23 +9,21 @@ import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state
type Sprite = NonNullable<CustomPet['sprite']>
function usePetAnimationName(dragging: boolean): PetAnimationName {
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
return useMemo(() => {
// Why: same-state prompt/tool pings clone agentStatusByPaneKey at PTY-event
// frequency, but they cannot change the coarse pet animation. Recompute on
// agentStatusEpoch instead, which also ticks when freshness boundaries pass.
void agentStatusEpoch
const { agentStatusByPaneKey } = useAppStore.getState()
return selectPetAnimationName({
entries: Object.values(agentStatusByPaneKey),
retainedCount: Object.keys(retainedAgentsByPaneKey).length,
dragging,
now: Date.now(),
staleAfterMs: AGENT_STATUS_STALE_AFTER_MS
})
}, [agentStatusEpoch, dragging, retainedAgentsByPaneKey])
// Re-render when the freshness scheduler ticks so stale live states stop
// driving pet animations even if no other store value changes.
void agentStatusEpoch
return selectPetAnimationName({
entries: Object.values(agentStatusByPaneKey),
retainedCount: Object.keys(retainedAgentsByPaneKey).length,
dragging,
now: Date.now(),
staleAfterMs: AGENT_STATUS_STALE_AFTER_MS
})
}
// Why: pet bundles ship a sprite sheet — animate by stepping a CSS background
@@ -1,11 +0,0 @@
import { describe, expect, it } from 'vitest'
import { shouldRunWorkspacePortScan } from './WorkspacePortScanner'
describe('shouldRunWorkspacePortScan', () => {
it('runs only while the document is visible', () => {
expect(shouldRunWorkspacePortScan({ documentVisible: true, windowFocused: true })).toBe(true)
expect(shouldRunWorkspacePortScan({ documentVisible: false, windowFocused: true })).toBe(false)
expect(shouldRunWorkspacePortScan({ documentVisible: true, windowFocused: false })).toBe(true)
expect(shouldRunWorkspacePortScan({ documentVisible: false, windowFocused: false })).toBe(false)
})
})
@@ -6,22 +6,10 @@ import {
scanWorkspacePortsForTarget,
workspacePortRuntimeTargetKey
} from '@/lib/workspace-port-actions'
import { installFocusedVisibilityInterval } from '@/lib/focused-visibility-interval'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
const WORKSPACE_PORT_SCAN_INTERVAL_MS = 5_000
export function shouldRunWorkspacePortScan({
documentVisible,
windowFocused
}: {
documentVisible: boolean
windowFocused: boolean
}): boolean {
void windowFocused
return documentVisible
}
function makeUnavailableScan(reason: string): WorkspacePortScanResult {
return {
platform: 'unknown',
@@ -83,21 +71,42 @@ export function WorkspacePortScanner(): null {
}, [hasWorktrees, runtimeTarget, scanKey, setWorkspacePortScan, setWorkspacePortScanRefreshing])
useEffect(() => {
let cancelled = false
let timeout: ReturnType<typeof setTimeout> | null = null
generationRef.current += 1
setWorkspacePortScan(null)
// Why: workspace port scans can cross runtime IPC or shell out remotely.
// Keep the timer stopped while no UI can display the result; visibility
// changes run one immediate refresh on return.
const stopFocusedInterval = installFocusedVisibilityInterval({
run: () => void refresh(),
intervalMs: WORKSPACE_PORT_SCAN_INTERVAL_MS
})
const run = async (): Promise<void> => {
try {
if (document.visibilityState === 'visible') {
await refresh()
}
} finally {
if (!cancelled) {
timeout = setTimeout(() => void run(), WORKSPACE_PORT_SCAN_INTERVAL_MS)
}
}
}
void run()
const refreshWhenVisible = (): void => {
if (document.visibilityState === 'visible' && document.hasFocus()) {
void refresh()
}
}
window.addEventListener('focus', refreshWhenVisible)
document.addEventListener('visibilitychange', refreshWhenVisible)
return () => {
cancelled = true
generationRef.current += 1
inFlightRef.current = null
stopFocusedInterval()
if (timeout) {
clearTimeout(timeout)
}
window.removeEventListener('focus', refreshWhenVisible)
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}, [refresh, setWorkspacePortScan])
@@ -36,8 +36,7 @@ import {
import { hostedReviewSummaryFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import {
checksPanelAsyncResultKey,
shouldCommitChecksPanelAsyncResult,
shouldPollChecksPanel
shouldCommitChecksPanelAsyncResult
} from './checks-panel-async-result-key'
export default function ChecksPanel(): React.JSX.Element {
@@ -52,15 +51,9 @@ export default function ChecksPanel(): React.JSX.Element {
(s) => s.getHostedReviewCreationEligibility
)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
)
const hasUncommittedChanges = useAppStore((s) =>
activeWorktreeId ? (s.gitStatusByWorktree[activeWorktreeId]?.length ?? 0) > 0 : false
)
const remoteStatus = useAppStore((s) =>
activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined
)
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree)
const pushBranch = useAppStore((s) => s.pushBranch)
const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
@@ -142,6 +135,13 @@ export default function ChecksPanel(): React.JSX.Element {
prCacheKey ? s.prRefreshStates[prCacheKey] : undefined
)
const prNumber = pr?.number ?? null
const remoteStatus = activeWorktreeId ? remoteStatusesByWorktree[activeWorktreeId] : undefined
const hasUncommittedChanges = activeWorktreeId
? (gitStatusByWorktree[activeWorktreeId]?.length ?? 0) > 0
: false
const conflictOperation = activeWorktreeId
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
: 'unknown'
// Why: select only timestamps (not whole cache records) so the entry-refresh
// effect doesn't re-run on every cache mutation. See
@@ -389,62 +389,24 @@ export default function ChecksPanel(): React.JSX.Element {
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
let cancelled = false
void fetchChecks()
const shouldPollNow = (): boolean =>
shouldPollChecksPanel({
documentVisible: document.visibilityState === 'visible',
windowFocused: document.hasFocus()
})
const clearScheduledPoll = (): void => {
if (pollRef.current) {
clearTimeout(pollRef.current)
pollRef.current = null
}
}
let fetchInFlight: Promise<void> | null = null
function schedulePoll(): void {
clearScheduledPoll()
if (cancelled || !shouldPollNow()) {
return
}
const schedulePoll = (): void => {
pollRef.current = setTimeout(() => {
pollRef.current = null
fetchAndSchedule()
void fetchChecks().then(() => {
if (!cancelled) {
schedulePoll()
}
})
}, pollIntervalRef.current)
}
function fetchAndSchedule(): void {
clearScheduledPoll()
if (cancelled || !shouldPollNow() || fetchInFlight) {
return
}
fetchInFlight = fetchChecks().finally(() => {
fetchInFlight = null
schedulePoll()
})
}
const reconcileVisibility = (): void => {
if (shouldPollNow()) {
fetchAndSchedule()
} else {
clearScheduledPoll()
}
}
fetchAndSchedule()
window.addEventListener('focus', reconcileVisibility)
window.addEventListener('blur', reconcileVisibility)
document.addEventListener('visibilitychange', reconcileVisibility)
schedulePoll()
return () => {
cancelled = true
clearScheduledPoll()
window.removeEventListener('focus', reconcileVisibility)
window.removeEventListener('blur', reconcileVisibility)
document.removeEventListener('visibilitychange', reconcileVisibility)
if (pollRef.current) {
clearTimeout(pollRef.current)
}
}
}, [fetchChecks, isPanelVisible, prNumber])
@@ -29,10 +29,6 @@ import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
import { useFileExplorerSelection } from './useFileExplorerSelection'
import { useFileExplorerGitIgnoredRows } from './useFileExplorerGitIgnoredRows'
import { getActiveWorktreeOpenFiles } from '@/components/terminal/active-worktree-open-files'
import type { GitStatusEntry } from '../../../../shared/git-status-types'
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -48,12 +44,8 @@ function FileExplorerInner(): React.JSX.Element {
const openFile = useAppStore((s) => s.openFile)
const pinFile = useAppStore((s) => s.pinFile)
const activeFileId = useAppStore((s) => s.activeFileId)
const entries = useAppStore((s) =>
activeWorktreeId
? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES)
: EMPTY_GIT_STATUS_ENTRIES
)
const openFiles = useAppStore((s) => getActiveWorktreeOpenFiles(s.openFiles, activeWorktreeId))
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const closeFile = useAppStore((s) => s.closeFile)
const worktreePath = activeWorktree?.path ?? null
@@ -119,6 +111,10 @@ function FileExplorerInner(): React.JSX.Element {
}
}, [])
const entries = useMemo(
() => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, gitStatusByWorktree]
)
const statusByRelativePath = useMemo(() => buildStatusMap(entries), [entries])
const folderStatusByRelativePath = useMemo(() => buildFolderStatusMap(entries), [entries])
@@ -107,7 +107,6 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { DiffNotesSendMenu } from '@/components/editor/DiffNotesSendMenu'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { installFocusedVisibilityInterval } from '@/lib/focused-visibility-interval'
import {
notifyEditorExternalFileChange,
requestEditorSaveQuiesce
@@ -143,6 +142,7 @@ import type {
GitConflictKind,
GitConflictOperation,
GitStatusEntry,
GitUpstreamStatus,
GlobalSettings,
SourceControlViewMode,
TuiAgent
@@ -161,9 +161,6 @@ import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commi
export type SourceControlScope = 'all' | 'uncommitted'
type RemoteActionError = { kind: RemoteOpKind; message: string }
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = []
const EMPTY_BRANCH_CHANGE_ENTRIES: GitBranchChangeEntry[] = []
// Why: directional signifiers ahead of each primary action label. Commit
// (✓) is affirmative; Push (↑) points in the direction data flows; Sync
// (↕) is bidirectional; Publish gets a cloud-up to distinguish the
@@ -767,27 +764,11 @@ function SourceControlInner(): React.JSX.Element {
const worktreeMap = useWorktreeMap()
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const entries = useAppStore((s) =>
activeWorktreeId
? (s.gitStatusByWorktree[activeWorktreeId] ?? EMPTY_GIT_STATUS_ENTRIES)
: EMPTY_GIT_STATUS_ENTRIES
)
const branchEntries = useAppStore((s) =>
activeWorktreeId
? (s.gitBranchChangesByWorktree[activeWorktreeId] ?? EMPTY_BRANCH_CHANGE_ENTRIES)
: EMPTY_BRANCH_CHANGE_ENTRIES
)
const branchSummary = useAppStore((s) =>
activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null
)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
)
// Why: leave undefined until fetchUpstreamStatus resolves for this worktree.
// A synthetic "no upstream" flashes "Publish Branch" during worktree switches.
const remoteStatus = useAppStore((s) =>
activeWorktreeId ? s.remoteStatusesByWorktree[activeWorktreeId] : undefined
)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
const gitBranchCompareSummaryByWorktree = useAppStore((s) => s.gitBranchCompareSummaryByWorktree)
const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree)
const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive)
const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind)
const settings = useAppStore((s) => s.settings)
@@ -1054,6 +1035,27 @@ function SourceControlInner(): React.JSX.Element {
activePullRequestGenerationRecordCandidate.context.branch === branchName
? activePullRequestGenerationRecordCandidate
: null
const entries = useMemo(
() => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, gitStatusByWorktree]
)
const branchEntries = useMemo(
() => (activeWorktreeId ? (gitBranchChangesByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, gitBranchChangesByWorktree]
)
const branchSummary = activeWorktreeId
? (gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null)
: null
const conflictOperation = activeWorktreeId
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
: 'unknown'
// Why: leave undefined until fetchUpstreamStatus resolves for this worktree.
// Substituting a synthetic { hasUpstream: false } flashes "Publish Branch"
// on every worktree switch — resolvePrimaryAction treats it as an
// unpublished branch until the real status lands a moment later.
const remoteStatus: GitUpstreamStatus | undefined = activeWorktreeId
? remoteStatusesByWorktree[activeWorktreeId]
: undefined
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
// Why: gate polling on both the active tab AND the sidebar being open.
// The sidebar now stays mounted when closed (for performance), so without
@@ -2808,12 +2810,7 @@ function SourceControlInner(): React.JSX.Element {
refreshActiveGitStatusAfterMutation
])
const branchCompareInFlightRef = useRef(false)
const branchCompareRerunRef = useRef(false)
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
const refreshBranchCompareRef = useRef<() => Promise<void>>(async () => {})
const runBranchCompare = useCallback(async () => {
const refreshBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
return
}
@@ -2879,38 +2876,7 @@ function SourceControlInner(): React.JSX.Element {
worktreePath
])
const refreshBranchCompare = useCallback(async () => {
if (branchCompareInFlightRef.current) {
branchCompareRerunRef.current = true
return branchCompareRunPromiseRef.current ?? undefined
}
branchCompareInFlightRef.current = true
const runPromise = (async (): Promise<void> => {
// Why: branch compare shells out to git on a timer and can exceed the
// 5s poll interval on large repos. Keep one compare chain in flight and
// collapse skipped ticks into one trailing refresh instead of stacking
// subprocesses while preserving the await contract for direct callers.
try {
await runBranchCompare()
} finally {
branchCompareInFlightRef.current = false
if (branchCompareRerunRef.current) {
branchCompareRerunRef.current = false
await refreshBranchCompareRef.current()
}
}
})()
branchCompareRunPromiseRef.current = runPromise
try {
await runPromise
} finally {
if (branchCompareRunPromiseRef.current === runPromise) {
branchCompareRunPromiseRef.current = null
}
}
}, [runBranchCompare])
const refreshBranchCompareRef = useRef(refreshBranchCompare)
refreshBranchCompareRef.current = refreshBranchCompare
const refreshGitHistory = useCallback(async (): Promise<void> => {
@@ -2972,13 +2938,21 @@ function SourceControlInner(): React.JSX.Element {
return
}
void refreshBranchCompareRef.current()
const refreshIfFocused = (): void => {
if (document.hasFocus()) {
void refreshBranchCompareRef.current()
}
}
// Why: branch compare shells out to git every tick. The panel only needs
// background freshness while Orca is visible; hidden-window time should not
// burn subprocess work or timer wakeups.
return installFocusedVisibilityInterval({
run: () => void refreshBranchCompareRef.current(),
intervalMs: BRANCH_REFRESH_INTERVAL_MS
})
// background freshness while Orca is focused; on focus we refresh
// immediately so hidden-window time does not burn subprocess work.
const intervalId = window.setInterval(refreshIfFocused, BRANCH_REFRESH_INTERVAL_MS)
window.addEventListener('focus', refreshIfFocused)
return () => {
window.clearInterval(intervalId)
window.removeEventListener('focus', refreshIfFocused)
}
}, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath])
useEffect(() => {
@@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
checksPanelAsyncResultKey,
shouldCommitChecksPanelAsyncResult,
shouldPollChecksPanel
shouldCommitChecksPanelAsyncResult
} from './checks-panel-async-result-key'
describe('checksPanelAsyncResultKey', () => {
@@ -34,14 +33,6 @@ describe('checksPanelAsyncResultKey', () => {
})
})
describe('shouldPollChecksPanel', () => {
it('skips checks polling only while the window is hidden', () => {
expect(shouldPollChecksPanel({ documentVisible: false, windowFocused: true })).toBe(false)
expect(shouldPollChecksPanel({ documentVisible: true, windowFocused: false })).toBe(true)
expect(shouldPollChecksPanel({ documentVisible: true, windowFocused: true })).toBe(true)
})
})
describe('shouldCommitChecksPanelAsyncResult', () => {
it('suppresses stale async completions', () => {
expect(
@@ -25,11 +25,3 @@ export function shouldCommitChecksPanelAsyncResult(
): boolean {
return currentKey === requestKey
}
export function shouldPollChecksPanel(args: {
documentVisible: boolean
windowFocused: boolean
}): boolean {
void args.windowFocused
return args.documentVisible
}
@@ -12,7 +12,6 @@ import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-f
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { renameRuntimePath } from '@/runtime/runtime-file-client'
import { getActiveWorktreeOpenFiles } from '@/components/terminal/active-worktree-open-files'
function extractIpcErrorMessage(err: unknown, fallback: string): string {
if (!(err instanceof Error)) {
@@ -101,7 +100,7 @@ export function useFileExplorerDragDrop({
refreshDir,
scrollRef
}: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult {
const openFiles = useAppStore((s) => getActiveWorktreeOpenFiles(s.openFiles, activeWorktreeId))
const openFiles = useAppStore((s) => s.openFiles)
const [isRootDragOver, setIsRootDragOver] = useState(false)
const rootDragCounterRef = useRef(0)
@@ -243,50 +243,4 @@ describe('useGitStatusPolling', () => {
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(2))
})
it('keeps the git poll interval scoped to visible windows', async () => {
vi.resetModules()
let visibilityState: DocumentVisibilityState = 'hidden'
const listeners = new Map<string, () => void>()
const clearIntervalMock = vi.fn()
const setIntervalMock = vi.fn(() => 1 as unknown as ReturnType<typeof setInterval>)
const run = vi.fn()
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
get visibilityState() {
return visibilityState
},
hasFocus: () => false,
addEventListener: vi.fn((event: string, listener: () => void) => {
listeners.set(event, listener)
}),
removeEventListener: vi.fn()
})
const { installFocusedGitPoll } = await import('./useGitStatusPolling')
const cleanup = installFocusedGitPoll({
run,
intervalMs: 3000,
setIntervalFn: setIntervalMock,
clearIntervalFn: clearIntervalMock
})
expect(run).not.toHaveBeenCalled()
expect(setIntervalMock).not.toHaveBeenCalled()
visibilityState = 'visible'
listeners.get('visibilitychange')?.()
expect(run).toHaveBeenCalledTimes(1)
expect(setIntervalMock).toHaveBeenCalledTimes(1)
visibilityState = 'hidden'
listeners.get('visibilitychange')?.()
expect(clearIntervalMock).toHaveBeenCalledWith(1)
cleanup()
})
})
@@ -7,14 +7,8 @@ import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client'
import { refreshGitStatusForWorktree } from './git-status-refresh'
import { createCoalescedPollRunner } from './coalesced-poll-runner'
import {
installFocusedVisibilityInterval,
isWindowVisible
} from '@/lib/focused-visibility-interval'
const POLL_INTERVAL_MS = 3000
export const isGitPollWindowVisible = isWindowVisible
export const installFocusedGitPoll = installFocusedVisibilityInterval
export function useGitStatusPolling(): void {
const activeWorktree = useActiveWorktree()
@@ -121,7 +115,24 @@ export function useGitStatusPolling(): void {
fetchStatusRef.current = fetchStatus
useEffect(() => {
return installFocusedVisibilityInterval({ run: fetchStatus, intervalMs: POLL_INTERVAL_MS })
void fetchStatus()
// Why: skip IPC-heavy git status calls when the window is not focused.
// These intervals run at the App root level regardless of which sidebar tab
// is open, so gating on document.hasFocus() prevents wasted CPU and IPC
// traffic while the user is working in another application.
const intervalId = setInterval(() => {
if (document.hasFocus()) {
void fetchStatus()
}
}, POLL_INTERVAL_MS)
// Why: when the user returns to the window, poll immediately so the sidebar
// shows up-to-date status without waiting up to POLL_INTERVAL_MS.
const onFocus = (): void => void fetchStatus()
window.addEventListener('focus', onFocus)
return () => {
clearInterval(intervalId)
window.removeEventListener('focus', onFocus)
}
}, [fetchStatus])
// Why: poll conflict operation for non-active worktrees that have a stale
@@ -158,13 +169,18 @@ export function useGitStatusPolling(): void {
// flight and coalesce skipped ticks into one trailing pass so stale badges
// catch up without stacking SSH/RPC work.
const pollRunner = createCoalescedPollRunner(pollStale)
const stopFocusedPoll = installFocusedVisibilityInterval({
run: () => pollRunner.run(),
intervalMs: POLL_INTERVAL_MS
})
pollRunner.run()
const intervalId = setInterval(() => {
if (document.hasFocus()) {
pollRunner.run()
}
}, POLL_INTERVAL_MS)
const onFocus = (): void => pollRunner.run()
window.addEventListener('focus', onFocus)
return () => {
pollRunner.dispose()
stopFocusedPoll()
clearInterval(intervalId)
window.removeEventListener('focus', onFocus)
}
}, [staleConflictWorktrees, setConflictOperation, isConnectionReady])
}
@@ -33,12 +33,7 @@ import type {
IssueInfo,
LinearIssue
} from '../../../../shared/types'
import {
branchDisplayName,
CONFLICT_OPERATION_LABELS,
FilledBellIcon,
shouldRefreshWorktreeCardDecoration
} from './WorktreeCardHelpers'
import { branchDisplayName, CONFLICT_OPERATION_LABELS, FilledBellIcon } from './WorktreeCardHelpers'
import {
WorktreeCardDetailsHover,
WorktreeCardMetaBadges,
@@ -50,11 +45,9 @@ import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state'
import { installFocusedVisibilityInterval } from '@/lib/focused-visibility-interval'
import { runWorktreeDelete } from './delete-worktree-flow'
import { runSleepWorktree } from './sleep-worktree-flow'
import { getWorkspaceQuickActionKind } from './worktree-card-quick-action'
import { useMacOptionKeyPressed } from './mac-option-key-state'
type WorktreeCardProps = {
worktree: Worktree
@@ -167,7 +160,26 @@ const WorktreeCard = React.memo(function WorktreeCard({
})
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
const isMacOptionPressed = useMacOptionKeyPressed()
const [isMacOptionPressed, setIsMacOptionPressed] = useState(false)
useEffect(() => {
const isMac = navigator.userAgent.includes('Mac')
if (!isMac) {
return
}
const handleKeyChange = (event: KeyboardEvent): void => {
setIsMacOptionPressed(event.altKey)
}
const handleWindowBlur = (): void => setIsMacOptionPressed(false)
window.addEventListener('keydown', handleKeyChange, true)
window.addEventListener('keyup', handleKeyChange, true)
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('keydown', handleKeyChange, true)
window.removeEventListener('keyup', handleKeyChange, true)
window.removeEventListener('blur', handleWindowBlur)
}
}, [])
// Why: on restart the previously-active worktree is auto-restored without a
// click, so the dialog never opens. Auto-show it for the active card when SSH
@@ -270,21 +282,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
if (isWebClient()) {
return
}
if (!repo || isFolder || worktree.isBare || !hostedReviewCacheKey || !showPR) {
return
}
const refreshHostedReviewIfVisible = (): void => {
if (
!shouldRefreshWorktreeCardDecoration({
documentVisible: document.visibilityState === 'visible',
windowFocused: document.hasFocus()
})
) {
return
}
if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && showPR) {
// Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR
// number from metadata or the visible cache whenever we have one.
void fetchHostedReviewForBranch(repo.path, branch, {
fetchHostedReviewForBranch(repo.path, branch, {
repoId: repo.id,
linkedGitHubPR: worktree.linkedPR ?? null,
fallbackGitHubPR: fallbackGitHubPRNumber,
@@ -292,13 +293,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
staleWhileRevalidate: true
})
}
refreshHostedReviewIfVisible()
window.addEventListener('focus', refreshHostedReviewIfVisible)
document.addEventListener('visibilitychange', refreshHostedReviewIfVisible)
return () => {
window.removeEventListener('focus', refreshHostedReviewIfVisible)
document.removeEventListener('visibilitychange', refreshHostedReviewIfVisible)
}
}, [
repo,
isFolder,
@@ -329,51 +323,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
return
}
const issueNumber = worktree.linkedIssue
const refreshIssueIfVisible = (): void => {
if (
!shouldRefreshWorktreeCardDecoration({
documentVisible: document.visibilityState === 'visible',
windowFocused: document.hasFocus()
})
) {
return
}
void fetchIssue(repo.path, issueNumber, { repoId: repo.id })
}
fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
// Background poll as fallback (activity triggers handle the fast path).
// The interval itself is stopped while hidden so issue cards do not keep
// long-lived workspaces waking just to skip their fetch.
return installFocusedVisibilityInterval({
run: refreshIssueIfVisible,
intervalMs: 5 * 60_000
})
// Background poll as fallback (activity triggers handle the fast path)
const interval = setInterval(() => {
fetchIssue(repo.path, worktree.linkedIssue!, { repoId: repo.id })
}, 5 * 60_000) // 5 minutes
return () => clearInterval(interval)
}, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
useEffect(() => {
if (!worktree.linkedLinearIssue || !showIssue) {
return
}
const linearIssueId = worktree.linkedLinearIssue
const refreshLinearIssueIfVisible = (): void => {
if (
!shouldRefreshWorktreeCardDecoration({
documentVisible: document.visibilityState === 'visible',
windowFocused: document.hasFocus()
})
) {
return
}
void fetchLinearIssue(linearIssueId)
}
refreshLinearIssueIfVisible()
window.addEventListener('focus', refreshLinearIssueIfVisible)
document.addEventListener('visibilitychange', refreshLinearIssueIfVisible)
return () => {
window.removeEventListener('focus', refreshLinearIssueIfVisible)
document.removeEventListener('visibilitychange', refreshLinearIssueIfVisible)
}
void fetchLinearIssue(worktree.linkedLinearIssue)
}, [worktree.linkedLinearIssue, fetchLinearIssue, showIssue])
// Stable click handler ignore clicks that are really text selections.
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { shouldRefreshWorktreeCardDecoration } from './WorktreeCardHelpers'
describe('shouldRefreshWorktreeCardDecoration', () => {
it('skips card decoration refreshes only while the app is hidden', () => {
expect(
shouldRefreshWorktreeCardDecoration({ documentVisible: false, windowFocused: true })
).toBe(false)
expect(
shouldRefreshWorktreeCardDecoration({ documentVisible: true, windowFocused: false })
).toBe(true)
expect(
shouldRefreshWorktreeCardDecoration({ documentVisible: true, windowFocused: true })
).toBe(true)
})
})
@@ -30,14 +30,6 @@ export function checksLabel(status: CheckStatus): string {
}
}
export function shouldRefreshWorktreeCardDecoration(args: {
documentVisible: boolean
windowFocused: boolean
}): boolean {
void args.windowFocused
return args.documentVisible
}
export const CONFLICT_OPERATION_LABELS: Record<Exclude<GitConflictOperation, 'unknown'>, string> = {
merge: 'Merging',
rebase: 'Rebasing',
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
getSleepableWorkspaceIds,
hasSleepableWorkspaceActivity,
shouldUseNativeContextMenu,
shouldIgnoreNestedWorktreeContextMenuScope,
@@ -114,25 +113,3 @@ describe('hasSleepableWorkspaceActivity', () => {
)
})
})
describe('getSleepableWorkspaceIds', () => {
it('selects only requested worktrees with live activity', () => {
expect(
getSleepableWorkspaceIds(
['wt-1', 'wt-2'],
{
'wt-1': [{ id: 'tab-1' }],
'wt-3': [{ id: 'tab-3' }]
},
{
'tab-1': ['pty-1'],
'tab-3': ['pty-3']
},
{
'wt-2': [],
'wt-4': [{ id: 'browser-4' }]
}
)
).toEqual(['wt-1'])
})
})
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: this menu keeps row targeting, batch actions, and ctrl-click event guards together so nested worktree menus share one event policy. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
DropdownMenu,
DropdownMenuContent,
@@ -102,17 +101,6 @@ function hasSleepableWorkspaceActivity(
return hasLiveTerminal || hasBrowser
}
function getSleepableWorkspaceIds(
worktreeIds: readonly string[],
tabsByWorktree: Record<string, { id: string }[]>,
ptyIdsByTabId: Record<string, string[]>,
browserTabsByWorktree: Record<string, { id: string }[]>
): string[] {
return worktreeIds.filter((worktreeId) =>
hasSleepableWorkspaceActivity(worktreeId, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
)
}
function findSidebarVirtualRowByKey(sidebar: Element, rowKey: string): HTMLElement | null {
return (
Array.from(sidebar.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')).find(
@@ -191,38 +179,26 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const [contextWorktrees, setContextWorktrees] = useState<readonly Worktree[]>(selectedWorktrees)
const activeContextWorktrees = menuOpen ? contextWorktrees : selectedWorktrees
const activeContextWorktreeIds = useMemo(
() => activeContextWorktrees.map((item) => item.id),
[activeContextWorktrees]
)
const isDeleting = deleteState?.isDeleting ?? false
const isFolder = repo ? isFolderRepo(repo) : false
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
const sleepableWorktreeIds = useAppStore(
useShallow((s) =>
getSleepableWorkspaceIds(
activeContextWorktreeIds,
s.tabsByWorktree,
s.ptyIdsByTabId,
s.browserTabsByWorktree
)
)
)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
const scopeRef = useRef<HTMLDivElement>(null)
const contextMenuOpenedAtRef = useRef<number | null>(null)
const activeContextWorktrees = menuOpen ? contextWorktrees : selectedWorktrees
const isMultiContext = activeContextWorktrees.length > 1
const sleepableWorktreeIdSet = useMemo(
() => new Set(sleepableWorktreeIds),
[sleepableWorktreeIds]
)
const sleepableWorktrees = useMemo(
() => activeContextWorktrees.filter((item) => sleepableWorktreeIdSet.has(item.id)),
[activeContextWorktrees, sleepableWorktreeIdSet]
() =>
activeContextWorktrees.filter((item) =>
hasSleepableWorkspaceActivity(item.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
),
[activeContextWorktrees, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree]
)
const deletingContext = useMemo(
() => activeContextWorktrees.some((item) => deleteStateByWorktreeId[item.id]?.isDeleting),
@@ -604,7 +580,6 @@ export {
CLOSE_ALL_CONTEXT_MENUS_EVENT,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR,
WORKTREE_NATIVE_CONTEXT_MENU_ATTR,
getSleepableWorkspaceIds,
hasSleepableWorkspaceActivity,
shouldUseNativeContextMenu,
shouldSuppressContextMenuFollowUpClick,
@@ -76,10 +76,6 @@ import {
setVisibleWorktreeIds,
sidebarHasActiveFilters
} from './visible-worktrees'
import {
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs
} from './visible-worktree-activity-inputs'
import {
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT,
useVirtualizedScrollAnchor,
@@ -127,18 +123,6 @@ const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = {
overflowAnchor: 'none'
}
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
export function countRecordKeysByReference(record: Record<string, unknown>): number {
const cached = recordKeyCountCache.get(record)
if (cached !== undefined) {
return cached
}
const count = Object.keys(record).length
recordKeyCountCache.set(record, count)
return count
}
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
isScrolling: boolean
now: number
@@ -485,7 +469,7 @@ function getVirtualRowKey(element: Element): string | null {
return element.getAttribute('data-worktree-virtual-row-key')
}
export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({
const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({
rows,
activeWorktreeId,
groupBy,
@@ -528,7 +512,6 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
WORKTREE_ROW_DRAG_INITIAL_STATE
)
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
const worktreeDragSessionRef = useRef<WorktreeDragSession | null>(null)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const suppressWorktreeClickUntilRef = useRef(0)
@@ -542,14 +525,6 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
const settings = useAppStore((s) => s.settings)
useEffect(
() =>
installWorktreeVisibleRefreshVisibilityListener(() => {
setDocumentVisibilityRevision((revision) => revision + 1)
}),
[]
)
// Drag is only meaningful when repo headers are using manual order. The
// controller is still constructed for hook order stability when inert.
const repoDrag = useRepoHeaderDrag({
@@ -910,8 +885,8 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
settings
])
const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache))
const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache))
const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length)
const issueCacheLen = useAppStore((s) => Object.keys(s.issueCache).length)
const renderRowKeySignature = useMemo(
() => renderRows.map(getRenderRowKey).join('\n'),
[renderRows]
@@ -1536,7 +1511,6 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
reportVisibleGitHubPRRefreshCandidates(visibleWorktreeIds, Date.now())
}, [
cardProps,
documentVisibilityRevision,
groupBy,
renderRows,
reportVisibleGitHubPRRefreshCandidates,
@@ -2280,11 +2254,6 @@ type WorktreeListProps = {
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
}
export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange)
return () => document.removeEventListener('visibilitychange', onChange)
}
const WorktreeList = React.memo(function WorktreeList({
scrollOffsetRef,
scrollAnchorRef
@@ -2313,12 +2282,10 @@ const WorktreeList = React.memo(function WorktreeList({
// Read tabsByWorktree when needed for filtering or sorting
const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart'
const tabsByWorktree = useAppStore((s) =>
needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null
)
const tabsByWorktree = useAppStore((s) => (needsActivityMaps ? s.tabsByWorktree : null))
const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null))
const browserTabsByWorktree = useAppStore((s) =>
!showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null
!showSleepingWorkspaces ? s.browserTabsByWorktree : null
)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
type StoredListener = (event: KeyboardEvent) => void
function createWindowStub(): {
addEventListener: ReturnType<typeof vi.fn>
removeEventListener: ReturnType<typeof vi.fn>
dispatch: (type: string, event: KeyboardEvent) => void
} {
const listeners = new Map<string, Set<StoredListener>>()
return {
addEventListener: vi.fn((type: string, listener: StoredListener) => {
const bucket = listeners.get(type) ?? new Set<StoredListener>()
bucket.add(listener)
listeners.set(type, bucket)
}),
removeEventListener: vi.fn((type: string, listener: StoredListener) => {
listeners.get(type)?.delete(listener)
}),
dispatch: (type, event) => {
for (const listener of listeners.get(type) ?? []) {
listener(event)
}
}
}
}
describe('mac option key state', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
})
it('shares one window listener set across subscribers and only notifies on value changes', async () => {
const windowStub = createWindowStub()
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('window', windowStub)
const { getMacOptionKeySnapshot, subscribeMacOptionKey } =
await import('./mac-option-key-state')
const first = vi.fn()
const second = vi.fn()
const unsubscribeFirst = subscribeMacOptionKey(first)
const unsubscribeSecond = subscribeMacOptionKey(second)
expect(windowStub.addEventListener).toHaveBeenCalledTimes(3)
windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent)
expect(getMacOptionKeySnapshot()).toBe(true)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(1)
windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(1)
unsubscribeFirst()
expect(windowStub.removeEventListener).not.toHaveBeenCalled()
windowStub.dispatch('keyup', { altKey: false } as KeyboardEvent)
expect(first).toHaveBeenCalledTimes(1)
expect(second).toHaveBeenCalledTimes(2)
unsubscribeSecond()
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(3)
expect(getMacOptionKeySnapshot()).toBe(false)
})
it('does not attach keyboard listeners on non-mac platforms', async () => {
const windowStub = createWindowStub()
vi.stubGlobal('navigator', { userAgent: 'Windows' })
vi.stubGlobal('window', windowStub)
const { getMacOptionKeySnapshot, subscribeMacOptionKey } =
await import('./mac-option-key-state')
const unsubscribe = subscribeMacOptionKey(vi.fn())
expect(windowStub.addEventListener).not.toHaveBeenCalled()
expect(getMacOptionKeySnapshot()).toBe(false)
unsubscribe()
})
})
@@ -1,65 +0,0 @@
import { useSyncExternalStore } from 'react'
type OptionKeyListener = () => void
let optionPressed = false
const listeners = new Set<OptionKeyListener>()
let disposeWindowListeners: (() => void) | null = null
function isMacPlatform(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
}
function setOptionPressed(nextPressed: boolean): void {
if (optionPressed === nextPressed) {
return
}
optionPressed = nextPressed
for (const listener of listeners) {
listener()
}
}
function startWindowListeners(): void {
if (disposeWindowListeners || !isMacPlatform() || typeof window === 'undefined') {
return
}
const handleKeyChange = (event: KeyboardEvent): void => setOptionPressed(event.altKey)
const handleWindowBlur = (): void => setOptionPressed(false)
window.addEventListener('keydown', handleKeyChange, true)
window.addEventListener('keyup', handleKeyChange, true)
window.addEventListener('blur', handleWindowBlur)
disposeWindowListeners = () => {
window.removeEventListener('keydown', handleKeyChange, true)
window.removeEventListener('keyup', handleKeyChange, true)
window.removeEventListener('blur', handleWindowBlur)
}
}
export function subscribeMacOptionKey(listener: OptionKeyListener): () => void {
if (!isMacPlatform()) {
return () => undefined
}
listeners.add(listener)
startWindowListeners()
return () => {
listeners.delete(listener)
if (listeners.size > 0) {
return
}
disposeWindowListeners?.()
disposeWindowListeners = null
setOptionPressed(false)
}
}
export function getMacOptionKeySnapshot(): boolean {
return isMacPlatform() ? optionPressed : false
}
export function useMacOptionKeyPressed(): boolean {
// Why: the sidebar can render dozens of cards. One shared external store
// avoids a global key listener per card and only re-renders on Option flips.
return useSyncExternalStore(subscribeMacOptionKey, getMacOptionKeySnapshot, () => false)
}
@@ -8,7 +8,6 @@ import type { TerminalTab } from '../../../../shared/types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import {
buildWorktreeAgentRows,
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree
} from './useWorktreeAgentRows'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -141,49 +140,6 @@ describe('selectMigrationUnsupportedEntriesForWorktree', () => {
// records preserves element identity for useShallow.
expect(first).toEqual([unsupported])
expect(second).toEqual([unsupported])
expect(first).toBe(second)
expect(first[0]).toBe(second[0])
})
})
describe('selectLiveAgentStatusEntriesForWorktree', () => {
it('reuses unaffected worktree arrays when another worktree receives a same-state ping', () => {
const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'first' })
const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'first' })
const state = {
tabsByWorktree: {
'wt-1': [makeTab('tab-1')],
'wt-2': [makeTab('tab-2')]
},
agentStatusByPaneKey: {
[PANE_KEY_1]: wt1Entry,
[PANE_KEY_2]: wt2Entry
},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {}
}
const firstWt1 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')
const firstWt2 = selectLiveAgentStatusEntriesForWorktree(state, 'wt-2')
const nextState = {
...state,
agentStatusByPaneKey: {
[PANE_KEY_1]: wt1Entry,
[PANE_KEY_2]: {
...wt2Entry,
prompt: 'updated prompt preview',
updatedAt: 1100
}
}
}
const secondWt1 = selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-1')
const secondWt2 = selectLiveAgentStatusEntriesForWorktree(nextState, 'wt-2')
// Why: WorktreeCard mounts one selector per visible card. A same-state
// hook ping for wt-2 must not make wt-1 pay a fresh array/render cost.
expect(secondWt1).toBe(firstWt1)
expect(secondWt2).not.toBe(firstWt2)
expect(secondWt2[0]?.prompt).toBe('updated prompt preview')
})
})
@@ -18,6 +18,7 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor
// reference when there's nothing for this worktree. Without stable empties,
// zustand's shallow equality would see a new `[]` every render and trigger
// unnecessary re-renders — defeating the purpose of the narrow selector.
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
@@ -30,188 +31,63 @@ type WorktreeAgentRowsState = Pick<
| 'tabsByWorktree'
>
type TabWorktreeIndexCache = {
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
tabIdToWorktreeId: Map<string, string>
}
type LiveEntriesByWorktreeCache = {
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
agentStatusByPaneKey: WorktreeAgentRowsState['agentStatusByPaneKey']
entriesByWorktree: Map<string, AgentStatusEntry[]>
}
type MigrationUnsupportedByWorktreeCache = {
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
migrationUnsupportedByPtyId: WorktreeAgentRowsState['migrationUnsupportedByPtyId']
entriesByWorktree: Map<string, MigrationUnsupportedPtyEntry[]>
}
type RetainedEntriesByWorktreeCache = {
retainedAgentsByPaneKey: WorktreeAgentRowsState['retainedAgentsByPaneKey']
entriesByWorktree: Map<string, RetainedAgentEntry[]>
}
let tabWorktreeIndexCache: TabWorktreeIndexCache | null = null
let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null
let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null
let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null
function reuseArrayIfEqual<T>(previous: T[] | undefined, next: T[]): T[] {
if (!previous || previous.length !== next.length) {
return next
export function selectLiveAgentStatusEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): AgentStatusEntry[] {
const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_LIVE_ENTRIES
}
for (let i = 0; i < next.length; i += 1) {
if (previous[i] !== next[i]) {
return next
}
}
return previous
}
function getTabIdToWorktreeId(
tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree']
): Map<string, string> {
if (tabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree) {
return tabWorktreeIndexCache.tabIdToWorktreeId
}
const tabIdToWorktreeId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
for (const tab of tabs) {
tabIdToWorktreeId.set(tab.id, worktreeId)
}
}
tabWorktreeIndexCache = { tabsByWorktree, tabIdToWorktreeId }
return tabIdToWorktreeId
}
function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, AgentStatusEntry[]> {
if (
liveEntriesByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
liveEntriesByWorktreeCache.agentStatusByPaneKey === state.agentStatusByPaneKey
) {
return liveEntriesByWorktreeCache.entriesByWorktree
}
const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
const previous = liveEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, AgentStatusEntry[]>()
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: AgentStatusEntry[] = []
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
}
const worktreeId = tabIdToWorktreeId.get(parsed.tabId)
if (!worktreeId) {
if (!tabIds.has(parsed.tabId)) {
continue
}
const bucket = entriesByWorktree.get(worktreeId)
if (bucket) {
bucket.push(entry)
} else {
entriesByWorktree.set(worktreeId, [entry])
}
out.push(entry)
}
for (const [worktreeId, entries] of entriesByWorktree) {
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
liveEntriesByWorktreeCache = {
tabsByWorktree: state.tabsByWorktree,
agentStatusByPaneKey: state.agentStatusByPaneKey,
entriesByWorktree
}
return entriesByWorktree
}
function getMigrationUnsupportedByWorktree(
state: WorktreeAgentRowsState
): Map<string, MigrationUnsupportedPtyEntry[]> {
if (
migrationUnsupportedByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId ===
state.migrationUnsupportedByPtyId
) {
return migrationUnsupportedByWorktreeCache.entriesByWorktree
}
const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
const previous = migrationUnsupportedByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, MigrationUnsupportedPtyEntry[]>()
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
if (!unsupported.paneKey) {
continue
}
const parsed = parsePaneKey(unsupported.paneKey)
const worktreeId = parsed ? tabIdToWorktreeId.get(parsed.tabId) : undefined
if (!worktreeId) {
continue
}
const bucket = entriesByWorktree.get(worktreeId)
if (bucket) {
bucket.push(unsupported)
} else {
entriesByWorktree.set(worktreeId, [unsupported])
}
}
for (const [worktreeId, entries] of entriesByWorktree) {
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
migrationUnsupportedByWorktreeCache = {
tabsByWorktree: state.tabsByWorktree,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
entriesByWorktree
}
return entriesByWorktree
}
function getRetainedEntriesByWorktree(
state: WorktreeAgentRowsState
): Map<string, RetainedAgentEntry[]> {
if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey) {
return retainedEntriesByWorktreeCache.entriesByWorktree
}
const previous = retainedEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, RetainedAgentEntry[]>()
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
const bucket = entriesByWorktree.get(retained.worktreeId)
if (bucket) {
bucket.push(retained)
} else {
entriesByWorktree.set(retained.worktreeId, [retained])
}
}
for (const [worktreeId, entries] of entriesByWorktree) {
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
retainedEntriesByWorktreeCache = {
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
entriesByWorktree
}
return entriesByWorktree
}
export function selectLiveAgentStatusEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): AgentStatusEntry[] {
return getLiveEntriesByWorktree(state).get(worktreeId) ?? EMPTY_LIVE_ENTRIES
return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
}
export function selectMigrationUnsupportedEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): MigrationUnsupportedPtyEntry[] {
return (
getMigrationUnsupportedByWorktree(state).get(worktreeId) ?? EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
)
const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: MigrationUnsupportedPtyEntry[] = []
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
if (!unsupported.paneKey) {
continue
}
const parsed = parsePaneKey(unsupported.paneKey)
if (!parsed || !tabIds.has(parsed.tabId)) {
continue
}
out.push(unsupported)
}
return out.length > 0 ? out : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
}
export function selectRetainedAgentEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): RetainedAgentEntry[] {
return getRetainedEntriesByWorktree(state).get(worktreeId) ?? EMPTY_RETAINED
const out: RetainedAgentEntry[] = []
for (const ra of Object.values(state.retainedAgentsByPaneKey)) {
if (ra.worktreeId === worktreeId) {
out.push(ra)
}
}
return out.length > 0 ? out : EMPTY_RETAINED
}
export function buildWorktreeAgentRows(args: {
@@ -279,10 +155,10 @@ export function buildWorktreeAgentRows(args: {
* list. Produces live hook-reported agents plus retained "done" snapshots,
* stale-decayed to 'idle' when the hook stream has gone quiet.
*
* Uses indexed per-worktree selectors rather than reusing useDashboardData's
* cross-worktree aggregate. The index is rebuilt once per relevant immutable
* store slice and then shared by every visible card, avoiding O(cards × agents)
* selector work on high-frequency agent status pings.
* Uses per-worktree selectors rather than reusing useDashboardData's
* cross-worktree aggregate that pipeline is O(repos × worktrees × agents)
* and would recompute once per sidebar card on every agent-status event.
* Scoped selectors keep the cost O(this-worktree-entries) per card.
*/
export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId])
@@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types'
import {
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs
} from './visible-worktree-activity-inputs'
function terminalTab(id: string, title: string): TerminalTab {
return {
id,
ptyId: id,
worktreeId: 'wt-1',
title,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function browserTab(id: string, title: string): BrowserWorkspace {
return {
id,
worktreeId: 'wt-1',
activePageId: `${id}-page`,
pageIds: [`${id}-page`],
url: 'https://example.com',
title,
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
}
describe('visible worktree activity inputs', () => {
it('preserves terminal activity projection when only tab metadata changes', () => {
const first = getVisibleWorktreeTerminalActivityTabs({
'wt-1': [terminalTab('tab-1', 'First')]
})
const second = getVisibleWorktreeTerminalActivityTabs({
'wt-1': [terminalTab('tab-1', 'Renamed')]
})
expect(second).toBe(first)
expect(second['wt-1']).toBe(first['wt-1'])
})
it('updates terminal activity projection when tab ids change', () => {
const first = getVisibleWorktreeTerminalActivityTabs({
'wt-1': [terminalTab('tab-1', 'First')]
})
const second = getVisibleWorktreeTerminalActivityTabs({
'wt-1': [terminalTab('tab-1', 'First'), terminalTab('tab-2', 'Second')]
})
expect(second).not.toBe(first)
expect(second['wt-1']?.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2'])
})
it('preserves browser activity projection when only browser metadata changes', () => {
const first = getVisibleWorktreeBrowserActivityTabs({
'wt-1': [browserTab('browser-1', 'First')]
})
const second = getVisibleWorktreeBrowserActivityTabs({
'wt-1': [browserTab('browser-1', 'Renamed')]
})
expect(second).toBe(first)
expect(second['wt-1']).toBe(first['wt-1'])
})
it('updates browser activity projection when browser ids change', () => {
const first = getVisibleWorktreeBrowserActivityTabs({
'wt-1': [browserTab('browser-1', 'First')]
})
const second = getVisibleWorktreeBrowserActivityTabs({
'wt-1': [browserTab('browser-2', 'Second')]
})
expect(second).not.toBe(first)
expect(second['wt-1']?.map((tab) => tab.id)).toEqual(['browser-2'])
})
})
@@ -1,77 +0,0 @@
import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types'
type TerminalActivityTab = Pick<TerminalTab, 'id'>
type BrowserActivityTab = Pick<BrowserWorkspace, 'id'>
function haveSameIds<T extends { id: string }>(
previous: readonly T[] | undefined,
next: readonly { id: string }[]
): boolean {
if (!previous || previous.length !== next.length) {
return false
}
for (let index = 0; index < next.length; index++) {
if (previous[index]?.id !== next[index]?.id) {
return false
}
}
return true
}
function projectIdTabs<T extends { id: string }, U extends { id: string }>(
tabsByWorktree: Record<string, readonly T[]>,
previousProjection: Record<string, U[]> | null
): { projection: Record<string, U[]>; unchanged: boolean } {
const nextProjection: Record<string, U[]> = {}
let unchanged =
previousProjection !== null &&
Object.keys(previousProjection).length === Object.keys(tabsByWorktree).length
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
const previousTabs = previousProjection?.[worktreeId]
if (haveSameIds(previousTabs, tabs)) {
nextProjection[worktreeId] = previousTabs as U[]
continue
}
unchanged = false
nextProjection[worktreeId] = tabs.map((tab) => ({ id: tab.id }) as U)
}
return { projection: nextProjection, unchanged }
}
let cachedTerminalSource: Record<string, TerminalTab[]> | null = null
let cachedTerminalProjection: Record<string, TerminalActivityTab[]> | null = null
export function getVisibleWorktreeTerminalActivityTabs(
tabsByWorktree: Record<string, TerminalTab[]>
): Record<string, TerminalActivityTab[]> {
if (cachedTerminalSource === tabsByWorktree && cachedTerminalProjection) {
return cachedTerminalProjection
}
const { projection, unchanged } = projectIdTabs(tabsByWorktree, cachedTerminalProjection)
cachedTerminalSource = tabsByWorktree
if (unchanged && cachedTerminalProjection) {
return cachedTerminalProjection
}
cachedTerminalProjection = projection
return projection
}
let cachedBrowserSource: Record<string, BrowserWorkspace[]> | null = null
let cachedBrowserProjection: Record<string, BrowserActivityTab[]> | null = null
export function getVisibleWorktreeBrowserActivityTabs(
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
): Record<string, BrowserActivityTab[]> {
if (cachedBrowserSource === browserTabsByWorktree && cachedBrowserProjection) {
return cachedBrowserProjection
}
const { projection, unchanged } = projectIdTabs(browserTabsByWorktree, cachedBrowserProjection)
cachedBrowserSource = browserTabsByWorktree
if (unchanged && cachedBrowserProjection) {
return cachedBrowserProjection
}
cachedBrowserProjection = projection
return projection
}
@@ -84,7 +84,7 @@ export function computeVisibleWorktreeIds(
opts: {
filterRepoIds: string[]
showSleepingWorkspaces: boolean
tabsByWorktree: Record<string, Pick<TerminalTab, 'id'>[]> | null
tabsByWorktree: Record<string, TerminalTab[]> | null
ptyIdsByTabId: Record<string, string[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | null
// Why required: every caller (WorktreeList, getVisibleWorktreeIds
@@ -52,80 +52,4 @@ describe('selectWorktreeAgentActivitySummary', () => {
})
expect(nowSpy).toHaveBeenCalledTimes(1)
})
it('reuses the cached summary when same-state agent pings only clone the status map', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000)
const paneKey = makePaneKey('tab-1', LEAF_ID)
const tabsByWorktree = {
'repo::/wt-1': [{ id: 'tab-1' }]
}
const migrationUnsupportedByPtyId = {}
const retainedAgentsByPaneKey = {}
const entry = makeAgentStatusEntry({ paneKey, state: 'working' })
const state = {
tabsByWorktree,
agentStatusEpoch: 0,
agentStatusByPaneKey: {
[paneKey]: entry
},
migrationUnsupportedByPtyId,
retainedAgentsByPaneKey
}
const sameStatePing = {
...state,
agentStatusByPaneKey: {
[paneKey]: {
...entry,
prompt: 'new prompt preview',
updatedAt: 1_500
}
}
}
expect(selectWorktreeAgentActivitySummary(state as never, 'repo::/wt-1')).toMatchObject({
hasLiveWorking: true
})
expect(selectWorktreeAgentActivitySummary(sameStatePing as never, 'repo::/wt-1')).toMatchObject(
{
hasLiveWorking: true
}
)
expect(nowSpy).toHaveBeenCalledTimes(1)
})
it('rebuilds the summary when the agent status epoch changes', () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000)
const paneKey = makePaneKey('tab-1', LEAF_ID)
const tabsByWorktree = {
'repo::/wt-1': [{ id: 'tab-1' }]
}
const migrationUnsupportedByPtyId = {}
const retainedAgentsByPaneKey = {}
const state = {
tabsByWorktree,
agentStatusEpoch: 0,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' })
},
migrationUnsupportedByPtyId,
retainedAgentsByPaneKey
}
const changedState = {
...state,
agentStatusEpoch: 1,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'done' })
}
}
expect(selectWorktreeAgentActivitySummary(state as never, 'repo::/wt-1')).toMatchObject({
hasLiveWorking: true,
hasLiveDone: false
})
expect(selectWorktreeAgentActivitySummary(changedState as never, 'repo::/wt-1')).toMatchObject({
hasLiveWorking: false,
hasLiveDone: true
})
expect(nowSpy).toHaveBeenCalledTimes(2)
})
})
@@ -33,6 +33,7 @@ type AgentActivityInput = Pick<
type AgentActivityCache = {
tabsByWorktree: AppState['tabsByWorktree']
agentStatusEpoch: number
agentStatusByPaneKey: AppState['agentStatusByPaneKey']
migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId']
retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey']
summaries: Map<string, WorktreeAgentActivitySummary>
@@ -54,6 +55,7 @@ function getWorktreeAgentActivitySummaries(
agentActivityCache &&
agentActivityCache.tabsByWorktree === state.tabsByWorktree &&
agentActivityCache.agentStatusEpoch === state.agentStatusEpoch &&
agentActivityCache.agentStatusByPaneKey === state.agentStatusByPaneKey &&
agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId &&
agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey
) {
@@ -104,6 +106,7 @@ function getWorktreeAgentActivitySummaries(
agentActivityCache = {
tabsByWorktree: state.tabsByWorktree,
agentStatusEpoch: state.agentStatusEpoch,
agentStatusByPaneKey: state.agentStatusByPaneKey,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
summaries
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
countRecordKeysByReference,
resolvePendingSidebarReveal,
shouldAdjustWorktreeSidebarMeasuredRowScroll
} from './WorktreeList'
@@ -16,21 +15,6 @@ const makeHeaderRow = (key: string) =>
}) as const
describe('shouldAdjustWorktreeSidebarMeasuredRowScroll', () => {
it('counts record keys once per object reference', () => {
const keysSpy = vi.spyOn(Object, 'keys')
const first = { a: 1, b: 2 }
const second = { ...first, c: 3 }
try {
expect(countRecordKeysByReference(first)).toBe(2)
expect(countRecordKeysByReference(first)).toBe(2)
expect(countRecordKeysByReference(second)).toBe(3)
expect(keysSpy).toHaveBeenCalledTimes(2)
} finally {
keysSpy.mockRestore()
}
})
it('suppresses measured-row scroll correction while TanStack is scrolling', () => {
expect(
shouldAdjustWorktreeSidebarMeasuredRowScroll({
@@ -1,43 +0,0 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installWorktreeVisibleRefreshVisibilityListener } from './WorktreeList'
describe('installWorktreeVisibleRefreshVisibilityListener', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('subscribes to document visibility changes so visible PR refresh can rerun on return', () => {
const listeners = new Map<string, () => void>()
const onChange = vi.fn()
const removeEventListener = vi.fn()
vi.stubGlobal('document', {
addEventListener: vi.fn((event: string, listener: () => void) => {
listeners.set(event, listener)
}),
removeEventListener
})
const cleanup = installWorktreeVisibleRefreshVisibilityListener(onChange)
listeners.get('visibilitychange')?.()
expect(onChange).toHaveBeenCalledTimes(1)
cleanup()
expect(removeEventListener).toHaveBeenCalledWith('visibilitychange', onChange)
})
it('keeps the visibility listener wired into the visible PR refresh effect', () => {
const source = readFileSync(
fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)),
'utf-8'
)
expect(source).toContain('installWorktreeVisibleRefreshVisibilityListener(() => {')
expect(source).toContain('setDocumentVisibilityRevision((revision) => revision + 1)')
expect(source).toMatch(/documentVisibilityRevision,\n\s+groupBy,/)
})
})
@@ -31,9 +31,8 @@ import {
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { installFocusedVisibilityInterval } from '@/lib/focused-visibility-interval'
import { useAppStore } from '../../store'
import { useWorktreeMap } from '../../store/selectors'
import { useAllWorktrees, useWorktreeMap } from '../../store/selectors'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { runSleepWorktree } from '../sidebar/sleep-worktree-flow'
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
@@ -56,12 +55,6 @@ import {
isResourceSessionActivationKey,
navigateResourceSessionToTab
} from './resource-session-navigation'
import {
getResourceUsageAllWorktrees,
getResourceUsageRepos,
getResourceUsageRuntimePaneTitlesByTabId,
getResourceUsageTabsByWorktree
} from './resource-usage-open-slices'
const POLL_MS = 2_000
const SESSIONS_POLL_MS = 10_000
@@ -666,12 +659,16 @@ export function ResourceUsageStatusSegment({
const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const setActiveView = useAppStore((s) => s.setActiveView)
const openModal = useAppStore((s) => s.openModal)
const openSpacePage = useAppStore((s) => s.openSpacePage)
const activeView = useAppStore((s) => s.activeView)
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const repos = useAppStore((s) => s.repos)
const allWorktrees = useAllWorktrees()
const activeRuntimeEnvironmentId = useAppStore(
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
)
@@ -687,19 +684,6 @@ export function ResourceUsageStatusSegment({
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanReady, setSpaceScanReady] = useState(false)
// Why: tab titles can update on terminal keystrokes. The resource popover's
// merged tree needs them only while open, so closed status-bar badges should
// not subscribe to those high-churn maps.
const runtimePaneTitlesByTabId = useAppStore((s) =>
getResourceUsageRuntimePaneTitlesByTabId(s, open, runtimeEnvironmentActive)
)
const repos = useAppStore((s) => getResourceUsageRepos(s, open, runtimeEnvironmentActive))
const allWorktrees = useAppStore((s) =>
getResourceUsageAllWorktrees(s, open, runtimeEnvironmentActive)
)
const tabsByWorktree = useAppStore((s) =>
getResourceUsageTabsByWorktree(s, open, runtimeEnvironmentActive)
)
const previousSpaceScanningRef = useRef(workspaceSpaceScanning)
const lastSeenSpaceScanAtRef = useRef<number | null>(workspaceSpaceScannedAt)
// Why: this segment only understands the local Electron PTY/resource daemon.
@@ -799,13 +783,23 @@ export function ResourceUsageStatusSegment({
setSessionsError(false)
return
}
const refreshIfVisible = (): void => {
if (document.visibilityState === 'visible' && document.hasFocus()) {
void refreshSessions()
}
}
void refreshSessions()
// Why: the closed-popover badge is informational. Polling daemon sessions
// while the whole window is hidden keeps IPC and daemon list calls hot for
// no visible UI; visibility refreshes catch the badge up immediately.
return installFocusedVisibilityInterval({
run: () => void refreshSessions(),
intervalMs: SESSIONS_POLL_MS
})
// no visible UI; focus/visibility refreshes catch the badge up immediately.
const interval = setInterval(refreshIfVisible, SESSIONS_POLL_MS)
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
return () => {
clearInterval(interval)
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
}
}, [runtimeEnvironmentActive, refreshSessions])
const repoDisplayNameById = useMemo(() => {
@@ -1063,6 +1063,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
const worktreeMap = useAppStore((state) => getWorktreeMapFromState(state))
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId)
const agentStatusByPaneKey = useAppStore((state) => state.agentStatusByPaneKey)
const migrationUnsupportedByPtyId = useAppStore((state) => state.migrationUnsupportedByPtyId)
const runtimePaneTitlesByTabId = useAppStore((state) => state.runtimePaneTitlesByTabId)
const agentStatusEpoch = useAppStore((state) => state.agentStatusEpoch)
@@ -1107,10 +1108,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
const decisionDetailsByWorktreeId = useMemo(() => {
// Why: active-agent freshness is time-based. The epoch bumps when fresh
// hook entries cross the stale boundary so delete readiness recomputes.
// Same-state prompt/tool pings clone the live status map but cannot change
// whether a workspace is safe to delete, so read the map only on epoch.
void agentStatusEpoch
const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey
const details = new Map<string, WorkspaceDecisionDetails>()
const now = Date.now()
for (const worktree of sourceRows) {
@@ -1142,6 +1140,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
}, [
activeWorktreeId,
agentStatusEpoch,
agentStatusByPaneKey,
browserTabsByWorktree,
editorDrafts,
gitStatusByWorktree,
@@ -1699,7 +1698,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
worktreeMap,
tabsByWorktree,
ptyIdsByTabId,
agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey,
agentStatusByPaneKey,
migrationUnsupportedByPtyId,
runtimePaneTitlesByTabId,
retainedAgentsByPaneKey,
@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getResourceUsageAllWorktrees,
getResourceUsageRepos,
getResourceUsageRuntimePaneTitlesByTabId,
getResourceUsageTabsByWorktree
} from './resource-usage-open-slices'
import type { AppState } from '../../store'
describe('resource usage open slices', () => {
it('returns stable empty slices while the popover is closed', () => {
const tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } as unknown as AppState['tabsByWorktree']
const runtimePaneTitlesByTabId = {
'tab-1': { 'tab-1:0': 'Working' }
} as AppState['runtimePaneTitlesByTabId']
const closedTabs = getResourceUsageTabsByWorktree({ tabsByWorktree }, false)
const closedTitles = getResourceUsageRuntimePaneTitlesByTabId(
{ runtimePaneTitlesByTabId },
false
)
expect(closedTabs).toBe(getResourceUsageTabsByWorktree({ tabsByWorktree: {} }, false))
expect(closedTitles).toBe(
getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId: {} }, false)
)
expect(closedTabs).toEqual({})
expect(closedTitles).toEqual({})
})
it('returns live slices while the popover is open', () => {
const tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } as unknown as AppState['tabsByWorktree']
const runtimePaneTitlesByTabId = {
'tab-1': { 'tab-1:0': 'Working' }
} as AppState['runtimePaneTitlesByTabId']
expect(getResourceUsageTabsByWorktree({ tabsByWorktree }, true)).toBe(tabsByWorktree)
expect(getResourceUsageRuntimePaneTitlesByTabId({ runtimePaneTitlesByTabId }, true)).toBe(
runtimePaneTitlesByTabId
)
})
it('gates repo and worktree slices while closed or runtime-backed', () => {
const repos = [{ id: 'repo-1' }] as AppState['repos']
const worktree = { id: 'wt-1', repoId: 'repo-1' }
const worktreesByRepo = {
'repo-1': [worktree]
} as unknown as AppState['worktreesByRepo']
expect(getResourceUsageRepos({ repos }, false, false)).toBe(
getResourceUsageRepos({ repos: [] }, false, false)
)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false, false)).toBe(
getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false, false)
)
expect(getResourceUsageRepos({ repos }, true, true)).toEqual([])
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, true)).toEqual([])
expect(getResourceUsageRepos({ repos }, true, false)).toBe(repos)
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, false)).toEqual([worktree])
})
})
@@ -1,49 +0,0 @@
import type { AppState } from '../../store'
import { getAllWorktreesFromState } from '../../store/selectors'
const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {}
const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {}
const EMPTY_REPOS: AppState['repos'] = []
const EMPTY_WORKTREES: ReturnType<typeof getAllWorktreesFromState> = []
function shouldReadPopoverSlices(open: boolean, runtimeEnvironmentActive: boolean): boolean {
return open && !runtimeEnvironmentActive
}
export function getResourceUsageTabsByWorktree(
state: Pick<AppState, 'tabsByWorktree'>,
open: boolean,
runtimeEnvironmentActive = false
): AppState['tabsByWorktree'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
? state.tabsByWorktree
: EMPTY_TABS_BY_WORKTREE
}
export function getResourceUsageRuntimePaneTitlesByTabId(
state: Pick<AppState, 'runtimePaneTitlesByTabId'>,
open: boolean,
runtimeEnvironmentActive = false
): AppState['runtimePaneTitlesByTabId'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
? state.runtimePaneTitlesByTabId
: EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID
}
export function getResourceUsageRepos(
state: Pick<AppState, 'repos'>,
open: boolean,
runtimeEnvironmentActive: boolean
): AppState['repos'] {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive) ? state.repos : EMPTY_REPOS
}
export function getResourceUsageAllWorktrees(
state: Pick<AppState, 'worktreesByRepo'>,
open: boolean,
runtimeEnvironmentActive: boolean
): ReturnType<typeof getAllWorktreesFromState> {
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
? getAllWorktreesFromState(state)
: EMPTY_WORKTREES
}
@@ -41,8 +41,6 @@ const isWindows = navigator.userAgent.includes('Windows')
const NEW_TERMINAL_SHORTCUT = isMac ? '⌘T' : 'Ctrl+T'
const NEW_BROWSER_SHORTCUT = isMac ? '⌘⇧B' : 'Ctrl+Shift+B'
const NEW_FILE_SHORTCUT = isMac ? '⌘⇧M' : 'Ctrl+Shift+M'
type GitStatusEntries = ReturnType<typeof useAppStore.getState>['gitStatusByWorktree'][string]
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = []
type TabBarProps = {
tabs: (TerminalTab & { unifiedTabId?: string })[]
@@ -146,9 +144,7 @@ function TabBarInner({
onCreateSplitGroup,
hoveredTabInsertion
}: TabBarProps): React.JSX.Element {
const gitStatusEntries = useAppStore(
(s) => s.gitStatusByWorktree[worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES
)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const defaultWindowsShell = useAppStore(
(s) => s.settings?.terminalWindowsShell ?? 'powershell.exe'
)
@@ -158,7 +154,10 @@ function TabBarInner({
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows)
const resolvedGroupId = groupId ?? worktreeId
const statusByRelativePath = useMemo(() => buildStatusMap(gitStatusEntries), [gitStatusEntries])
const statusByRelativePath = useMemo(
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
[worktreeId, gitStatusByWorktree]
)
// Why: Electron <webview> elements run in a separate process, so clicking
// inside one never dispatches a pointerdown on the renderer document.
@@ -1251,90 +1251,6 @@ describe('connectPanePty', () => {
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('does not enumerate every worktree tab for ordinary input without Codex restart notices', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-live')
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: new Proxy(
{
'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }],
'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }]
},
{
ownKeys() {
throw new Error('tabsByWorktree should not be enumerated')
}
}
),
codexRestartNoticeByPtyId: {}
}
const pane = createPane(1)
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
expect(onDataHandler).toBeDefined()
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
;(onDataHandler as (data: string) => void)('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport(null)
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: new Proxy(
{
'wt-1': [{ id: 'tab-1', ptyId: 'pty-live' }],
'wt-2': [{ id: 'tab-2', ptyId: 'pty-other' }]
},
{
ownKeys() {
throw new Error('tabsByWorktree should not be enumerated')
}
}
),
codexRestartNoticeByPtyId: {
'pty-other': { previousAccountLabel: 'A', nextAccountLabel: 'B' }
}
}
const pane = createPane(1)
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
expect(onDataHandler).toBeDefined()
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
;(onDataHandler as (data: string) => void)('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('blocks input to stale Codex panes until they restart', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -53,11 +53,6 @@ const PTY_CONNECT_DIAG_LIMIT = 200
const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1000
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
let codexRestartNoticePresenceSource: Record<
string,
{ previousAccountLabel: string; nextAccountLabel: string }
> | null = null
let codexRestartNoticePresence = false
function isAgentTaskCompleteNotificationEnabled(): boolean {
const notifications = useAppStore.getState().settings?.notifications
@@ -102,16 +97,6 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
}
function hasCodexRestartNotices(
noticesByPtyId: Record<string, { previousAccountLabel: string; nextAccountLabel: string }>
): boolean {
if (codexRestartNoticePresenceSource !== noticesByPtyId) {
codexRestartNoticePresenceSource = noticesByPtyId
codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0
}
return codexRestartNoticePresence
}
function sshPromptConnectOutcomeForStatus(
status: string | undefined,
sawNonDisconnected: boolean
@@ -160,21 +145,15 @@ async function waitForSshConnection(connectionId: string): Promise<SshConnectRes
return promise
}
function isCodexPaneStale(args: {
tabId: string
worktreeId: string
panePtyId: string | null
}): boolean {
function isCodexPaneStale(args: { tabId: string; panePtyId: string | null }): boolean {
const state = useAppStore.getState()
const { codexRestartNoticeByPtyId } = state
if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) {
return false
}
if (args.panePtyId && codexRestartNoticeByPtyId[args.panePtyId]) {
return true
}
const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId)
const tabs = Object.values(state.tabsByWorktree ?? {}).flat()
const tab = tabs.find((entry) => entry.id === args.tabId)
if (tab?.ptyId && codexRestartNoticeByPtyId[tab.ptyId]) {
return true
}
@@ -706,10 +685,8 @@ export function connectPanePty(
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
)
}
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe((state, previousState) => {
if (state.settings?.notifications !== previousState?.settings?.notifications) {
syncAgentTaskCompleteNotificationEnabled()
}
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe(() => {
syncAgentTaskCompleteNotificationEnabled()
})
// ─── Agent task-complete: OS notification, not tab attention ──────────
@@ -861,13 +838,7 @@ export function connectPanePty(
// still says the pane is stale. Fall back to the tab's persisted PTY ID so
// the block still holds during reconnect races before the live transport has
// updated its local PTY binding.
if (
isCodexPaneStale({
tabId: deps.tabId,
worktreeId: deps.worktreeId,
panePtyId: currentPtyId
})
) {
if (isCodexPaneStale({ tabId: deps.tabId, panePtyId: currentPtyId })) {
clearPendingTerminalInputIntent()
return
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { getActiveWorktreeOpenFiles } from './active-worktree-open-files'
const file = (id: string, worktreeId: string): OpenFile =>
({
id,
filePath: `/tmp/${id}.md`,
relativePath: `${id}.md`,
worktreeId,
language: 'markdown',
isDirty: false,
runtimeEnvironmentId: null
}) as OpenFile
describe('getActiveWorktreeOpenFiles', () => {
it('preserves the active slice when unrelated worktree files change', () => {
const active = file('active', 'wt-active')
const first = getActiveWorktreeOpenFiles([active, file('other-a', 'wt-other')], 'wt-active')
const second = getActiveWorktreeOpenFiles([active, file('other-b', 'wt-other')], 'wt-active')
expect(second).toBe(first)
expect(second).toEqual([active])
})
it('returns a stable empty slice without an active worktree', () => {
const first = getActiveWorktreeOpenFiles([file('active', 'wt-active')], null)
const second = getActiveWorktreeOpenFiles([file('other', 'wt-other')], null)
expect(second).toBe(first)
expect(second).toEqual([])
})
})
@@ -1,35 +0,0 @@
import type { OpenFile } from '@/store/slices/editor'
const EMPTY_OPEN_FILES: OpenFile[] = []
let cachedOpenFiles: OpenFile[] | null = null
let cachedWorktreeId: string | null = null
let cachedFiles: OpenFile[] = EMPTY_OPEN_FILES
export function getActiveWorktreeOpenFiles(
openFiles: OpenFile[],
activeWorktreeId: string | null
): OpenFile[] {
if (!activeWorktreeId) {
return EMPTY_OPEN_FILES
}
if (openFiles === cachedOpenFiles && activeWorktreeId === cachedWorktreeId) {
return cachedFiles
}
const nextFiles = openFiles.filter((file) => file.worktreeId === activeWorktreeId)
if (
cachedOpenFiles !== null &&
activeWorktreeId === cachedWorktreeId &&
nextFiles.length === cachedFiles.length &&
nextFiles.every((file, index) => file === cachedFiles[index])
) {
cachedOpenFiles = openFiles
return cachedFiles
}
cachedOpenFiles = openFiles
cachedWorktreeId = activeWorktreeId
cachedFiles = nextFiles.length > 0 ? nextFiles : EMPTY_OPEN_FILES
return cachedFiles
}
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BrowserTab } from '../../../../shared/types'
import { getTerminalBrowserTabSlices } from './terminal-browser-tab-slices'
const browserTab = (id: string): BrowserTab => ({
id,
worktreeId: 'wt-active',
url: `https://example.com/${id}`,
title: id,
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
})
describe('getTerminalBrowserTabSlices', () => {
it('preserves slices when an unmounted worktree browser tab array changes', () => {
const activeBrowserTabs = [browserTab('active')]
const mountedIds = new Set(['wt-active'])
const first = getTerminalBrowserTabSlices(
{ 'wt-active': activeBrowserTabs, 'wt-hidden': [browserTab('hidden-a')] },
mountedIds,
'wt-active'
)
const second = getTerminalBrowserTabSlices(
{ 'wt-active': activeBrowserTabs, 'wt-hidden': [browserTab('hidden-b')] },
mountedIds,
'wt-active'
)
expect(second).toBe(first)
expect(second.activeBrowserTabs).toBe(activeBrowserTabs)
})
it('updates slices when a mounted worktree browser tab array changes', () => {
const mountedIds = new Set(['wt-active', 'wt-mounted'])
const first = getTerminalBrowserTabSlices(
{ 'wt-active': [browserTab('active')], 'wt-mounted': [browserTab('mounted-a')] },
mountedIds,
'wt-active'
)
const mountedBrowserTabs = [browserTab('mounted-b')]
const second = getTerminalBrowserTabSlices(
{ 'wt-active': first.activeBrowserTabs, 'wt-mounted': mountedBrowserTabs },
mountedIds,
'wt-active'
)
expect(second).not.toBe(first)
expect(second.mountedBrowserTabsByWorktree['wt-mounted']).toBe(mountedBrowserTabs)
})
})
@@ -1,68 +0,0 @@
import type { BrowserTab } from '../../../../shared/types'
export type TerminalBrowserTabSlices = {
activeBrowserTabs: BrowserTab[]
mountedBrowserTabsByWorktree: Record<string, BrowserTab[]>
}
const EMPTY_BROWSER_TABS: BrowserTab[] = []
let cachedBrowserTabsByWorktree: Record<string, BrowserTab[]> | null = null
let cachedMountedIdsKey = ''
let cachedActiveWorktreeId: string | null = null
let cachedSlices: TerminalBrowserTabSlices = {
activeBrowserTabs: EMPTY_BROWSER_TABS,
mountedBrowserTabsByWorktree: {}
}
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
return [...mountedWorktreeIds].sort().join('\0')
}
function sameMountedBrowserTabs(
left: Record<string, BrowserTab[]>,
right: Record<string, BrowserTab[]>
): boolean {
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key])
}
export function getTerminalBrowserTabSlices(
browserTabsByWorktree: Record<string, BrowserTab[]>,
mountedWorktreeIds: ReadonlySet<string>,
activeWorktreeId: string | null
): TerminalBrowserTabSlices {
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
if (
browserTabsByWorktree === cachedBrowserTabsByWorktree &&
nextMountedIdsKey === cachedMountedIdsKey &&
activeWorktreeId === cachedActiveWorktreeId
) {
return cachedSlices
}
const activeBrowserTabs = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? EMPTY_BROWSER_TABS)
: EMPTY_BROWSER_TABS
const mountedBrowserTabsByWorktree: Record<string, BrowserTab[]> = {}
for (const worktreeId of mountedWorktreeIds) {
mountedBrowserTabsByWorktree[worktreeId] =
browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS
}
cachedBrowserTabsByWorktree = browserTabsByWorktree
cachedMountedIdsKey = nextMountedIdsKey
cachedActiveWorktreeId = activeWorktreeId
if (
activeBrowserTabs === cachedSlices.activeBrowserTabs &&
sameMountedBrowserTabs(mountedBrowserTabsByWorktree, cachedSlices.mountedBrowserTabsByWorktree)
) {
return cachedSlices
}
// Why: hidden BrowserPanes are retained only for mounted worktrees. Avoid
// rendering or resubscribing the terminal surface when browser tabs in
// unvisited worktrees restore or refresh in the background.
cachedSlices = { activeBrowserTabs, mountedBrowserTabsByWorktree }
return cachedSlices
}
@@ -1,89 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import { getTerminalMountedWorktreeSnapshot } from './terminal-mounted-worktrees'
const worktree = (input: Partial<Worktree> & Pick<Worktree, 'id' | 'path'>): Worktree => ({
id: input.id,
path: input.path,
repoId: input.repoId ?? 'repo-1',
displayName: input.displayName ?? input.id,
comment: input.comment ?? '',
branch: input.branch ?? 'main',
head: input.head ?? 'abc123',
isBare: input.isBare ?? false,
isMainWorktree: input.isMainWorktree ?? false,
linkedIssue: input.linkedIssue ?? null,
linkedPR: input.linkedPR ?? null,
linkedLinearIssue: input.linkedLinearIssue ?? null,
isArchived: input.isArchived ?? false,
isUnread: input.isUnread ?? false,
isPinned: input.isPinned ?? false,
sortOrder: input.sortOrder ?? 0,
lastActivityAt: input.lastActivityAt ?? 0
})
describe('getTerminalMountedWorktreeSnapshot', () => {
it('preserves the snapshot when unrelated worktree metadata changes', () => {
const mountedIds = new Set(['wt-active'])
const first = getTerminalMountedWorktreeSnapshot(
{
'repo-1': [
worktree({ id: 'wt-active', path: '/repo/active', linkedIssue: 1 }),
worktree({ id: 'wt-other', path: '/repo/other', displayName: 'Other' })
]
},
mountedIds
)
const second = getTerminalMountedWorktreeSnapshot(
{
'repo-1': [
worktree({ id: 'wt-active', path: '/repo/active', linkedIssue: 2 }),
worktree({ id: 'wt-other', path: '/repo/other', displayName: 'Renamed' })
]
},
mountedIds
)
expect(second).toBe(first)
expect(second.mountedWorktrees).toEqual([{ id: 'wt-active', path: '/repo/active' }])
})
it('returns a new snapshot when a mounted worktree path changes', () => {
const mountedIds = new Set(['wt-active'])
const first = getTerminalMountedWorktreeSnapshot(
{ 'repo-1': [worktree({ id: 'wt-active', path: '/repo/active' })] },
mountedIds
)
const second = getTerminalMountedWorktreeSnapshot(
{ 'repo-1': [worktree({ id: 'wt-active', path: '/repo/moved' })] },
mountedIds
)
expect(second).not.toBe(first)
expect(second.mountedWorktrees).toEqual([{ id: 'wt-active', path: '/repo/moved' }])
})
it('preserves the snapshot when an unmounted worktree path changes', () => {
const mountedIds = new Set(['wt-active'])
const first = getTerminalMountedWorktreeSnapshot(
{
'repo-1': [
worktree({ id: 'wt-active', path: '/repo/active' }),
worktree({ id: 'wt-hidden', path: '/repo/hidden-a' })
]
},
mountedIds
)
const second = getTerminalMountedWorktreeSnapshot(
{
'repo-1': [
worktree({ id: 'wt-active', path: '/repo/active' }),
worktree({ id: 'wt-hidden', path: '/repo/hidden-b' })
]
},
mountedIds
)
expect(second).toBe(first)
})
})
@@ -1,67 +0,0 @@
import type { Worktree } from '../../../../shared/types'
export type TerminalMountedWorktreeSnapshot = {
mountedWorktrees: Pick<Worktree, 'id' | 'path'>[]
worktreeIds: string[]
}
let cachedWorktreesByRepo: Record<string, Worktree[]> | null = null
let cachedMountedIdsKey = ''
let cachedSnapshot: TerminalMountedWorktreeSnapshot = {
mountedWorktrees: [],
worktreeIds: []
}
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
return [...mountedWorktreeIds].sort().join('\0')
}
function sameWorktreeProjection(
left: Pick<Worktree, 'id' | 'path'>[],
right: Pick<Worktree, 'id' | 'path'>[]
): boolean {
return (
left.length === right.length &&
left.every((worktree, index) => {
const other = right[index]
return worktree.id === other.id && worktree.path === other.path
})
)
}
export function getTerminalMountedWorktreeSnapshot(
worktreesByRepo: Record<string, Worktree[]>,
mountedWorktreeIds: ReadonlySet<string>
): TerminalMountedWorktreeSnapshot {
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
if (worktreesByRepo === cachedWorktreesByRepo && nextMountedIdsKey === cachedMountedIdsKey) {
return cachedSnapshot
}
const mountedWorktrees: Pick<Worktree, 'id' | 'path'>[] = []
const worktreeIds: string[] = []
for (const repoWorktrees of Object.values(worktreesByRepo)) {
for (const worktree of repoWorktrees) {
worktreeIds.push(worktree.id)
if (mountedWorktreeIds.has(worktree.id)) {
mountedWorktrees.push({ id: worktree.id, path: worktree.path })
}
}
}
cachedWorktreesByRepo = worktreesByRepo
cachedMountedIdsKey = nextMountedIdsKey
if (
worktreeIds.length === cachedSnapshot.worktreeIds.length &&
worktreeIds.every((id, index) => id === cachedSnapshot.worktreeIds[index]) &&
sameWorktreeProjection(mountedWorktrees, cachedSnapshot.mountedWorktrees)
) {
return cachedSnapshot
}
// Why: Terminal only needs all IDs for pruning plus id/path for mounted pane
// trees. Preserve the snapshot when unrelated or unmounted worktree metadata
// changes so sidebar/status refreshes don't rerender xterm during typing.
cachedSnapshot = { mountedWorktrees, worktreeIds }
return cachedSnapshot
}
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
import { getTerminalTabSlices } from './terminal-tab-slices'
const tab = (id: string): TerminalTab => ({
id,
title: id,
ptyId: null,
worktreeId: 'wt-active',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0,
generation: 0
})
describe('getTerminalTabSlices', () => {
it('preserves slices when an unmounted worktree tab array changes', () => {
const activeTabs = [tab('active')]
const mountedIds = new Set(['wt-active'])
const first = getTerminalTabSlices(
{ 'wt-active': activeTabs, 'wt-hidden': [tab('hidden-a')] },
mountedIds,
'wt-active'
)
const second = getTerminalTabSlices(
{ 'wt-active': activeTabs, 'wt-hidden': [tab('hidden-b')] },
mountedIds,
'wt-active'
)
expect(second).toBe(first)
expect(second.activeTabs).toBe(activeTabs)
})
it('updates slices when a mounted worktree tab array changes', () => {
const mountedIds = new Set(['wt-active', 'wt-mounted'])
const first = getTerminalTabSlices(
{ 'wt-active': [tab('active')], 'wt-mounted': [tab('mounted-a')] },
mountedIds,
'wt-active'
)
const mountedTabs = [tab('mounted-b')]
const second = getTerminalTabSlices(
{ 'wt-active': first.activeTabs, 'wt-mounted': mountedTabs },
mountedIds,
'wt-active'
)
expect(second).not.toBe(first)
expect(second.mountedTabsByWorktree['wt-mounted']).toBe(mountedTabs)
})
})
@@ -1,67 +0,0 @@
import type { TerminalTab } from '../../../../shared/types'
export type TerminalTabSlices = {
activeTabs: TerminalTab[]
mountedTabsByWorktree: Record<string, TerminalTab[]>
}
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
let cachedTabsByWorktree: Record<string, TerminalTab[]> | null = null
let cachedMountedIdsKey = ''
let cachedActiveWorktreeId: string | null = null
let cachedSlices: TerminalTabSlices = {
activeTabs: EMPTY_TERMINAL_TABS,
mountedTabsByWorktree: {}
}
function mountedIdsKey(mountedWorktreeIds: ReadonlySet<string>): string {
return [...mountedWorktreeIds].sort().join('\0')
}
function sameMountedTabs(
left: Record<string, TerminalTab[]>,
right: Record<string, TerminalTab[]>
): boolean {
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key])
}
export function getTerminalTabSlices(
tabsByWorktree: Record<string, TerminalTab[]>,
mountedWorktreeIds: ReadonlySet<string>,
activeWorktreeId: string | null
): TerminalTabSlices {
const nextMountedIdsKey = mountedIdsKey(mountedWorktreeIds)
if (
tabsByWorktree === cachedTabsByWorktree &&
nextMountedIdsKey === cachedMountedIdsKey &&
activeWorktreeId === cachedActiveWorktreeId
) {
return cachedSlices
}
const activeTabs = activeWorktreeId
? (tabsByWorktree[activeWorktreeId] ?? EMPTY_TERMINAL_TABS)
: EMPTY_TERMINAL_TABS
const mountedTabsByWorktree: Record<string, TerminalTab[]> = {}
for (const worktreeId of mountedWorktreeIds) {
mountedTabsByWorktree[worktreeId] = tabsByWorktree[worktreeId] ?? EMPTY_TERMINAL_TABS
}
cachedTabsByWorktree = tabsByWorktree
cachedMountedIdsKey = nextMountedIdsKey
cachedActiveWorktreeId = activeWorktreeId
if (
activeTabs === cachedSlices.activeTabs &&
sameMountedTabs(mountedTabsByWorktree, cachedSlices.mountedTabsByWorktree)
) {
return cachedSlices
}
// Why: Terminal renders only the active titlebar and mounted pane trees.
// Ignore tab-array churn for unmounted worktrees so background metadata
// updates do not rerender xterm while the user is typing.
cachedSlices = { activeTabs, mountedTabsByWorktree }
return cachedSlices
}
@@ -24,7 +24,7 @@ function isAgentTaskCompleteNotificationEnabled(): boolean {
export function syncAgentHookCompletionNotificationSettings(): boolean {
const enabled = isAgentTaskCompleteNotificationEnabled()
if (enabled !== wasAgentTaskCompleteNotificationEnabled) {
if (!enabled || (!wasAgentTaskCompleteNotificationEnabled && enabled)) {
requireFreshWorkingForNewCoordinators = true
for (const [paneKey, entry] of coordinatorsByPaneKey) {
paneKeysRequiringFreshWorking.add(paneKey)
@@ -6,9 +6,9 @@ import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-sess
import { submitPromptToAgentTab } from '@/lib/agent-paste-draft'
import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
import { getAutomationAgentCompletionObservation } from '@/lib/automation-agent-completion'
import { useAppStore } from '@/store'
import type { AutomationDispatchResult } from '../../../shared/automations-types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
createAutomationRunOutputSnapshotBuffer,
selectAutomationRunOutputSnapshot
@@ -195,35 +195,35 @@ export function useAutomationDispatchEvents(): void {
void markCompletionResult()
}
const observeAgentStatus = (
paneKey: string,
tabId: string,
startedAfter: number,
options?: { requireWorkingAfterStart?: boolean }
): void => {
let sawWorkingAfterStart = false
const checkCurrentStatus = (): void => {
const entry = useAppStore.getState().agentStatusByPaneKey[paneKey]
const observation = getAutomationAgentCompletionObservation({
entry,
startedAfter,
sawWorkingAfterStart,
requireWorkingAfterStart: options?.requireWorkingAfterStart
})
sawWorkingAfterStart = observation.sawWorkingAfterStart
latestAssistantMessage = observation.latestAssistantMessage || latestAssistantMessage
if (observation.done) {
handleAgentDone()
const { agentStatusByPaneKey } = useAppStore.getState()
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (parsed?.tabId !== tabId || entry.updatedAt < startedAfter) {
continue
}
if (entry.state === 'working') {
sawWorkingAfterStart = true
}
if (
entry.state === 'done' &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage =
entry.lastAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
return
}
}
}
// Why: Codex/Claude completion normally arrives through the global
// hook IPC listener, not the hidden PTY OSC fallback.
unsubscribeAgentStatus = useAppStore.subscribe((state, previousState) => {
if (
state.agentStatusByPaneKey[paneKey] === previousState.agentStatusByPaneKey[paneKey]
) {
return
}
checkCurrentStatus()
})
unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus)
checkCurrentStatus()
}
const dispatchStartedAt = Date.now()
@@ -282,7 +282,7 @@ export function useAutomationDispatchEvents(): void {
void markExitResult(code)
}
})
observeAgentStatus(reusableSession.paneKey, reuseCompletionStartedAt, {
observeAgentStatus(reusableSession.tabId, reuseCompletionStartedAt, {
requireWorkingAfterStart: true
})
await markDispatchResult({
@@ -338,7 +338,7 @@ export function useAutomationDispatchEvents(): void {
if (!result) {
throw new Error('Unable to build an agent launch plan.')
}
observeAgentStatus(result.paneKey, dispatchStartedAt)
observeAgentStatus(result.tabId, dispatchStartedAt)
try {
await markDispatchResult({
runId: run.id,
@@ -1,49 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { getEditorExternalWatchTargets } from './useEditorExternalWatch'
vi.mock('@/store', () => ({
useAppStore: {
getState: vi.fn()
}
}))
vi.mock('@/components/editor/editor-autosave', () => ({
notifyEditorExternalFileChange: vi.fn(),
getOpenFilesForExternalFileChange: vi.fn(() => [])
}))
describe('getEditorExternalWatchTargets', () => {
const repo = { id: 'repo-1', path: '/repo', kind: 'git', connectionId: null }
const worktree = { id: 'wt-1', repoId: 'repo-1', path: '/repo' }
const makeState = (isDirty: boolean) =>
({
openFiles: [
{
id: 'file-1',
worktreeId: 'wt-1',
filePath: '/repo/notes.md',
relativePath: 'notes.md',
isDirty
}
],
worktreesByRepo: { 'repo-1': [worktree] },
repos: [repo],
activeWorktreeId: null,
settings: null
}) as never
it('preserves the snapshot when open-file metadata changes without changing watched roots', () => {
const first = getEditorExternalWatchTargets(makeState(false))
const second = getEditorExternalWatchTargets(makeState(true))
expect(second).toBe(first)
expect(second.targets).toEqual([
{
worktreeId: 'wt-1',
worktreePath: '/repo',
connectionId: undefined,
runtimeEnvironmentId: undefined
}
])
})
})
@@ -2,8 +2,8 @@
target diffing, fs:changed dispatch, tombstone coalescing, and rename
correlation so the end-to-end event-to-store mutation contract stays
readable in one file. */
import { useEffect, useRef } from 'react'
import { useAppStore, type AppState } from '@/store'
import { useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import { basename, joinPath } from '@/lib/path'
import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
@@ -71,23 +71,6 @@ type ExternalWatchNotification = {
relativePath: string
}
type WatchedTargetsSnapshot = {
targets: WatchedTarget[]
targetsKey: string
}
type EditorExternalWatchTargetState = Pick<
AppState,
'openFiles' | 'worktreesByRepo' | 'repos' | 'activeWorktreeId' | 'settings'
>
let cachedOpenFiles: AppState['openFiles'] | null = null
let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null
let cachedRepos: AppState['repos'] | null = null
let cachedActiveWorktreeId: string | null = null
let cachedRuntimeEnvironmentId: string | undefined
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' }
export function getWatchedTargetKey(target: WatchedTarget): string {
// Why: SSH worktrees can exist in the store before their remote filesystem
// provider is ready. Include connectionId so a local/unknown placeholder
@@ -95,64 +78,6 @@ export function getWatchedTargetKey(target: WatchedTarget): string {
return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}`
}
export function getEditorExternalWatchTargets(
state: EditorExternalWatchTargetState
): WatchedTargetsSnapshot {
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined
if (
cachedOpenFiles === state.openFiles &&
cachedWorktreesByRepo === state.worktreesByRepo &&
cachedRepos === state.repos &&
cachedActiveWorktreeId === state.activeWorktreeId &&
cachedRuntimeEnvironmentId === runtimeEnvironmentId
) {
return cachedWatchedTargetsSnapshot
}
const ids = new Set<string>()
// Why: only the set of worktree IDs matters for watcher ownership. Dirty
// flags and editor metadata can churn while typing/saving, but should not
// re-render App or rebuild watch subscriptions.
for (const f of state.openFiles) {
ids.add(f.worktreeId)
}
if (state.activeWorktreeId) {
ids.add(state.activeWorktreeId)
}
const nextTargets: WatchedTarget[] = []
const parts: string[] = []
for (const id of Array.from(ids).sort()) {
const wt = findWorktreeById(state.worktreesByRepo, id)
if (!wt) {
continue
}
const repo = state.repos.find((r) => r.id === wt.repoId)
const target = {
worktreeId: id,
worktreePath: wt.path,
connectionId: repo?.connectionId ?? undefined,
runtimeEnvironmentId
}
nextTargets.push(target)
parts.push(getWatchedTargetKey(target))
}
const targetsKey = parts.join('|')
cachedOpenFiles = state.openFiles
cachedWorktreesByRepo = state.worktreesByRepo
cachedRepos = state.repos
cachedActiveWorktreeId = state.activeWorktreeId
cachedRuntimeEnvironmentId = runtimeEnvironmentId
if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) {
return cachedWatchedTargetsSnapshot
}
cachedWatchedTargetsSnapshot = { targets: nextTargets, targetsKey }
return cachedWatchedTargetsSnapshot
}
// Why: macOS atomic writes (Claude Code Edit, vim :w, VSCode save) deliver a
// delete event immediately followed by a create event for the same path. When
// those two land in separate fs:changed payloads a few ms apart, the tab
@@ -183,7 +108,47 @@ type PendingDeleteTimer = {
* regardless of which UI panel is visible.
*/
export function useEditorExternalWatch(): void {
const { targets, targetsKey } = useAppStore(getEditorExternalWatchTargets)
const openFiles = useAppStore((s) => s.openFiles)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const runtimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
// Why: unify the target computation and the dependency key into one memo so
// there's a single source of truth. The derived string key drives the
// watch-diff effect; the array itself is what the effect actually iterates.
const { targets, targetsKey } = useMemo(() => {
const ids = new Set<string>()
// Why: watch every worktree that has an editor tab open, so terminal edits
// in any of those roots reach the editor. Also watch the active worktree
// even when it has no open files — otherwise the File Explorer's tree
// reconciliation loses its event stream the moment the last tab for that
// worktree is closed.
for (const f of openFiles) {
ids.add(f.worktreeId)
}
if (activeWorktreeId) {
ids.add(activeWorktreeId)
}
const nextTargets: WatchedTarget[] = []
const parts: string[] = []
for (const id of Array.from(ids).sort()) {
const wt = findWorktreeById(worktreesByRepo, id)
if (!wt) {
continue
}
const repo = repos.find((r) => r.id === wt.repoId)
const target = {
worktreeId: id,
worktreePath: wt.path,
connectionId: repo?.connectionId ?? undefined,
runtimeEnvironmentId: runtimeEnvironmentId?.trim() || undefined
}
nextTargets.push(target)
parts.push(getWatchedTargetKey(target))
}
return { targets: nextTargets, targetsKey: parts.join('|') }
}, [openFiles, worktreesByRepo, repos, activeWorktreeId, runtimeEnvironmentId])
const targetsRef = useRef<WatchedTarget[]>([])
const latestTargetsRef = useRef<WatchedTarget[]>(targets)
+1 -80
View File
@@ -1897,7 +1897,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
stateStartedAt: number
}
type StoreLike = Record<string, unknown>
type StoreSubscribeListener = (state: StoreLike, previousState?: StoreLike) => void
type StoreSubscribeListener = (state: StoreLike) => void
function buildStoreState(overrides: StoreLike): StoreLike {
// Why: copy the defensive set of getState() fields the hook touches during
@@ -2514,85 +2514,6 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('gates snapshot and notification-settings work to the store fields they consume', async () => {
const getSnapshot = vi.fn(() => Promise.resolve([]))
const syncAgentHookCompletionNotificationSettings = vi.fn(() => true)
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
const notifications = { enabled: true, agentTaskComplete: true }
let storeState: StoreLike = buildStoreState({
workspaceSessionReady: true,
settings: { terminalFontSize: 13, notifications }
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn((listener: StoreSubscribeListener) => {
subscribeListenerRef.current = listener
return () => {
subscribeListenerRef.current = null
}
}),
getState: () => storeState
}
}))
vi.doMock('./agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification: vi.fn(),
syncAgentHookCompletionNotificationSettings
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot,
onSet: () => () => {}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
expect(getSnapshot).toHaveBeenCalledTimes(1)
expect(syncAgentHookCompletionNotificationSettings).toHaveBeenCalledTimes(1)
if (typeof subscribeListenerRef.current !== 'function') {
throw new Error('Expected useAppStore.subscribe listener to be registered')
}
let previousState = storeState
storeState = { ...storeState, activeView: 'tasks' }
subscribeListenerRef.current(storeState, previousState)
expect(getSnapshot).toHaveBeenCalledTimes(1)
expect(syncAgentHookCompletionNotificationSettings).toHaveBeenCalledTimes(1)
previousState = storeState
storeState = {
...storeState,
settings: {
...(storeState.settings as Record<string, unknown>),
notifications: { enabled: true, agentTaskComplete: false }
}
}
subscribeListenerRef.current(storeState, previousState)
expect(getSnapshot).toHaveBeenCalledTimes(1)
expect(syncAgentHookCompletionNotificationSettings).toHaveBeenCalledTimes(2)
previousState = storeState
storeState = { ...storeState, workspaceSessionReady: false }
subscribeListenerRef.current(storeState, previousState)
expect(getSnapshot).toHaveBeenCalledTimes(1)
expect(syncAgentHookCompletionNotificationSettings).toHaveBeenCalledTimes(2)
previousState = storeState
storeState = { ...storeState, workspaceSessionReady: true }
subscribeListenerRef.current(storeState, previousState)
await Promise.resolve()
expect(getSnapshot).toHaveBeenCalledTimes(2)
expect(syncAgentHookCompletionNotificationSettings).toHaveBeenCalledTimes(2)
})
it('waits for the remote workspace client id before dropping self notifications', async () => {
const hydrateWorkspaceSession = vi.fn()
const hydrateTabsSession = vi.fn()
+4 -23
View File
@@ -2036,30 +2036,11 @@ export function useIpcEvents(): void {
// can be safely ignored instead of buffered against partially hydrated
// renderer state.
requestAgentStatusSnapshotIfReady()
syncAgentHookCompletionNotificationSettings()
unsubs.push(
useAppStore.subscribe((state, previousState) => {
if (!previousState || state.workspaceSessionReady !== previousState.workspaceSessionReady) {
requestAgentStatusSnapshotIfReady()
}
// Why: pending hook events only become resolvable when pane routing
// inputs change; avoid scanning the queue on high-rate terminal updates.
if (
!previousState ||
state.workspaceSessionReady !== previousState.workspaceSessionReady ||
state.terminalLayoutsByTabId !== previousState.terminalLayoutsByTabId ||
state.tabsByWorktree !== previousState.tabsByWorktree ||
state.worktreesByRepo !== previousState.worktreesByRepo ||
state.repos !== previousState.repos
) {
flushPendingAgentStatuses()
}
if (
!previousState ||
state.settings?.notifications !== previousState.settings?.notifications
) {
syncAgentHookCompletionNotificationSettings()
}
useAppStore.subscribe(() => {
requestAgentStatusSnapshotIfReady()
flushPendingAgentStatuses()
syncAgentHookCompletionNotificationSettings()
})
)
@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getAutomationAgentCompletionObservation } from './automation-agent-completion'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
function status(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 2,
stateStartedAt: 2,
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
stateHistory: [],
...overrides
}
}
describe('getAutomationAgentCompletionObservation', () => {
it('ignores stale entries from before the automation completion window', () => {
expect(
getAutomationAgentCompletionObservation({
entry: status({ state: 'done', updatedAt: 1 }),
startedAfter: 2,
sawWorkingAfterStart: false
})
).toEqual({
sawWorkingAfterStart: false,
latestAssistantMessage: null,
done: false
})
})
it('requires a fresh working state before completing reused sessions', () => {
const waiting = getAutomationAgentCompletionObservation({
entry: status({ state: 'done', updatedAt: 3 }),
startedAfter: 2,
sawWorkingAfterStart: false,
requireWorkingAfterStart: true
})
const working = getAutomationAgentCompletionObservation({
entry: status({ state: 'working', updatedAt: 4 }),
startedAfter: 2,
sawWorkingAfterStart: waiting.sawWorkingAfterStart,
requireWorkingAfterStart: true
})
const done = getAutomationAgentCompletionObservation({
entry: status({ state: 'done', updatedAt: 5, lastAssistantMessage: ' finished ' }),
startedAfter: 2,
sawWorkingAfterStart: working.sawWorkingAfterStart,
requireWorkingAfterStart: true
})
expect(waiting.done).toBe(false)
expect(working.sawWorkingAfterStart).toBe(true)
expect(done).toEqual({
sawWorkingAfterStart: true,
latestAssistantMessage: 'finished',
done: true
})
})
})
@@ -1,30 +0,0 @@
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
export type AutomationAgentCompletionObservation = {
sawWorkingAfterStart: boolean
latestAssistantMessage: string | null
done: boolean
}
export function getAutomationAgentCompletionObservation(args: {
entry: AgentStatusEntry | undefined
startedAfter: number
sawWorkingAfterStart: boolean
requireWorkingAfterStart?: boolean
}): AutomationAgentCompletionObservation {
const { entry, startedAfter, requireWorkingAfterStart } = args
if (!entry || entry.updatedAt < startedAfter) {
return {
sawWorkingAfterStart: args.sawWorkingAfterStart,
latestAssistantMessage: null,
done: false
}
}
const sawWorkingAfterStart = args.sawWorkingAfterStart || entry.state === 'working'
return {
sawWorkingAfterStart,
latestAssistantMessage: entry.lastAssistantMessage?.trim() || null,
done: entry.state === 'done' && (!requireWorkingAfterStart || sawWorkingAfterStart)
}
}
@@ -1,53 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installFocusedVisibilityInterval } from './focused-visibility-interval'
describe('installFocusedVisibilityInterval', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('runs intervals only while the document is visible', () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, () => void>()
const clearIntervalMock = vi.fn()
const setIntervalMock = vi.fn(() => 1 as unknown as ReturnType<typeof setInterval>)
const run = vi.fn()
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
get visibilityState() {
return visibilityState
},
hasFocus: () => false,
addEventListener: vi.fn((event: string, listener: () => void) => {
documentListeners.set(event, listener)
}),
removeEventListener: vi.fn()
})
const cleanup = installFocusedVisibilityInterval({
run,
intervalMs: 3000,
setIntervalFn: setIntervalMock,
clearIntervalFn: clearIntervalMock
})
expect(run).not.toHaveBeenCalled()
expect(setIntervalMock).not.toHaveBeenCalled()
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
expect(run).toHaveBeenCalledTimes(1)
expect(setIntervalMock).toHaveBeenCalledTimes(1)
visibilityState = 'hidden'
documentListeners.get('visibilitychange')?.()
expect(clearIntervalMock).toHaveBeenCalledWith(1)
cleanup()
})
})
@@ -1,57 +0,0 @@
export type FocusedVisibilityIntervalTimer = ReturnType<typeof setInterval>
export function isWindowVisible(): boolean {
return typeof document.visibilityState === 'undefined' || document.visibilityState === 'visible'
}
export function installFocusedVisibilityInterval(args: {
run: () => void
intervalMs: number
setIntervalFn?: (callback: () => void, intervalMs: number) => FocusedVisibilityIntervalTimer
clearIntervalFn?: (handle: FocusedVisibilityIntervalTimer) => void
}): () => void {
const setIntervalFn =
args.setIntervalFn ??
((callback: () => void, intervalMs: number): FocusedVisibilityIntervalTimer =>
setInterval(callback, intervalMs))
const clearIntervalFn =
args.clearIntervalFn ??
((handle: FocusedVisibilityIntervalTimer): void => clearInterval(handle))
let intervalId: FocusedVisibilityIntervalTimer | null = null
const stop = (): void => {
if (!intervalId) {
return
}
clearIntervalFn(intervalId)
intervalId = null
}
const start = (): void => {
if (intervalId || !isWindowVisible()) {
return
}
args.run()
// Why: many callers shell out or cross IPC. Keep their interval alive only
// while Orca can present the refreshed data, but still refresh a visible
// unfocused window so status UI does not go stale on a second display.
intervalId = setIntervalFn(args.run, args.intervalMs)
}
const reconcile = (): void => {
if (isWindowVisible()) {
start()
} else {
stop()
}
}
start()
if (typeof document.addEventListener === 'function') {
document.addEventListener('visibilitychange', reconcile)
}
return () => {
stop()
if (typeof document.removeEventListener === 'function') {
document.removeEventListener('visibilitychange', reconcile)
}
}
}
@@ -154,7 +154,7 @@ describe('launchAgentBackgroundSession', () => {
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1', paneKey })
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1' })
})
it('pre-marks trust for agents with first-launch trust prompts', async () => {
@@ -329,6 +329,6 @@ describe('launchAgentBackgroundSession', () => {
}),
expect.any(Object)
)
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'remote:env-1@@terminal-1', paneKey })
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'remote:env-1@@terminal-1' })
})
})
@@ -38,7 +38,6 @@ export type LaunchAgentBackgroundSessionArgs = {
export type LaunchAgentBackgroundSessionResult = {
tabId: string
ptyId: string
paneKey: string
startupPlan: AgentStartupPlan
}
@@ -253,5 +252,5 @@ export async function launchAgentBackgroundSession(
})
}
return { tabId: tab.id, ptyId, paneKey, startupPlan }
return { tabId: tab.id, ptyId, startupPlan }
}
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getExternalWorkspacePorts,
getWorkspacePortGroups,
getWorkspacePortsByWorktreeId
} from './workspace-port-groups'
describe('workspace port group caches', () => {
it('returns stable empty references when no scan result exists', () => {
expect(getWorkspacePortsByWorktreeId(null)).toBe(getWorkspacePortsByWorktreeId(undefined))
expect(getWorkspacePortGroups(null)).toBe(getWorkspacePortGroups(undefined))
expect(getExternalWorkspacePorts(null)).toBe(getExternalWorkspacePorts(undefined))
})
})
@@ -10,9 +10,6 @@ export type WorkspacePortGroup = {
const portsByWorktreeCache = new WeakMap<WorkspacePortScanResult, Map<string, WorkspacePort[]>>()
const workspaceGroupsCache = new WeakMap<WorkspacePortScanResult, WorkspacePortGroup[]>()
const externalPortsCache = new WeakMap<WorkspacePortScanResult, WorkspacePort[]>()
const EMPTY_PORTS_BY_WORKTREE = new Map<string, WorkspacePort[]>()
const EMPTY_WORKSPACE_PORT_GROUPS: WorkspacePortGroup[] = []
const EMPTY_EXTERNAL_PORTS: WorkspacePort[] = []
function comparePorts(a: WorkspacePort, b: WorkspacePort): number {
return a.port - b.port || (a.processName ?? '').localeCompare(b.processName ?? '')
@@ -21,9 +18,6 @@ function comparePorts(a: WorkspacePort, b: WorkspacePort): number {
export function getWorkspacePortsByWorktreeId(
scan: WorkspacePortScanResult | null | undefined
): Map<string, WorkspacePort[]> {
if (!scan) {
return EMPTY_PORTS_BY_WORKTREE
}
if (scan) {
const cached = portsByWorktreeCache.get(scan)
if (cached) {
@@ -54,9 +48,6 @@ export function getWorkspacePortsByWorktreeId(
export function getWorkspacePortGroups(
scan: WorkspacePortScanResult | null | undefined
): WorkspacePortGroup[] {
if (!scan) {
return EMPTY_WORKSPACE_PORT_GROUPS
}
if (scan) {
const cached = workspaceGroupsCache.get(scan)
if (cached) {
@@ -96,9 +87,6 @@ export function getWorkspacePortGroups(
export function getExternalWorkspacePorts(
scan: WorkspacePortScanResult | null | undefined
): WorkspacePort[] {
if (!scan) {
return EMPTY_EXTERNAL_PORTS
}
if (scan) {
const cached = externalPortsCache.get(scan)
if (cached) {
@@ -2,7 +2,6 @@
import { describe, expect, it } from 'vitest'
import {
buildMobileSessionTabSnapshots,
canSkipRuntimeMobileSessionSyncKeyBuild,
getRuntimeMobileSessionSyncKey,
runtimeMobileSessionSyncKeysEqual
} from './sync-runtime-graph'
@@ -24,8 +23,6 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
browserPagesByWorkspace: {},
openFiles: [],
editorDrafts: {},
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
activeTabId: null,
...overrides
} as AppState
@@ -52,15 +49,10 @@ function makeSharedOverrides(): Partial<AppState> {
activeFileIdByWorktree: {},
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
agentStatusByPaneKey: {},
agentStatusEpoch: 0
browserPagesByWorkspace: {}
}
}
type BrowserWorkspaceForTest = AppState['browserTabsByWorktree'][string][number]
type BrowserPageForTest = AppState['browserPagesByWorkspace'][string][number]
describe('getRuntimeMobileSessionSyncKey', () => {
it('changes when mobile markdown tab state changes', () => {
const base = makeState({
@@ -248,114 +240,21 @@ describe('getRuntimeMobileSessionSyncKey', () => {
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
})
it('changes when explicit agent status epoch changes', () => {
it('changes when explicit agent status changes', () => {
const sharedOverrides = makeSharedOverrides()
const before = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
agentStatusEpoch: 0
agentStatusByPaneKey: {}
})
)
const after = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
agentStatusEpoch: 1
})
)
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
})
it('changes when mobile browser workspace projection fields change', () => {
const sharedOverrides = makeSharedOverrides()
const workspace: BrowserWorkspaceForTest = {
id: 'browser-1',
worktreeId: 'wt-1',
sessionProfileId: null,
activePageId: 'page-1',
pageIds: ['page-1'],
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
const baseKey = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
browserTabsByWorktree: { 'wt-1': [workspace] }
})
)
for (const changedWorkspace of [
{ ...workspace, title: 'Changed' },
{ ...workspace, url: 'https://changed.example.com' },
{ ...workspace, loading: true },
{ ...workspace, canGoBack: true },
{ ...workspace, canGoForward: true }
]) {
const changedKey = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
browserTabsByWorktree: { 'wt-1': [changedWorkspace] }
})
)
expect(runtimeMobileSessionSyncKeysEqual(baseKey, changedKey)).toBe(false)
}
})
it('changes when mobile browser page projection fields change', () => {
const sharedOverrides = makeSharedOverrides()
const page: BrowserPageForTest = {
id: 'page-1',
workspaceId: 'browser-1',
worktreeId: 'wt-1',
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
const baseKey = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
browserPagesByWorkspace: { 'browser-1': [page] }
})
)
for (const changedPage of [
{ ...page, title: 'Changed' },
{ ...page, url: 'https://changed.example.com' },
{ ...page, loading: true },
{ ...page, canGoBack: true },
{ ...page, canGoForward: true }
]) {
const changedKey = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
browserPagesByWorkspace: { 'browser-1': [changedPage] }
})
)
expect(runtimeMobileSessionSyncKeysEqual(baseKey, changedKey)).toBe(false)
}
})
it('changes for same-state agent prompt/tool updates because mobile publishes details', () => {
const sharedOverrides = makeSharedOverrides()
const before = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
agentStatusEpoch: 1,
agentStatusByPaneKey: {
'term-1:11111111-1111-4111-8111-111111111111': {
state: 'working',
prompt: 'first prompt',
prompt: 'fix parity',
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'codex',
@@ -366,67 +265,9 @@ describe('getRuntimeMobileSessionSyncKey', () => {
}
})
)
const after = getRuntimeMobileSessionSyncKey(
makeState({
...sharedOverrides,
agentStatusEpoch: 1,
agentStatusByPaneKey: {
'term-1:11111111-1111-4111-8111-111111111111': {
state: 'working',
prompt: 'updated prompt preview',
toolName: 'Edit',
updatedAt: 1_700_000_001_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'codex',
paneKey: 'term-1:11111111-1111-4111-8111-111111111111',
terminalTitle: 'codex [working]',
stateHistory: []
}
}
})
)
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
})
it('does not skip the App subscriber gate for same-epoch agent detail updates', () => {
const sharedOverrides = makeSharedOverrides()
const before = makeState({
...sharedOverrides,
agentStatusEpoch: 1,
agentStatusByPaneKey: {
'term-1:11111111-1111-4111-8111-111111111111': {
state: 'working',
prompt: 'first prompt',
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'codex',
paneKey: 'term-1:11111111-1111-4111-8111-111111111111',
terminalTitle: 'codex [working]',
stateHistory: []
}
}
})
const after = makeState({
...sharedOverrides,
agentStatusEpoch: 1,
agentStatusByPaneKey: {
'term-1:11111111-1111-4111-8111-111111111111': {
state: 'working',
prompt: 'updated prompt preview',
toolName: 'Edit',
updatedAt: 1_700_000_001_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'codex',
paneKey: 'term-1:11111111-1111-4111-8111-111111111111',
terminalTitle: 'codex [working]',
stateHistory: []
}
}
})
expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false)
})
})
describe('buildMobileSessionTabSnapshots', () => {
+4 -33
View File
@@ -69,6 +69,7 @@ const NO_TRANSPORT_GRACE_MS = 10_000
const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {}
const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {}
const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {}
const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {}
let syncScheduled = false
let syncInFlight = false
@@ -178,7 +179,6 @@ export type RuntimeMobileSessionSyncKey = {
activeFileIdByWorktree: AppState['activeFileIdByWorktree']
activeTabId: AppState['activeTabId']
activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree']
agentStatusEpoch: number
agentStatusByPaneKey: AppState['agentStatusByPaneKey']
// Why: these projections still need value-level inspection because the
// underlying references churn even when the mobile-relevant shape is
@@ -190,32 +190,6 @@ export type RuntimeMobileSessionSyncKey = {
editorDraftsProjection: string
}
export function canSkipRuntimeMobileSessionSyncKeyBuild(
state: AppState,
previousState: AppState
): boolean {
return (
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.layoutByWorktree === previousState.layoutByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
state.browserTabsByWorktree === previousState.browserTabsByWorktree &&
state.browserPagesByWorkspace === previousState.browserPagesByWorkspace &&
state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree &&
state.openFiles === previousState.openFiles &&
state.editorDrafts === previousState.editorDrafts &&
state.activeTabId === previousState.activeTabId &&
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
state.agentStatusEpoch === previousState.agentStatusEpoch &&
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
)
}
export function getRuntimeMobileSessionSyncKey(
state: AppState,
previousState?: AppState,
@@ -244,11 +218,9 @@ export function getRuntimeMobileSessionSyncKey(
activeTabId: state.activeTabId,
activeBrowserTabIdByWorktree:
state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE,
// Why: paired web/mobile snapshots include full agentStatus details; the
// epoch keeps freshness timers cheap, while map identity preserves prompt
// and tool updates that do not change the visible state enum.
agentStatusEpoch: state.agentStatusEpoch ?? 0,
agentStatusByPaneKey: state.agentStatusByPaneKey,
// Why: explicit hook status is published with terminal surfaces so paired
// web can render the same per-worktree agent rows before a PTY is opened.
agentStatusByPaneKey: state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY,
// Why: background agent title ticks can change runtimePaneTitlesByTabId
// many times per second while the user types elsewhere. Reuse unchanged
// projections so those ticks do not rescan all tabs, files, and drafts.
@@ -395,7 +367,6 @@ export function runtimeMobileSessionSyncKeysEqual(
a.activeFileIdByWorktree === b.activeFileIdByWorktree &&
a.activeTabId === b.activeTabId &&
a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree &&
a.agentStatusEpoch === b.agentStatusEpoch &&
a.agentStatusByPaneKey === b.agentStatusByPaneKey &&
a.tabsProjection === b.tabsProjection &&
a.openFilesProjection === b.openFilesProjection &&
@@ -148,7 +148,7 @@ describe('agent status tool + assistant fields', () => {
expect(store.getState().agentStatusByPaneKey['tab-1:1'].agentType).toBe('cursor')
})
it('keeps global epochs stable for fresh same-state working pings while updating the entry', () => {
it('keeps global epochs stable for fresh same-state pings while updating the entry', () => {
vi.useFakeTimers()
const store = createTestStore()
store
@@ -177,8 +177,8 @@ describe('agent status tool + assistant fields', () => {
expect(sameStateEntry.updatedAt).toBe(2_000)
// Why: same-state hook pings are high-frequency and already update the
// owning row through agentStatusByPaneKey. The global epochs are reserved
// for state/freshness/final-done changes that can affect aggregate
// dashboard/sidebar calculations.
// for state/freshness changes that can affect aggregate dashboard/sidebar
// calculations.
expect(store.getState().agentStatusEpoch).toBe(firstEpoch)
expect(store.getState().sortEpoch).toBe(firstSortEpoch)
@@ -192,39 +192,6 @@ describe('agent status tool + assistant fields', () => {
expect(store.getState().sortEpoch).toBe(firstSortEpoch + 1)
})
it('bumps the status epoch, not sort epoch, for same-state done updates', () => {
vi.useFakeTimers()
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:1', { state: 'done', prompt: 'p1', agentType: 'claude' }, 'claude', {
updatedAt: 1_000,
stateStartedAt: 1_000
})
const firstEpoch = store.getState().agentStatusEpoch
const firstSortEpoch = store.getState().sortEpoch
store.getState().setAgentStatus(
'tab-1:1',
{
state: 'done',
prompt: 'p1',
agentType: 'claude',
lastAssistantMessage: 'final answer'
},
'claude',
{ updatedAt: 2_000, stateStartedAt: 1_000 }
)
expect(store.getState().agentStatusByPaneKey['tab-1:1'].lastAssistantMessage).toBe(
'final answer'
)
// Why: retained rows need the final done snapshot, but done->done does not
// change smart-sort class, so only the status/retention epoch should tick.
expect(store.getState().agentStatusEpoch).toBe(firstEpoch + 1)
expect(store.getState().sortEpoch).toBe(firstSortEpoch)
})
it('bumps global epochs when a stale same-state entry refreshes', () => {
vi.useFakeTimers()
const store = createTestStore()
+5 -12
View File
@@ -259,12 +259,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
interrupted: payload.interrupted
}
// Why: broad freshness-aware subscribers only need a global tick when
// an entry appears, changes state, crosses stale->fresh, or receives
// a same-state `done` update that may carry the final assistant
// message for retained rows. Same-state working prompt/tool pings
// still update agentStatusByPaneKey for the owning row, but they must
// not fan out through dashboard/sidebar aggregate work across every
// card. Sort-relevant inputs are:
// an entry appears, changes state, or crosses stale->fresh. Same-state
// tool/prompt pings still update agentStatusByPaneKey for the owning
// row, but they must not fan out through dashboard/sidebar aggregate
// work across every card. Sort-relevant inputs are:
// 1. `state` transitions — smart-sort class is a function of state.
// 2. Freshness transitions (stale → fresh) — `resolveAttention` in
// smart-attention.ts filters entries through
@@ -278,11 +276,6 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const wasFresh =
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
const sortRelevantChange = !existing || existing.state !== payload.state || !wasFresh
const retentionRelevantChange =
sortRelevantChange ||
(existing?.state === 'done' &&
payload.state === 'done' &&
updatedAt !== existing.updatedAt)
// Why: a new status event means the agent is live again — lift any
// one-shot retention suppressor so the row can be retained normally
// on its next disappearance. setAgentStatus fires on every PTY status
@@ -305,7 +298,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
agentStatusEpoch:
retentionRelevantChange || migrationUnsupported.changed
sortRelevantChange || migrationUnsupported.changed
? s.agentStatusEpoch + 1
: s.agentStatusEpoch,
sortEpoch:
@@ -134,149 +134,6 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined()
})
it('preserves browser map references when a page-state update is unchanged', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
title: 'Example'
})
const pageId = tab.activePageId
if (!pageId) {
throw new Error('Expected a new browser page')
}
const page = store.getState().browserPagesByWorkspace[tab.id]?.[0]
if (!page) {
throw new Error('Expected page state')
}
const browserPagesByWorkspace = store.getState().browserPagesByWorkspace
const browserTabsByWorktree = store.getState().browserTabsByWorktree
store.getState().updateBrowserPageState(pageId, {
title: page.title,
loading: page.loading,
faviconUrl: page.faviconUrl,
canGoBack: page.canGoBack,
canGoForward: page.canGoForward,
loadError: page.loadError
})
expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace)
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
})
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
title: 'Example'
})
const pageId = tab.activePageId
if (!pageId) {
throw new Error('Expected a new browser page')
}
store.setState({
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'unified-browser-tab',
entityId: tab.id,
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'browser',
label: 'Stale label',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
const browserPagesByWorkspace = store.getState().browserPagesByWorkspace
const browserTabsByWorktree = store.getState().browserTabsByWorktree
store.getState().updateBrowserPageState(pageId, { title: 'Example' })
expect(store.getState().unifiedTabsByWorktree['wt-1']?.[0]?.label).toBe('Example')
expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace)
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
})
it('repairs stale active browser workspace metadata on an otherwise unchanged page update', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
title: 'Example'
})
const pageId = tab.activePageId
if (!pageId) {
throw new Error('Expected a new browser page')
}
store.setState((state) => ({
browserTabsByWorktree: {
...state.browserTabsByWorktree,
'wt-1': (state.browserTabsByWorktree['wt-1'] ?? []).map((workspace) =>
workspace.id === tab.id
? {
...workspace,
title: 'Stale workspace',
url: 'https://stale.example.com',
loading: false,
canGoBack: true,
canGoForward: true
}
: workspace
)
}
}))
const browserPagesByWorkspace = store.getState().browserPagesByWorkspace
store.getState().updateBrowserPageState(pageId, { title: 'Example' })
const repaired = store
.getState()
.browserTabsByWorktree['wt-1']?.find((entry) => entry.id === tab.id)
expect(repaired).toMatchObject({
title: 'Example',
url: 'https://example.com',
loading: true,
canGoBack: false,
canGoForward: false
})
expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace)
})
it('updates the active browser unified-tab label without a second tab-label write', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
title: 'Example'
})
const pageId = tab.activePageId
if (!pageId) {
throw new Error('Expected a new browser page')
}
store.setState({
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'unified-browser-tab',
entityId: tab.id,
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'browser',
label: 'Example',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
store.getState().updateBrowserPageState(pageId, { title: 'Next', loading: false })
expect(store.getState().unifiedTabsByWorktree['wt-1']?.[0]?.label).toBe('Next')
expect(store.getState().setTabLabel).not.toHaveBeenCalled()
})
it('caps stored browser annotations per page', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com')
+37 -119
View File
@@ -317,26 +317,6 @@ function mirrorWorkspaceFromActivePage(
}
}
function browserWorkspaceMirrorFieldsEqual(
workspace: BrowserWorkspace,
mirrored: BrowserWorkspace
): boolean {
const workspacePageIds = workspace.pageIds ?? []
const mirroredPageIds = mirrored.pageIds ?? []
return (
workspace.activePageId === mirrored.activePageId &&
workspacePageIds.length === mirroredPageIds.length &&
workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) &&
workspace.url === mirrored.url &&
workspace.title === mirrored.title &&
workspace.loading === mirrored.loading &&
workspace.faviconUrl === mirrored.faviconUrl &&
workspace.canGoBack === mirrored.canGoBack &&
workspace.canGoForward === mirrored.canGoForward &&
workspace.loadError === mirrored.loadError
)
}
function getFallbackTabTypeForWorktree(
worktreeId: string,
openFiles: AppState['openFiles'],
@@ -355,46 +335,26 @@ function getFallbackTabTypeForWorktree(
return 'terminal'
}
const browserWorkspaceByIdCache = new WeakMap<
Record<string, BrowserWorkspace[]>,
Map<string, BrowserWorkspace>
>()
const browserPageByIdCache = new WeakMap<Record<string, BrowserPage[]>, Map<string, BrowserPage>>()
function findWorkspace(
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
workspaceId: string
): BrowserWorkspace | null {
const cached = browserWorkspaceByIdCache.get(browserTabsByWorktree)
if (cached) {
return cached.get(workspaceId) ?? null
}
const workspaceById = new Map<string, BrowserWorkspace>()
for (const workspaces of Object.values(browserTabsByWorktree)) {
for (const workspace of workspaces) {
workspaceById.set(workspace.id, workspace)
}
}
browserWorkspaceByIdCache.set(browserTabsByWorktree, workspaceById)
return workspaceById.get(workspaceId) ?? null
return (
Object.values(browserTabsByWorktree)
.flat()
.find((workspace) => workspace.id === workspaceId) ?? null
)
}
function findPage(
browserPagesByWorkspace: Record<string, BrowserPage[]>,
pageId: string
): BrowserPage | null {
const cached = browserPageByIdCache.get(browserPagesByWorkspace)
if (cached) {
return cached.get(pageId) ?? null
}
const pageById = new Map<string, BrowserPage>()
for (const pages of Object.values(browserPagesByWorkspace)) {
for (const page of pages) {
pageById.set(page.id, page)
}
}
browserPageByIdCache.set(browserPagesByWorkspace, pageById)
return pageById.get(pageId) ?? null
return (
Object.values(browserPagesByWorkspace)
.flat()
.find((page) => page.id === pageId) ?? null
)
}
export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = (set, get) => ({
@@ -1134,67 +1094,24 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
if (!workspace) {
return s
}
const nextPage = {
...page,
title:
updates.title === undefined ? page.title : normalizeBrowserTitle(updates.title, page.url),
loading: updates.loading ?? page.loading,
faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl,
canGoBack: updates.canGoBack ?? page.canGoBack,
canGoForward: updates.canGoForward ?? page.canGoForward,
loadError: updates.loadError === undefined ? page.loadError : updates.loadError
}
const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? []
const unifiedIndex =
workspace.activePageId === pageId && updates.title !== undefined
? unifiedTabs.findIndex(
(entry) => entry.contentType === 'browser' && entry.entityId === workspace.id
)
: -1
const unifiedLabelNeedsRepair =
unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title
const pageStateUnchanged =
nextPage.title === page.title &&
nextPage.loading === page.loading &&
nextPage.faviconUrl === page.faviconUrl &&
nextPage.canGoBack === page.canGoBack &&
nextPage.canGoForward === page.canGoForward &&
nextPage.loadError === page.loadError
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const mirroredWorkspace = pageStateUnchanged
? mirrorWorkspaceFromActivePage(workspace, currentPages)
: null
const workspaceNeedsRepair =
mirroredWorkspace !== null &&
!browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace)
if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) {
return s
}
if (pageStateUnchanged) {
const nextState: Partial<AppState> = {}
if (workspaceNeedsRepair && mirroredWorkspace) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? mirroredWorkspace : tab)
)
}
}
if (unifiedLabelNeedsRepair) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextPage.title } : entry
)
}
}
return nextState
}
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId ? nextPage : entry
entry.id === pageId
? {
...entry,
title:
updates.title === undefined
? entry.title
: normalizeBrowserTitle(updates.title, entry.url),
loading: updates.loading ?? entry.loading,
faviconUrl: updates.faviconUrl === undefined ? entry.faviconUrl : updates.faviconUrl,
canGoBack: updates.canGoBack ?? entry.canGoBack,
canGoForward: updates.canGoForward ?? entry.canGoForward,
loadError: updates.loadError === undefined ? entry.loadError : updates.loadError
}
: entry
)
const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages)
const nextState: Partial<AppState> = {
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
@@ -1206,18 +1123,19 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
)
}
}
if (workspace.activePageId === pageId && updates.title !== undefined && unifiedIndex !== -1) {
if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry
)
}
}
}
return nextState
})
const page = findPage(get().browserPagesByWorkspace, pageId)
if (!page) {
return
}
const workspace = findWorkspace(get().browserTabsByWorktree, page.workspaceId)
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === page.workspaceId)
if (item && workspace && workspace.activePageId === pageId && updates.title) {
get().setTabLabel(item.id, workspace.title)
}
},
setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url),
@@ -2111,78 +2111,6 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
})
it('does not fetch linked issue details when the issue card section is hidden', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
await Promise.resolve()
expect(mockApi.gh.issue).not.toHaveBeenCalled()
})
it('fetches linked issue details when the issue card section is visible', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const worktreeId = 'wt-1'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['issue'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: worktreeId,
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshGitHubForWorktreeIfStale(worktreeId)
await Promise.resolve()
expect(mockApi.gh.issue).toHaveBeenCalledWith({
repoPath,
repoId: 'repo-1',
number: 123
})
})
it('enqueues active PR refresh IPC for connected SSH-backed repos', () => {
const store = createTestStore()
const repoPath = '/repo'
@@ -2470,78 +2398,6 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
timeoutMs: 30_000
})
})
it('does not refresh stale linked issues when the issue card section is hidden', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
await Promise.resolve()
expect(mockApi.gh.issue).not.toHaveBeenCalled()
})
it('refreshes stale linked issues when the issue card section is visible', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['issue'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1,
linkedIssue: 123
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
await Promise.resolve()
expect(mockApi.gh.issue).toHaveBeenCalledWith({
repoPath,
repoId: 'repo-1',
number: 123
})
})
})
describe('createGitHubSlice.refreshGitHubForWorktree', () => {
+3 -8
View File
@@ -930,10 +930,6 @@ function evictStaleEntries<T>(
return pruned
}
function shouldRefreshIssueDecorations(state: AppState): boolean {
return (state.worktreeCardProperties ?? []).includes('issue')
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSaveCache(state: AppState): void {
@@ -2501,7 +2497,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const now = Date.now()
const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = []
const cardProps = state.worktreeCardProperties ?? []
const shouldRefreshIssues = shouldRefreshIssueDecorations(state)
const isPRStatusGrouping = state.groupBy === 'pr-status'
const rightSidebarShowsPR =
state.rightSidebarOpen &&
@@ -2535,7 +2530,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
}
if (shouldRefreshIssues && wt.linkedIssue) {
if (wt.linkedIssue) {
const issueKey = repoScopedCacheKey(repo.path, repo.id, String(wt.linkedIssue))
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
@@ -2617,7 +2612,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
}
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
if (worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
}
},
@@ -2800,7 +2795,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
}
if (shouldRefreshIssueDecorations(state) && worktree.linkedIssue) {
if (worktree.linkedIssue) {
const issueKey = repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue))
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
@@ -59,32 +59,6 @@ describe('runtimePaneTitle → sortEpoch', () => {
expect(store.getState().sortEpoch).toBe(baseline)
})
it('does not enumerate terminal tabs when the classification is unchanged', () => {
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })]
},
tabsByWorktree: {
'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })]
}
})
store.getState().setRuntimePaneTitle('tab-1', 1, '⠋ Claude')
const baseline = store.getState().sortEpoch
store.setState({
tabsByWorktree: new Proxy(store.getState().tabsByWorktree, {
ownKeys() {
throw new Error('tabsByWorktree should not be enumerated')
}
})
})
store.getState().setRuntimePaneTitle('tab-1', 1, '⠙ Claude')
expect(store.getState().runtimePaneTitlesByTabId['tab-1']?.[1]).toBe('⠙ Claude')
expect(store.getState().sortEpoch).toBe(baseline)
})
it('bumps sortEpoch when clearing a classified title back to none', () => {
const store = createTestStore()
seedStore(store, {
@@ -101,32 +75,6 @@ describe('runtimePaneTitle → sortEpoch', () => {
expect(store.getState().sortEpoch).toBeGreaterThan(baseline)
})
it('does not enumerate terminal tabs when clearing an unclassified title', () => {
const store = createTestStore()
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: 'wt-bg', repoId: 'repo1', path: '/path/wt-bg' })]
},
tabsByWorktree: {
'wt-bg': [makeTab({ id: 'tab-1', worktreeId: 'wt-bg' })]
}
})
store.getState().setRuntimePaneTitle('tab-1', 1, 'shell prompt')
const baseline = store.getState().sortEpoch
store.setState({
tabsByWorktree: new Proxy(store.getState().tabsByWorktree, {
ownKeys() {
throw new Error('tabsByWorktree should not be enumerated')
}
})
})
store.getState().clearRuntimePaneTitle('tab-1', 1)
expect(store.getState().runtimePaneTitlesByTabId['tab-1']).toBeUndefined()
expect(store.getState().sortEpoch).toBe(baseline)
})
it('does not bump sortEpoch when the changing pane belongs to the active worktree (set)', () => {
// Why: clicking a slept worktree wakes it; the PTY remount briefly
// reclassifies its title, which must NOT re-rank the active worktree.
@@ -1358,29 +1358,6 @@ describe('setActiveWorktree', () => {
})
})
it('preserves terminal and unified tab map references when a live title repeats', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
}
})
const first = store.getState().createTab(wt)
store.getState().updateTabTitle(first.id, 'Claude Code')
const tabsByWorktree = store.getState().tabsByWorktree
const unifiedTabsByWorktree = store.getState().unifiedTabsByWorktree
const sortEpoch = store.getState().sortEpoch
store.getState().updateTabTitle(first.id, 'Claude Code')
expect(store.getState().tabsByWorktree).toBe(tabsByWorktree)
expect(store.getState().unifiedTabsByWorktree).toBe(unifiedTabsByWorktree)
expect(store.getState().sortEpoch).toBe(sortEpoch)
})
it('clears stale background browser tab type when closing the last browser tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
@@ -208,12 +208,6 @@ export function patchTab(
if (!found) {
return null
}
const patchChangesTab = (Object.keys(patch) as (keyof Tab)[]).some(
(key) => found.tab[key] !== patch[key]
)
if (!patchChangesTab) {
return null
}
const { worktreeId } = found
const tabs = tabsByWorktree[worktreeId] ?? []
return {
@@ -973,16 +973,6 @@ describe('TabsSlice', () => {
expect(store.getState().unifiedTabsByWorktree[WT][0].label).toBe('zsh')
})
it('setTabLabel preserves tab map references when the label is unchanged', () => {
const tab = store.getState().createUnifiedTab(WT, 'terminal')
store.getState().setTabLabel(tab.id, 'zsh')
const before = store.getState().unifiedTabsByWorktree
store.getState().setTabLabel(tab.id, 'zsh')
expect(store.getState().unifiedTabsByWorktree).toBe(before)
})
it('setTabCustomLabel updates customLabel', () => {
const tab = store.getState().createUnifiedTab(WT, 'terminal')
store.getState().setTabCustomLabel(tab.id, 'my-term')
+55 -88
View File
@@ -3,7 +3,6 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
SetupSplitDirection,
Tab,
TerminalLayoutSnapshot,
TerminalTab,
Worktree,
@@ -68,40 +67,6 @@ function getFallbackTabTitle(tab: TerminalTab, index?: number): string {
)
}
let terminalTabOwnerCacheSource: Record<string, TerminalTab[]> | null = null
let terminalTabOwnerCache = new Map<string, string>()
function getTerminalTabOwnerWorktreeId(
tabsByWorktree: Record<string, TerminalTab[]>,
tabId: string
): string | null {
if (terminalTabOwnerCacheSource !== tabsByWorktree) {
const nextCache = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
for (const tab of tabs) {
nextCache.set(tab.id, worktreeId)
}
}
terminalTabOwnerCacheSource = tabsByWorktree
terminalTabOwnerCache = nextCache
}
return terminalTabOwnerCache.get(tabId) ?? null
}
function updateUnifiedTerminalLabel(
unifiedTabs: Tab[],
terminalTabId: string,
label: string
): Tab[] | null {
const unifiedIndex = unifiedTabs.findIndex(
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
)
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.label === label) {
return null
}
return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry))
}
function isWindowsRendererRuntime(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
}
@@ -847,47 +812,38 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// unchanged) would break shallow-equality checks in unrelated
// selectors and trigger spurious re-renders across background
// worktrees on every OSC title frame.
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return s
}
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
const tabIndex = tabs.findIndex((t) => t.id === tabId)
const currentTab = tabs[tabIndex]
if (!currentTab) {
return s
}
const nextTitle = title.trim() || getFallbackTabTitle(currentTab)
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel(
currentUnifiedTabs,
tabId,
nextTitle
)
if (currentTab.title === nextTitle) {
return unifiedTabsWithUpdatedLabel
? {
unifiedTabsByWorktree: {
...s.unifiedTabsByWorktree,
[ownerWorktreeId]: unifiedTabsWithUpdatedLabel
let ownerWorktreeId: string | null = null
let ownerTabs: TerminalTab[] | null = null
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
const idx = tabs.findIndex((t) => t.id === tabId)
if (idx === -1) {
continue
}
const t = tabs[idx]
const nextTitle = title.trim() || getFallbackTabTitle(t)
if (t.title === nextTitle) {
return s
}
ownerWorktreeId = wId
ownerTabs = tabs.map((tab) =>
tab.id === tabId
? {
...tab,
// Why: PTYs can briefly emit an empty title while an agent exits.
// Keep the stable fallback label instead of rendering a blank tab.
title: nextTitle,
defaultTitle:
tab.defaultTitle ??
(/^Terminal \d+$/.test(tab.title) ? tab.title : undefined) ??
(/^Terminal \d+$/.test(nextTitle) ? nextTitle : undefined)
}
}
: s
: tab
)
break
}
if (!ownerWorktreeId || !ownerTabs) {
return s
}
const ownerTabs = tabs.map((tab) =>
tab.id === tabId
? {
...tab,
// Why: PTYs can briefly emit an empty title while an agent exits.
// Keep the stable fallback label instead of rendering a blank tab.
title: nextTitle,
defaultTitle:
tab.defaultTitle ??
(/^Terminal \d+$/.test(tab.title) ? tab.title : undefined) ??
(/^Terminal \d+$/.test(nextTitle) ? nextTitle : undefined)
}
: tab
)
scheduleRuntimeGraphSync()
const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs }
// Agent status is derived from terminal titles and affects sort scoring,
@@ -898,17 +854,20 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// title update). Bumping sortEpoch here would reorder the sidebar
// on click — the exact bug PR #209 intended to fix.
const isActive = ownerWorktreeId === s.activeWorktreeId
const nextState: Partial<AppState> = isActive
return isActive
? { tabsByWorktree: nextTabsByWorktree }
: { tabsByWorktree: nextTabsByWorktree, sortEpoch: s.sortEpoch + 1 }
if (unifiedTabsWithUpdatedLabel) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[ownerWorktreeId]: unifiedTabsWithUpdatedLabel
}
}
return nextState
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
const resolvedTitle =
Object.values(get().tabsByWorktree)
.flat()
.find((tab) => tab.id === tabId)?.title ?? title.trim()
get().setTabLabel(item.id, resolvedTitle)
}
},
setRuntimePaneTitle: (tabId, paneId, title) => {
@@ -933,9 +892,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// re-emits its working title) — bumping would re-rank the sidebar on
// click, the exact bug PR #209 fixed for updateTabTitle. If no owner
// is found the pane is orphaned; skip the bump as unsafe.
const ownerWorktreeId = classificationChanged
? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
: null
let ownerWorktreeId: string | null = null
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
if (tabs.some((t) => t.id === tabId)) {
ownerWorktreeId = wId
break
}
}
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive
return {
@@ -973,9 +936,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// fire as a side-effect of a click-driven PTY teardown in the active
// worktree must not re-rank the sidebar. Skip bumping when no owner is
// found (orphaned pane) for the same safety reason.
const ownerWorktreeId = hadClassification
? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
: null
let ownerWorktreeId: string | null = null
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
if (tabs.some((t) => t.id === tabId)) {
ownerWorktreeId = wId
break
}
}
const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId
const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive
return {
-142
View File
@@ -1,142 +0,0 @@
import type { Page } from '@stablyai/playwright-test'
import { rmSync, writeFileSync } from 'fs'
import path from 'path'
import { test, expect } from './helpers/orca-app'
import {
getTerminalContent,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput,
sendToTerminal
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop'
const MAX_MEDIAN_KEY_LATENCY_MS = 250
const MAX_WORST_KEY_LATENCY_MS = 1_000
async function focusActiveTerminalInput(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to focus')
}
pane.terminal.focus()
const textarea = pane.container.querySelector(
'.xterm-helper-textarea'
) as HTMLTextAreaElement | null
if (!textarea) {
throw new Error('Active terminal has no xterm helper textarea')
}
textarea.focus()
})
}
function codexLikePromptScript(runId: string): string {
return `
process.stdin.setEncoding('utf8')
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.resume()
let seq = 0
const interrupt = String.fromCharCode(3)
process.stdout.write('\\x1b]0;Codex typing benchmark\\x07')
process.stdout.write('CODEX_TYPING_READY_${runId}\\n')
process.stdin.on('data', (chunk) => {
if (chunk.includes(interrupt)) {
process.exit(0)
}
for (const char of chunk) {
if (char === '\\r' || char === '\\n') continue
seq += 1
process.stdout.write('\\r\\x1b[2KCodex prompt ' + seq + ': ' + char + ' CODEX_KEY_${runId}_' + seq + '\\n')
}
})
`
}
async function waitForMarkerLatency(
page: Page,
marker: string,
timeoutMs: number
): Promise<number> {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
if ((await getTerminalContent(page, 12_000)).includes(marker)) {
return performance.now() - start
}
await page.waitForTimeout(5)
}
throw new Error(`Timed out waiting for terminal marker ${marker}`)
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)] ?? 0
}
test.describe('Terminal typing latency', () => {
test('Codex-like interactive prompt echoes typed keys without visible lag', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = String(Date.now())
const scriptPath = path.join(testRepoPath, `.orca-typing-benchmark-${runId}.mjs`)
writeFileSync(scriptPath, codexLikePromptScript(runId))
let commandSent = false
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
commandSent = true
await waitForTerminalOutput(orcaPage, `CODEX_TYPING_READY_${runId}`, 10_000)
await focusActiveTerminalInput(orcaPage)
const latencies: number[] = []
for (const [index, char] of [...KEY_LATENCY_SAMPLES].entries()) {
const seq = index + 1
const marker = `CODEX_KEY_${runId}_${seq}`
const start = performance.now()
await orcaPage.keyboard.type(char)
await waitForMarkerLatency(orcaPage, marker, MAX_WORST_KEY_LATENCY_MS)
latencies.push(performance.now() - start)
}
const medianLatency = median(latencies)
const worstLatency = Math.max(...latencies)
testInfo.annotations.push({
type: 'terminal-typing-latency',
description: `median=${medianLatency.toFixed(1)}ms worst=${worstLatency.toFixed(1)}ms samples=${latencies
.map((value) => value.toFixed(1))
.join(',')}`
})
console.info(
`[terminal-typing-latency] median=${medianLatency.toFixed(
1
)}ms worst=${worstLatency.toFixed(1)}ms samples=${latencies
.map((value) => value.toFixed(1))
.join(',')}`
)
expect(medianLatency).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS)
expect(worstLatency).toBeLessThan(MAX_WORST_KEY_LATENCY_MS)
} finally {
if (commandSent) {
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
}
rmSync(scriptPath, { force: true })
}
})
})