diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 2491591572f..25902305f41 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -2,10 +2,18 @@ import { readFileSync } from 'node:fs' const SOURCE_FILES = [ './terminal-webview-html.ts', - ...Array.from( - { length: 10 }, - (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` - ) + './terminal-webview-html/document-shell.ts', + './terminal-webview-html/runtime-state-and-text-scaling.ts', + './terminal-webview-html/fit-scale-and-write-queue.ts', + './terminal-webview-html/terminal-init-and-write.ts', + './terminal-webview-html/host-message-router.ts', + './terminal-webview-html/selection-state-and-eviction.ts', + './terminal-webview-html/term-observers-and-mode-mirroring.ts', + './terminal-webview-html/mouse-report-and-scroll-routing.ts', + './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', + './terminal-webview-html/selection-overlay.ts', + './terminal-webview-html/surface-touch-gestures.ts', + './terminal-webview-html/message-bridge-and-document-close.ts' ] as const /** Reads the TypeScript source that assembles the in-WebView document. */ diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index b55421bbe4d..40b7a2db22c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,28 +1,34 @@ -import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01' -import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02' -import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03' -import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04' -import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05' -import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06' -import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07' -import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08' -import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09' -import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10' +import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' +import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' +import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' +import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' +import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' +import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' +import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' +import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' +import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' +import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' +import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently reviewable. +// Why: keep the document source stable while each script/style concern remains independently +// reviewable. Boundaries can only fall where the emitted document allows, so a few modules +// carry a second concern noted at the top of the file. export const XTERM_HTML = [ - TERMINAL_HTML_FRAGMENT_01, - TERMINAL_HTML_FRAGMENT_02, - TERMINAL_HTML_FRAGMENT_03, - TERMINAL_HTML_FRAGMENT_04, - TERMINAL_HTML_FRAGMENT_05, - TERMINAL_HTML_FRAGMENT_06, - TERMINAL_HTML_FRAGMENT_07, - TERMINAL_HTML_FRAGMENT_08, - TERMINAL_HTML_FRAGMENT_09, - TERMINAL_HTML_FRAGMENT_10 + TERMINAL_HTML_DOCUMENT_SHELL, + TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, + TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_INIT_AND_WRITE, + TERMINAL_HTML_HOST_MESSAGE_ROUTER, + TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, + TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, + TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, + TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, + TERMINAL_HTML_SELECTION_OVERLAY, + TERMINAL_HTML_SURFACE_TOUCH_GESTURES, + TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/fragment-01.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-01.ts rename to mobile/src/terminal/terminal-webview-html/document-shell.ts index 77dba06232a..d6e733cdd77 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-01.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -1,7 +1,7 @@ import { colors } from '../../theme/mobile-theme' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated' -export const TERMINAL_HTML_FRAGMENT_01 = ` +export const TERMINAL_HTML_DOCUMENT_SHELL = ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-03.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-03.ts rename to mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts index 075ee819ec6..074185d43a5 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-03.ts +++ b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts @@ -1,6 +1,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' -export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS} +// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. +export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} function getCellHeight() { if (!term || !term._core) return 15; diff --git a/mobile/src/terminal/terminal-webview-html/fragment-10.ts b/mobile/src/terminal/terminal-webview-html/fragment-10.ts deleted file mode 100644 index d461f4a2039..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fragment-10.ts +++ /dev/null @@ -1,132 +0,0 @@ -export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - - function handleIncomingMessage(e) { - var msg; - try { - msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - } catch (ex) { - return; - } - try { - handleMsg(msg); - } catch(ex) { - reportEngineError( - msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, - msg && msg.type === 'init' && !everReady - ); - } - } - - window.addEventListener('message', handleIncomingMessage); - - document.addEventListener('message', handleIncomingMessage); - - window.addEventListener('resize', function() { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize'); - adjustRowsForViewport(); - repositionOverlay(); - clampPan(); - updateTransform(); - }); - - if (window.Terminal) { - notify({ type: 'web-ready' }); - } else { - reportEngineError('terminal engine missing', 'xterm failed to load', true); - } -})(); - - -` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-05.ts b/mobile/src/terminal/terminal-webview-html/host-message-router.ts similarity index 77% rename from mobile/src/terminal/terminal-webview-html/fragment-05.ts rename to mobile/src/terminal/terminal-webview-html/host-message-router.ts index c8852c50d78..29cba25df36 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-05.ts +++ b/mobile/src/terminal/terminal-webview-html/host-message-router.ts @@ -1,6 +1,6 @@ import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected' -export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} +export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS} function notify(msg) { if (window.ReactNativeWebView) { @@ -186,45 +186,4 @@ export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} } } - // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. ` diff --git a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts new file mode 100644 index 00000000000..49cd3b4d84f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts @@ -0,0 +1,43 @@ +export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); + + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-06.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts similarity index 56% rename from mobile/src/terminal/terminal-webview-html/fragment-06.ts rename to mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts index 1a03331df34..3b7e7f24cf3 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-06.ts +++ b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts @@ -1,100 +1,6 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' -export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - - function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - - function viewportToCell(clientX, clientY) { +export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { if (!term) return null; var cellW = getCellWidth(); var cellH = getCellHeight(); @@ -201,4 +107,82 @@ export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; } if (sgrMouseMode) { // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-02.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-02.ts rename to mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts index 7d25b0fb6b2..44ce1d39042 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-02.ts +++ b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts @@ -3,7 +3,8 @@ import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injec import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' import { DEFAULT_TERMINAL_THEME } from './theme' -export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; +// Also carries the scroll-indicator painter, which reads the scale state declared here. +export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); diff --git a/mobile/src/terminal/terminal-webview-html/fragment-08.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-08.ts rename to mobile/src/terminal/terminal-webview-html/selection-overlay.ts index 29f0d929918..335407af51a 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-08.ts +++ b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts @@ -1,7 +1,8 @@ import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' -export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS} +// Opens with the path/url tap matchers: they land at this point in the emitted document. +export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} ${URL_TAP_WEBVIEW_JS} function seedWordSelection(col, absRow) { diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts new file mode 100644 index 00000000000..48c05b6ad85 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts @@ -0,0 +1,71 @@ +export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // the tap (which opens links/paths). {x,y,t,identifier} or null once the + // gesture is disqualified as a tap (moved too far or held too long). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-07.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts similarity index 54% rename from mobile/src/terminal/terminal-webview-html/fragment-07.ts rename to mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts index 22c3372b26f..de9db152fbf 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-07.ts +++ b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts @@ -1,82 +1,4 @@ -export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - - function clampNormalScrollLines(lines) { +export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; var buffer = term.buffer.active; if (lines > 0) { diff --git a/mobile/src/terminal/terminal-webview-html/fragment-09.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-html/fragment-09.ts rename to mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts index 06ffa927c65..515d7fd9d26 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-09.ts +++ b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts @@ -2,7 +2,8 @@ import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injec import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' -export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} +// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. +export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} // External mouse / trackpad scroll: see // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). @@ -135,4 +136,93 @@ export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { panX += x - ts.lastX; clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) notify({ type: 'haptic', kind: 'selection' }); + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + ` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts new file mode 100644 index 00000000000..b69547affb5 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts @@ -0,0 +1,67 @@ +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' + +export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-04.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-04.ts rename to mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts index c2c9863be79..90dba7cbdbc 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-04.ts +++ b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts @@ -1,7 +1,7 @@ import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' -export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS} +export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 962cfddaa4d..9218e5d6ad9 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in // TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. @@ -8,16 +9,7 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + - Array.from({ length: 10 }, (_, index) => - readFileSync( - new URL( - `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, - import.meta.url - ), - 'utf8' - ) - ).join('') + readTerminalWebViewHtmlSource() const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 0deee79f670..d775237ddcc 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -11,8 +11,8 @@ const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) -const terminalHtmlFragmentSource = readFileSync( - new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), +const terminalHtmlDocumentShellSource = readFileSync( + new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) // Read behavior from the assembled document; the module source only contains @@ -150,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index d17c3446db1..3849f39a7c9 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' // group. These tests are the only thing standing between that trust and a // silently unreachable command, so they load every group for real. +// Why: __dirname works under both Vitest and the CommonJS tsc emit that +// build:cli type-checks this file against; import.meta.dirname does not. +const HANDLERS_DIR = join(__dirname, 'handlers') + +// Why: both the plural records and the single-command `*_HANDLER` ones that +// nested modules export get spread into a group, so both must route. +const HANDLER_RECORD_EXPORT = /_HANDLERS?$/ + +function listHandlerModules(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listHandlerModules(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +function isHandlerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every((entry) => typeof entry === 'function') + ) +} + describe('handler group manifest', () => { it('lists a loadable group for every entry', async () => { for (const group of HANDLER_GROUPS) { @@ -54,25 +80,35 @@ describe('handler group manifest', () => { }) // Why: dropping a group from the manifest silently unregisters its commands — - // scan the directory so a new or forgotten handler file fails here, not in prod. - it('registers every handler module that exports a handler group', async () => { - // Why: __dirname works under both Vitest and the CommonJS tsc emit that - // build:cli type-checks this file against; import.meta.dirname does not. - const dir = join(__dirname, 'handlers') - const modules = readdirSync(dir).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - ) - const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) - const missing: string[] = [] - for (const file of modules) { - const name = file.slice(0, -'.ts'.length) - const exports: Record = await import(join(dir, file)) - const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) - if (exportsGroup && !registered.has(name)) { - missing.push(name) + // walk the tree so a new or forgotten handler file fails here, not in prod. + // Nested modules are spread into a parent group rather than registered under + // their own name, so routability, not file name, is the invariant that holds. + it('routes every command exported by a handler module', async () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const unroutable: string[] = [] + for (const file of listHandlerModules(HANDLERS_DIR)) { + const exports: Record = await import(file) + for (const [name, value] of Object.entries(exports)) { + if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + if (!routes.has(key)) { + unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`) + } + } } } - expect(missing).toEqual([]) + expect(unroutable).toEqual([]) + }) + + it('finds the modules it is meant to guard', () => { + // Why: a walk that missed the tree would make the guard above vacuously pass. + const modules = listHandlerModules(HANDLERS_DIR) + expect(modules.length).toBeGreaterThanOrEqual(40) + expect( + modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length + ).toBeGreaterThanOrEqual(7) }) }) diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index b236c90a94e..32d1c00f0e1 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -71,3 +71,11 @@ export function validateGitRelativeFilePath(worktreePath: string, filePath: stri return normalizedRelativePath } + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index 51a90c36213..6b73df909da 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -12,10 +12,16 @@ import { DOWNLOAD_SESSION_TTL_MS, inspectDownloadDestination, promoteDownloadedFile, - validateRequiredString, createSiblingTransferPath, type DownloadFileResult -} from './filesystem-file-helpers' +} from './filesystem-download-promotion' + +function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context diff --git a/src/main/ipc/filesystem/filesystem-download-promotion.ts b/src/main/ipc/filesystem/filesystem-download-promotion.ts new file mode 100644 index 00000000000..9fbc8acddd2 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-promotion.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isENOENT } from '../filesystem-path-containment' + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts new file mode 100644 index 00000000000..b140532c372 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -0,0 +1,85 @@ +import { open } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts deleted file mode 100644 index 53e2eb3ec30..00000000000 --- a/src/main/ipc/filesystem/filesystem-file-helpers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { open, rename, rm, stat } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' -import { isENOENT } from '../filesystem-path-containment' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -export const BINARY_PROBE_BYTES = 8192 -export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -export async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -export function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') -} - -export function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -export async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -export async function inspectDownloadDestination( - destinationPath: string -): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -export async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ -export function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, BINARY_PROBE_BYTES) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -export async function isBinaryFilePrefix(filePath: string): Promise { - const handle: FileHandle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -export function isDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - return entry.isDirectory() -} - -export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts index 4e55a39f76a..ae029dc2f41 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -23,7 +23,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitCommitGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts index ff706fcb078..8434e02264b 100644 --- a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -7,8 +7,10 @@ import { } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' -import { validateGitRelativeFilePath } from '../filesystem-path-containment' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { + validateFullGitObjectId, + validateGitRelativeFilePath +} from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts index 0933c08587e..8de07853057 100644 --- a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -12,7 +12,7 @@ import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { resolveModelDiscoveryLocalPath, getLocalAgentRuntimeTarget -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import type { FilesystemHandlerContext } from './filesystem-handler-context' diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index b779fb44429..1c16a119b3b 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -27,7 +27,7 @@ import { getLocalAgentRuntimeTarget, getLocalTextGenerationTarget, getRepoForSourceControlAi -} from './filesystem-worktree-helpers' +} from './filesystem-source-control-ai-targets' export function registerFilesystemGitPullRequestGenerationHandlers( context: FilesystemHandlerContext diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts index cb14d98db60..4ed9b55057e 100644 --- a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -6,7 +6,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../filesystem-path-containment' import type { FilesystemHandlerContext } from './filesystem-handler-context' export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts index 3aeb8320cc3..02488931e1e 100644 --- a/src/main/ipc/filesystem/filesystem-handler-context.ts +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -3,7 +3,7 @@ import type { FileHandle } from 'node:fs/promises' import type { Store } from '../../persistence' import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' -import { cleanupLocalTransferPath } from './filesystem-file-helpers' +import { cleanupLocalTransferPath } from './filesystem-download-promotion' export type DownloadSession = { destinationPath: string diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index d1a86f1f748..938370a2816 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -20,7 +20,7 @@ import { MAX_TEXT_FILE_SIZE, PREVIEWABLE_BINARY_MIME_TYPES, readLocalLogSnapshot -} from './filesystem-file-helpers' +} from './filesystem-file-content-inspection' export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { const { store } = context diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index c2ec1531ead..f6dd8d57284 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -27,7 +27,9 @@ import { searchWithGitGrep } from '../filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' import type { FilesystemHandlerContext } from './filesystem-handler-context' -import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { const { store, activeTextSearches } = context diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts similarity index 95% rename from src/main/ipc/filesystem/filesystem-worktree-helpers.ts rename to src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 18dd5c4f1f6..62b86e7361a 100644 --- a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -175,11 +175,3 @@ export function getLocalTextGenerationTarget( ...(env ? { env } : {}) } } - -export function validateFullGitObjectId(value: string, label: string): string { - const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ - if (!pattern.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts index a522ae10af2..a2d7d7a3cd5 100644 --- a/src/main/ipc/filesystem/git-remote/compare-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -10,7 +10,7 @@ import { } from '../../../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' -import { validateFullGitObjectId } from '../filesystem-worktree-helpers' +import { validateFullGitObjectId } from '../../filesystem-path-containment' import type { FilesystemHandlerContext } from '../filesystem-handler-context' import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 73fc291f4ca..22a8b4c4178 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -88,8 +88,8 @@ describe('GitHub IPC channel parity', () => { // The preload facade now composes the two GitHub bridge owners; inspect // both owners so the channel census remains tied to the actual invokes. const preloadSource = [ - '../../preload/api/gh-bridge-part-1.ts', - '../../preload/api/gh-bridge-part-2.ts' + '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', + '../../preload/api/gh-bridge-mutations-and-projects.ts' ] .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) .join('\n') diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts deleted file mode 100644 index f9fe77cbdc5..00000000000 --- a/src/main/runtime/agent-session-claim-key-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function isVerifiable( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): boolean { - const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= retentionMs -} - -export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { - return { - ...record, - updatedAt: now, - // A conflicted key must remain conflicted after its observing process exits. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - } -} - -export function retire( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): void { - if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - state.retiredClaimKeys = state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= retentionMs - ) -} - -export function listOrphanSpawnTokens( - records: readonly AgentSessionRecord[], - observedTokens: readonly string[] -): string[] { - const leases = records.map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) -} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c9d82cbd744..911d185b0fd 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -733,13 +733,25 @@ function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) } +/** + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps + * that failed to load. `unverifiable` — the probe never answered, which is evidence about the + * transport, not about the deps. + * + * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and + * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. + */ +type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' + async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal -): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { +): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { @@ -757,11 +769,14 @@ async function probeRequiredNativeDeps( `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - const available = probe.includes('ORCA-NATIVE-DEPS-OK') - return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } + return probe.includes('ORCA-NATIVE-DEPS-OK') + ? { status: 'ok', missing: [] } + : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } } catch { signal?.throwIfAborted() - return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } + // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and + // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. + return { status: 'unverifiable', missing: [] } } } @@ -810,7 +825,8 @@ async function repairInstalledNativeDeps( lockResult === 'busy' || lockResult === 'error' ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) : undefined - if (initialProbe.available) { + // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. + if (initialProbe.status !== 'blocked') { // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. if (lockResult !== 'acquired') { return { ownsInstallLock: false, gcClaimToken } @@ -847,7 +863,9 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) let repairNamespace: RelayInstallNamespace | undefined - if (!probe.available) { + if (probe.status !== 'ok') { + // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. + const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. repairNamespace = await createRelayLaunchNamespace( conn, @@ -863,7 +881,7 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing, + resetDeps, repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts new file mode 100644 index 00000000000..c67270db4c3 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -0,0 +1,226 @@ +// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec +// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are +// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' + +const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" +const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" + +describe('native-deps repair probe verdicts', () => { + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + function warnings(): string[] { + return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + } + + it('launches an intact relay when the health probe never answers', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + // Assert the repair-avoidance facts before the launch outcome so a regression names the defect + // rather than the fixture drift that follows from an unexpected repair. + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) + }) + + it('still resets and repairs when the probe answers without the OK marker', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // answered, no marker line: both deps are genuinely broken + 'MISSING', // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(NODE_PTY_RESET) + expect(install).toContain(WATCHER_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('skips repair entirely when the probe answers OK', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken + { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. + expect(install).not.toContain(NODE_PTY_RESET) + }) +}) diff --git a/src/preload/api/browser-bridge-part-1.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts similarity index 99% rename from src/preload/api/browser-bridge-part-1.ts rename to src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 47883976338..3714a3bca7c 100644 --- a/src/preload/api/browser-bridge-part-1.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -7,7 +7,7 @@ import type { import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' -export const browserApiPart1 = { +export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), registerGuest: (args: { diff --git a/src/preload/api/browser-bridge-part-2.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts similarity index 99% rename from src/preload/api/browser-bridge-part-2.ts rename to src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 07788c59d2e..0e959e0af4f 100644 --- a/src/preload/api/browser-bridge-part-2.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from 'electron' -export const browserApiPart2 = { +export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( callback: (event: { browserPageId: string diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index 9b72b1658d3..dca222c5843 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,4 +1,7 @@ -import { browserApiPart1 } from './browser-bridge-part-1' -import { browserApiPart2 } from './browser-bridge-part-2' +import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' +import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' -export const browserApi = { ...browserApiPart1, ...browserApiPart2 } +export const browserApi = { + ...browserGuestRegistrationAndDownloadsApi, + ...browserPageInteractionAndSessionsApi +} diff --git a/src/preload/api/gh-bridge-part-2.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts similarity index 99% rename from src/preload/api/gh-bridge-part-2.ts rename to src/preload/api/gh-bridge-mutations-and-projects.ts index c7ebc51b57e..3103cb432ec 100644 --- a/src/preload/api/gh-bridge-part-2.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -36,7 +36,7 @@ import type { } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' -export const ghApiPart2 = { +export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string repoId?: string diff --git a/src/preload/api/gh-bridge-part-1.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts similarity index 99% rename from src/preload/api/gh-bridge-part-1.ts rename to src/preload/api/gh-bridge-pull-requests-and-work-items.ts index 11ed8c32f79..a4f3e1d45ef 100644 --- a/src/preload/api/gh-bridge-part-1.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -10,7 +10,7 @@ import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/wo import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' -export const ghApiPart1 = { +export const ghPullRequestsAndWorkItemsApi = { viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), repoSlug: (args: { repoPath: string; repoId?: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index c4bef718721..52c21a966a7 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,4 @@ -import { ghApiPart1 } from './gh-bridge-part-1' -import { ghApiPart2 } from './gh-bridge-part-2' +import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' +import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghApiPart1, ...ghApiPart2 } +export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } diff --git a/src/preload/api/pty-bridge-part-1.ts b/src/preload/api/pty-bridge-session-control.ts similarity index 99% rename from src/preload/api/pty-bridge-part-1.ts rename to src/preload/api/pty-bridge-session-control.ts index 40da8dbfa52..7e761738256 100644 --- a/src/preload/api/pty-bridge-part-1.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -16,7 +16,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' -export const ptyApiPart1 = { +export const ptySessionControlApi = { spawn: (opts: { cols: number rows: number diff --git a/src/preload/api/pty-bridge-part-2.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts similarity index 99% rename from src/preload/api/pty-bridge-part-2.ts rename to src/preload/api/pty-bridge-stream-and-serialization.ts index 1686a621f97..8fc9c48bce1 100644 --- a/src/preload/api/pty-bridge-part-2.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' -export const ptyApiPart2 = { +export const ptyStreamAndSerializationApi = { inspectProcess: ( id: string ): Promise<{ diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index 314bd6f993f..df080692178 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,4 @@ -import { ptyApiPart1 } from './pty-bridge-part-1' -import { ptyApiPart2 } from './pty-bridge-part-2' +import { ptySessionControlApi } from './pty-bridge-session-control' +import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptyApiPart1, ...ptyApiPart2 } +export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } diff --git a/src/preload/api/ui-bridge-part-4.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts similarity index 98% rename from src/preload/api/ui-bridge-part-4.ts rename to src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 2c5e2683931..867bd80026d 100644 --- a/src/preload/api/ui-bridge-part-4.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -13,7 +13,7 @@ import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' -export const uiApiPart4 = { +export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( callback: (data: { worktreeId: string @@ -191,5 +191,8 @@ export const uiApiPart4 = { }, confirmWindowClose: (): void => { ipcRenderer.send('window:confirm-close') + }, + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') } } diff --git a/src/preload/api/ui-bridge-part-5.ts b/src/preload/api/ui-bridge-part-5.ts deleted file mode 100644 index 7c4c10eb1c6..00000000000 --- a/src/preload/api/ui-bridge-part-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ipcRenderer } from 'electron' - -export const uiApiPart5 = { - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } -} diff --git a/src/preload/api/ui-bridge-part-1.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-1.ts rename to src/preload/api/ui-bridge-state-and-menu-commands.ts index b402b44b248..25476165cfb 100644 --- a/src/preload/api/ui-bridge-part-1.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -2,7 +2,7 @@ import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' -export const uiApiPart1 = { +export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), set: (args) => ipcRenderer.invoke('ui:set', args), setWithAck: (args) => ipcRenderer.invoke('ui:set', args), diff --git a/src/preload/api/ui-bridge-part-2.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts similarity index 99% rename from src/preload/api/ui-bridge-part-2.ts rename to src/preload/api/ui-bridge-tab-and-browser-commands.ts index fdf8956622e..ccca9a24f5b 100644 --- a/src/preload/api/ui-bridge-part-2.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -7,7 +7,7 @@ import type { } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' -export const uiApiPart2 = { +export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( callback: (data: { requestId: string diff --git a/src/preload/api/ui-bridge-part-3.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts similarity index 99% rename from src/preload/api/ui-bridge-part-3.ts rename to src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 44669b67838..9de47cceb0c 100644 --- a/src/preload/api/ui-bridge-part-3.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -12,7 +12,7 @@ import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' -export const uiApiPart3 = { +export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( callback: (data: { requestId?: string diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts index 97e199215c6..200d49ba1c1 100644 --- a/src/preload/api/ui-bridge.ts +++ b/src/preload/api/ui-bridge.ts @@ -1,14 +1,12 @@ import type { PreloadApi } from '../api-types' -import { uiApiPart1 } from './ui-bridge-part-1' -import { uiApiPart2 } from './ui-bridge-part-2' -import { uiApiPart3 } from './ui-bridge-part-3' -import { uiApiPart4 } from './ui-bridge-part-4' -import { uiApiPart5 } from './ui-bridge-part-5' +import { uiStateAndMenuCommandsApi } from './ui-bridge-state-and-menu-commands' +import { uiTabAndBrowserCommandsApi } from './ui-bridge-tab-and-browser-commands' +import { uiTerminalAndSessionTabsApi } from './ui-bridge-terminal-and-session-tabs' +import { uiClipboardAndWindowControlsApi } from './ui-bridge-clipboard-and-window-controls' export const uiApi = { - ...uiApiPart1, - ...uiApiPart2, - ...uiApiPart3, - ...uiApiPart4, - ...uiApiPart5 + ...uiStateAndMenuCommandsApi, + ...uiTabAndBrowserCommandsApi, + ...uiTerminalAndSessionTabsApi, + ...uiClipboardAndWindowControlsApi } satisfies PreloadApi['ui'] diff --git a/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx index 3d8bad1ed6d..1c03a15fe49 100644 --- a/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx +++ b/src/renderer/src/components/TaskPageGitHubAssigneesCell.tsx @@ -19,6 +19,7 @@ import { cn } from '@/lib/utils' import { LoaderCircle, ChevronDown, Check } from 'lucide-react' import type { TaskPageGitHubWorkItemMutationRunner } from './task-page-linear-jira-list-model' import { GitHubAssigneeAvatar } from './TaskPageGitHubAvatars' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' export function GHAssigneesCell({ item, repo, @@ -270,13 +271,12 @@ export function GHAssigneesCell({ ) : null} - {user.avatarUrl ? ( - - ) : ( - - {user.login.slice(0, 1).toUpperCase()} - - )} + {user.login} {user.name ? ( diff --git a/src/renderer/src/components/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx index 14b8bacaee8..26162f1ceb6 100644 --- a/src/renderer/src/components/TaskPageGitHubAvatars.tsx +++ b/src/renderer/src/components/TaskPageGitHubAvatars.tsx @@ -31,24 +31,13 @@ export function GitHubAssigneeAvatar({ }: { assignee: GitHubAssignableUser }): React.JSX.Element { - if (assignee.avatarUrl) { - return ( - {assignee.login} - ) - } return ( - - {assignee.login.slice(0, 1).toUpperCase()} - + ) } diff --git a/src/renderer/src/components/TaskPageGitHubList.tsx b/src/renderer/src/components/TaskPageGitHubList.tsx index aa53374ffa7..569db75502a 100644 --- a/src/renderer/src/components/TaskPageGitHubList.tsx +++ b/src/renderer/src/components/TaskPageGitHubList.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' import { LoaderCircle } from 'lucide-react' import { TaskPageGitHubRows } from './TaskPageGitHubRows' import { PaginationBar } from './TaskPagePaginationBar' +import { supersedeGitHubListScrollRestore } from './task-page-github-list-scroll-restore' export function TaskPageGitHubList({ model }: { @@ -33,6 +34,7 @@ export function TaskPageGitHubList({ githubListScrollRef, githubListScrollTopRef, pendingGithubScrollRestoreRef, + githubListRestoreWriteRef, loadingTargetPage, taskListPositionRef, perRepoSourceState, @@ -61,14 +63,21 @@ export function TaskPageGitHubList({ }} onScroll={(event) => { const state = useAppStore.getState() - if ( - state.activeView !== 'tasks' || - state.taskPageData.openGitHubWorkItem || - pendingGithubScrollRestoreRef.current !== null - ) { + if (state.activeView !== 'tasks' || state.taskPageData.openGitHubWorkItem) { return } const scrollTop = event.currentTarget.scrollTop + // Why: a restore's own write must not be saved as the user's position, but a real + // user scroll has to supersede a pending restore or saving stays suppressed. + if ( + !supersedeGitHubListScrollRestore({ + scrollTop, + pendingRestoreRef: pendingGithubScrollRestoreRef, + restoreWriteRef: githubListRestoreWriteRef + }) + ) { + return + } githubListScrollTopRef.current = scrollTop taskListPositionRef.current = { contextKey: githubResumeContextKey, diff --git a/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx index 2a18fcf15ad..d61d669c478 100644 --- a/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx +++ b/src/renderer/src/components/TaskPageGitHubReviewerPicker.tsx @@ -8,6 +8,7 @@ import { PopoverContent } from '@/components/ui/popover' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { Repo } from '../../../shared/repo-types' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' type ReviewerPickerProps = { actionableReviewerRows: GitHubAssignableUser[] @@ -81,13 +82,12 @@ export function TaskPageGitHubReviewerPicker({ {selected ? : null} - {reviewer.avatarUrl ? ( - - ) : ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - )} + {reviewer.login} diff --git a/src/renderer/src/components/TaskPageGitLabItemList.tsx b/src/renderer/src/components/TaskPageGitLabItemList.tsx index 5799c131e8a..31c279fe8aa 100644 --- a/src/renderer/src/components/TaskPageGitLabItemList.tsx +++ b/src/renderer/src/components/TaskPageGitLabItemList.tsx @@ -71,12 +71,15 @@ export function TaskPageGitLabItemList({
{ useAppStore.getState().recordFeatureInteraction('gitlab-tasks') openGitLabDetailPage(item) }} onKeyDown={(e) => { + if (e.target !== e.currentTarget) { + return + } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() useAppStore.getState().recordFeatureInteraction('gitlab-tasks') diff --git a/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx b/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx index 3e7862d432a..ba5dda2f65b 100644 --- a/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx +++ b/src/renderer/src/components/TaskPageLinearIssueAttributes.tsx @@ -56,7 +56,7 @@ export function TaskPageLinearIssueAttributes({ - +
{translate('auto.components.TaskPage.154b0fa623', 'Status')}
@@ -65,7 +65,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
{newLinearStates.data.map((s) => ( - +
{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
@@ -137,7 +137,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
- +
{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}
@@ -263,7 +263,7 @@ export function TaskPageLinearIssueAttributes({ - +
{translate('auto.components.TaskPage.00022ec0ba', 'Project')}
@@ -272,7 +272,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
- +
{translate('auto.components.TaskPage.d0ca4aa1d0', 'Labels')}
@@ -332,7 +332,7 @@ export function TaskPageLinearIssueAttributes({
) : ( -
+
{newLinearLabels.data.map((l) => { const isSelected = newLinearIssueLabelIds.includes(l.id) return ( diff --git a/src/renderer/src/components/TaskPageLinearIssueDialog.tsx b/src/renderer/src/components/TaskPageLinearIssueDialog.tsx index 15edf732046..f0c5198ee47 100644 --- a/src/renderer/src/components/TaskPageLinearIssueDialog.tsx +++ b/src/renderer/src/components/TaskPageLinearIssueDialog.tsx @@ -65,7 +65,10 @@ export function TaskPageLinearIssueDialog({ - +
{translate('auto.components.TaskPage.4f3cb99f41', 'Switch Team')}
diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index b6842fd95fe..5c3145360f6 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,3 +1,4 @@ +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' import type { TerminalController } from './use-terminal-controller' @@ -22,12 +23,22 @@ export function TerminalSplitWorkspaceSurfaces({ renderedActiveWorktreeId, workspaceSurfaces } = controller + // Why: this and TerminalSurface are both strict ancestors of every browser , so a + // remote controller needs each to drop `hidden` — the per-worktree surface hatch below cannot + // override an ancestor that stopped compositing. + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!effectiveActiveLayout) if (!anyMountedWorktreeHasLayout) { return null } return (
{workspaceSurfaces .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 66553037b42..023f19f7311 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -1,4 +1,5 @@ import EditorAutosaveController from './editor/EditorAutosaveController' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces' import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' @@ -11,10 +12,17 @@ export function TerminalSurface({ controller: TerminalController }): React.JSX.Element { const { renderedActiveWorktreeId } = controller + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!renderedActiveWorktreeId) return (
diff --git a/src/renderer/src/components/TerminalTitlebarTabs.test.tsx b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx new file mode 100644 index 00000000000..e4f4162c97a --- /dev/null +++ b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientHostedBrowserRow } from '../../../shared/client-hosted-browser-rows' +import { + applyClientHostedBrowserRows, + getClientHostedBrowserRows +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' +import type { TerminalController } from './use-terminal-controller' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + tabBarProps: [] as Record[] +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('./tab-bar/TabBar', () => ({ + default: (props: Record) => { + mocks.tabBarProps.push(props) + return null + } +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' +const ROW: ClientHostedBrowserRow = { + browserPageId: 'page-1', + title: 'Client page', + url: 'https://example.com' +} as ClientHostedBrowserRow + +let titlebarTarget: HTMLElement +let container: HTMLElement + +function renderTitlebarTabs(): void { + const controller = { + activeBrowserTabId: null, + activeFileId: null, + activeTabId: null, + activeTabType: 'terminal', + effectiveActiveLayout: null, + expandedPaneByTabId: {}, + handleActivateBrowserTab: vi.fn(), + handleActivateTab: vi.fn(), + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleCloseOthers: vi.fn(), + handleCloseTab: vi.fn(), + handleCloseTabsToLeft: vi.fn(), + handleCloseTabsToRight: vi.fn(), + handleDuplicateBrowserTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + handleOpenEntry: vi.fn(), + handleTogglePaneExpand: vi.fn(), + makePreviewFilePermanent: vi.fn(), + mobileEmulatorEnabled: false, + pinFile: vi.fn(), + renderedActiveWorktreeId: WORKTREE_ID, + setActiveFile: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabColor: vi.fn(), + setTabCustomTitle: vi.fn(), + tabBarOrder: [], + titlebarTabsTarget: titlebarTarget, + worktreeBrowserTabs: [], + // Mirrors the projection hook's derivation so the assertion follows the real row store. + worktreeClientHostedBrowserRows: getClientHostedBrowserRows(WORKTREE_ID), + worktreeFiles: [] + } as unknown as TerminalController + const root = createRoot(container) + act(() => root.render()) + act(() => root.unmount()) +} + +describe('TerminalTitlebarTabs', () => { + beforeEach(() => { + mocks.tabBarProps = [] + mocks.state = { tabsByWorktree: {}, unifiedTabsByWorktree: {}, getActiveTab: () => null } + titlebarTarget = document.createElement('div') + container = document.createElement('div') + document.body.append(titlebarTarget, container) + }) + + afterEach(() => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [] }) + titlebarTarget.remove() + container.remove() + }) + + it('forwards client-hosted browser rows to the titlebar tab bar', () => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [ROW] }) + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([ROW]) + }) + + it('passes no rows when the worktree has none', () => { + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/TerminalTitlebarTabs.tsx b/src/renderer/src/components/TerminalTitlebarTabs.tsx index a275da9d801..c2a5d8218e4 100644 --- a/src/renderer/src/components/TerminalTitlebarTabs.tsx +++ b/src/renderer/src/components/TerminalTitlebarTabs.tsx @@ -1,8 +1,20 @@ import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import TabBar from './tab-bar/TabBar' import type { TerminalController } from './use-terminal-controller' +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] + +// Why: keeps title-only tab updates a leaf subscription so the Terminal root, +// which reads the topology projection, does not re-render on every rename. +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} + export function TerminalTitlebarTabs({ controller }: { @@ -41,17 +53,16 @@ export function TerminalTitlebarTabs({ setTabColor, setTabCustomTitle, tabBarOrder, - tabs, titlebarTabsTarget, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, worktreeFiles } = controller if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) { return null } return createPortal( - ([ ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], + // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree + // surface hatch below cannot rescue a guest either one parked with `hidden`. + ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], + ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], [ 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 9842a75f7d7..a7c915cd6c6 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -75,7 +75,7 @@ describe('feature interaction writer boundaries', () => { ).toContain(githubWriter) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleOpenOrUseGitHubWorkItem', 'const openComposerForGitLabItem' ) @@ -84,25 +84,25 @@ describe('feature interaction writer boundaries', () => { it('threads GitHub task source context through inline task mutations', () => { const sections = [ - componentSource('task-page/github/github-status-cell.tsx'), - componentSource('task-page/github/github-assignees-cell.tsx'), - componentSource('task-page/github/pr-review-cell.tsx'), - componentSource('task-page/github/pr-merge-cell.tsx'), + componentSource('TaskPageGitHubStatusCell.tsx'), + componentSource('TaskPageGitHubAssigneesCell.tsx'), + componentSource('TaskPageGitHubReviewCell.tsx'), + componentSource('TaskPageGitHubMergeCell.tsx'), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-github-submit.ts'), + componentSource('use-task-page-github-issue-creation.ts'), 'const handleCreateNewIssue', - 'return { handleCreateNewIssue }' + 'const nextModel' ) ] for (const section of sections) { expect(section).toContain('sourceContext') } - const rowSource = componentSource('task-page/github/github-work-item-row.tsx') - expect(rowSource).toContain( - "const rowSourceContext = getTaskPageRepoSourceContext(itemRepo, 'github')" - ) - expect(rowSource).toContain('sourceContext={rowSourceContext}') + const rowSource = componentSource('TaskPageGitHubRows.tsx') + // Rows inline the repo lookup per cell rather than hoisting one const. + expect( + rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) + ).toHaveLength(4) }) it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { @@ -120,7 +120,7 @@ describe('feature interaction writer boundaries', () => { 'const openRelatedLinearIssue' ), sourceBetween( - componentSource('task-page/chrome/task-page-source-toolbar.tsx'), + componentSource('TaskPageSourceBar.tsx'), 'taskSourceManuallyChangedRef.current = true', 'void updateSettings' ) @@ -165,16 +165,16 @@ describe('feature interaction writer boundaries', () => { expect( sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), + componentSource('TaskPageGitLabItemList.tsx'), '{displayedGitLabItems.map((item) => (', 'handleUseGitLabItem(item)' ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) ).toHaveLength(2) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleUseGitLabItem', - 'return {' + 'const nextModel' ) ).toContain(gitlabWriter) @@ -200,18 +200,18 @@ describe('feature interaction writer boundaries', () => { it('keeps nested GitLab row actions from also opening task details by keyboard', () => { const rowSection = sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), - 'onKeyDown={(event) => {', + componentSource('TaskPageGitLabItemList.tsx'), + 'onKeyDown={(e) => {', 'className="grid w-full cursor-pointer' ) - expect(rowSection).toContain('event.target !== event.currentTarget') - expect(rowSection.indexOf('event.target !== event.currentTarget')).toBeLessThan( - rowSection.indexOf("event.key === 'Enter'") + expect(rowSection).toContain('e.target !== e.currentTarget') + expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( + rowSection.indexOf("e.key === 'Enter'") ) }) it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/gitlab-work-item-list.tsx')).toContain( + expect(componentSource('TaskPageGitLabItemList.tsx')).toContain( 'key={`${item.repoId}:${item.id}`}' ) }) @@ -225,22 +225,22 @@ describe('feature interaction writer boundaries', () => { const taskPageSections = [ sourceBetween( - componentSource('task-page/linear/linear-state-cell.tsx'), + componentSource('task-page-linear-issue-model.tsx'), 'export function LinearStateCell', 'return (' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-board.tsx'), + componentSource('use-task-page-linear-board.ts'), 'const handleLinearBoardDrop', 'const toggleLinearDisplayProperty' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-linear-submits.tsx'), + componentSource('use-task-page-linear-issue-creation.ts'), 'const handleCreateNewLinearIssue', - 'return {' + 'const nextModel' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseLinearItem', 'const handleLinearWorkspaceChange' ) @@ -270,9 +270,9 @@ describe('feature interaction writer boundaries', () => { // marker) no longer exists in TaskPage. expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-jira-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseJiraItem', - 'return {' + 'const nextModel' ) ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 781948111c1..a60cdc7d93e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { detectLanguage } from '@/lib/language-detect' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -74,6 +75,9 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { + return + } const url = browserDefaultUrl ?? 'about:blank' createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate( diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index 67e0bc79db3..b597810c0fd 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { matchFloatingWorkspacePanelOwnedAction, matchFloatingWorkspacePanelShortcut @@ -7,6 +8,7 @@ import { import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' import type { FloatingPanelShortcutInput, @@ -123,6 +125,11 @@ export function useFloatingTerminalPanelShortcuts({ if (resolution.action === 'tab.newTerminal') { createFloatingTerminalTab() } else if (resolution.action === 'tab.newBrowser') { + if ( + !ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser') + ) { + return 'handled' + } createFloatingBrowserTab() } else if (resolution.action === 'tab.newMarkdown') { createFloatingMarkdownTab() diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index aa90a447780..deeda3ef506 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -16,8 +16,8 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHub Enterprise slug routing boundaries', () => { it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('task-page/github/github-status-cell.tsx') - const assigneeSection = componentSource('task-page/github/github-assignees-cell.tsx') + const statusSection = componentSource('TaskPageGitHubStatusCell.tsx') + const assigneeSection = componentSource('TaskPageGitHubAssigneesCell.tsx') expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') expect(assigneeSection).toContain('parsed?.slug.host') @@ -25,11 +25,13 @@ describe('GitHub Enterprise slug routing boundaries', () => { }) it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('task-page/github/pr-review-cell.tsx') - const mergeSection = componentSource('task-page/github/pr-merge-cell.tsx') + const reviewSection = componentSource('TaskPageGitHubReviewCell.tsx') + const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') + const mergeSection = componentSource('TaskPageGitHubMergeCell.tsx') expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') - expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) + expect(reviewSection).toContain('reviewRepo,') + expect(reviewActionsSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) expect(mergeSection).toContain('const prRepo = resolveTaskPullRequestRepo(item)') expect(mergeSection).not.toContain('prRepo: item.prRepo ?? null') }) diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index c48cd53071a..8e3b831020d 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -47,7 +47,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) const reviewChip = readFileSync( - join(__dirname, '../task-page/github/github-assignee-avatars.tsx'), + join(__dirname, '../TaskPageGitHubAvatars.tsx'), 'utf8' ) expect(reviewChip).toMatch(/GitHubUserAvatar/) @@ -77,20 +77,20 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { displayName: 'option.name' }, { - file: 'task-page/github/github-assignee-avatars.tsx', + file: 'TaskPageGitHubAvatars.tsx', fn: 'GitHubAssigneeAvatar', login: 'assignee.login', displayName: 'assignee.name' }, { - file: 'task-page/github/github-assignees-cell.tsx', + file: 'TaskPageGitHubAssigneesCell.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, { - file: 'task-page/github/pr-review-picker-panel.tsx', - fn: 'PRReviewPickerPanel', + file: 'TaskPageGitHubReviewerPicker.tsx', + fn: 'TaskPageGitHubReviewerPicker', login: 'reviewer.login', displayName: 'reviewer.name' } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index e1d58f70ded..4382cd1aeb0 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -21,23 +21,6 @@ const COPY_SOURCE = readSource('smart-workspace-name-field-copy.ts') const INPUT_SOURCE = readSource('smart-workspace-name-input-surface.tsx') const SURFACE_SOURCE = readSource('smart-workspace-name-field-surface.tsx') const DIALOG_SOURCE = readSource('smart-workspace-cross-repo-dialog.tsx') -const FIELD_SOURCES = [ - MODEL_SOURCE, - CONTROLLER_SOURCE, - FOUNDATION_SOURCE, - AVAILABILITY_SOURCE, - FOCUS_SOURCE, - STATE_SOURCE, - GITHUB_SOURCE, - GITLAB_SOURCE, - SECONDARY_SEARCH_SOURCE, - ACTIONS_SOURCE, - PRESENTATION_SOURCE, - COPY_SOURCE, - INPUT_SOURCE, - SURFACE_SOURCE, - DIALOG_SOURCE -].join('\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -72,7 +55,7 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(availableModesSection).toContain("item.id === 'jira'") expect(availableModesSection).toContain('return jiraSourceConnected') expect(availableModesSection).toContain('branchesEnabled && !repoBackedSourcesDisabled') - expect(FIELD_SOURCES).toContain('repoBackedSourcesDisabled') + expect(CONTROLLER_SOURCE).toContain('repoBackedSourcesDisabled') expect(CONTROLLER_SOURCE).toContain('foundation.gitlabSourceAvailable') const jiraLookupSection = sourceBetween( @@ -107,8 +90,13 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { }) it('searches repo-backed task sources through implicit repo targets instead of a menu', () => { - expect(FIELD_SOURCES).not.toContain('RepoBackedSourceMenu') - expect(FIELD_SOURCES).not.toContain('repoBackedSourceOptions') + // The menu declared a prop, derived a visibility flag, and rendered a control; implicit repo + // targets replaced all three, so each former host is pinned separately. + expect(MODEL_SOURCE).not.toContain('repoBackedSourceOptions') + expect(CONTROLLER_SOURCE).not.toContain('repoBackedSourceOptions') + expect(FOUNDATION_SOURCE).not.toContain('repoBackedSourceOptions') + expect(SURFACE_SOURCE).not.toContain('RepoBackedSourceMenu') + expect(INPUT_SOURCE).not.toContain('RepoBackedSourceMenu') expect(MODEL_SOURCE).toContain('repoBackedSearchRepos?: readonly RepoOption[]') const targetSection = sourceBetween( @@ -125,26 +113,27 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(CONTROLLER_SOURCE).toContain('foundation.repoBackedSearchTargets.length > 0') expect(GITHUB_SOURCE).toContain('fetchWorkItemsAcrossRepos') expect(GITHUB_SOURCE).toContain('repoBackedSearchTargets.map') + expect(GITLAB_SOURCE).toContain('repoBackedSearchTargets.map') }) it('does not fan decisive Linear URLs out to unrelated providers', () => { const githubGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGithub =', 'const shouldQueryLinear =' ) const branchGate = sourceBetween( - FIELD_SOURCES, + SECONDARY_SEARCH_SOURCE, 'const branchSearchRequest = useMemo', 'useEffect(() => {\n if (!branchSearchRequest)' ) const gitlabGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGitlab =', 'useSmartWorkspaceGitlabSearch({' ) - expect(FIELD_SOURCES).toContain( + expect(PRESENTATION_SOURCE).toContain( "linearUrlIntent !== null && (mode === 'smart' || mode === 'linear')" ) expect(githubGate).toContain('!linearUrlIntentOwnsInput') diff --git a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx b/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx deleted file mode 100644 index 6f74cba2d5e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' -import { translate } from '@/i18n/i18n' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} -
- - -
-
- ) -} - -export function ResourceUsageAppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')} - -
- - - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx b/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx deleted file mode 100644 index fcb6beb6f2e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function ResourceUsageKillDialog({ - foundation, - actions -}: { - foundation: ResourceUsageFoundation - actions: ResourceUsageActions -}): React.JSX.Element { - const { killConfirm, killing, setKillConfirm } = foundation - const { runKillConfirmed } = actions - - return ( - { - if (next) { - return - } - if (killing) { - return - } - setKillConfirm(null) - }} - > - { - if (killing) { - event.preventDefault() - } - }} - onEscapeKeyDown={(event) => { - if (killing) { - event.preventDefault() - } - }} - > - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0', - 'Kill {{value0}}?', - { - value0: - killConfirm?.label ?? - translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80', - 'this session' - ) - } - )} - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49', - "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone." - )} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx b/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx deleted file mode 100644 index 6353c41b761..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { memo, useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { Metric } from './resource-usage-merge-types' - -export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -export const CPU_COLUMN_CLS = 'w-12 text-right' -export const MEM_COLUMN_CLS = 'w-16 text-right' -export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' - -export function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -export function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatMetricCpu(value: Metric): string { - return value === null ? '—' : formatCpu(value) -} - -function formatMetricMemory(value: Metric): string { - return value === null ? '—' : formatMemory(value) -} - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const value of safe) { - if (value < min) { - min = value - } - if (value > max) { - max = value - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - const out: string[] = [] - for (let index = 0; index < safe.length; index++) { - const x = (index * stepX).toFixed(1) - const y = (height - ((safe[index] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -export const ResourceUsageSparkline = memo(SparklineImpl, (left, right) => { - if (left.width !== right.width || left.height !== right.height) { - return false - } - const leftSamples = Array.isArray(left.samples) ? left.samples : [] - const rightSamples = Array.isArray(right.samples) ? right.samples : [] - if (leftSamples === rightSamples) { - return true - } - if (leftSamples.length !== rightSamples.length) { - return false - } - for (let index = 0; index < leftSamples.length; index++) { - if (leftSamples[index] !== rightSamples[index]) { - return false - } - } - return true -}) - -export function ResourceUsageMetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: Metric - memory: Metric - size?: 'base' | 'small' -}): React.JSX.Element { - const textClassName = size === 'small' ? 'text-[11px]' : 'text-xs' - const muted = cpu === null && memory === null - return ( -
- {formatMetricCpu(cpu)} - {formatMetricMemory(memory)} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx b/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx deleted file mode 100644 index 64ca6049363..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react' -import { AlertTriangle, ChevronRight, MemoryStick, RotateCw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { PopoverContent } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' -import { ResourceUsageAppSection } from './ResourceUsageAppSection' -import { - CPU_COLUMN_CLS, - formatCpu, - formatMemory, - MEM_COLUMN_CLS, - METRIC_COLUMNS_CLS, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' -import { ResourceUsageTree } from './ResourceUsageTree' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsagePopoverContent({ - foundation, - projection, - actions -}: { - foundation: ResourceUsageFoundation - projection: ResourceUsageProjection - actions: ResourceUsageActions -}): React.JSX.Element { - const { - sortOption, - setSortOption, - daemonActions, - resourceSnapshot, - setPopoverBodyNode, - collapsedRepos, - collapsedWorktrees, - activeWorktreeId, - appCollapsed, - setAppCollapsed - } = foundation - const { - daemonUnreachable, - sessionsOnlyError, - totalCpu, - totalMemory, - memoryMetricCopy, - orphanCount, - unifiedRepos - } = projection - const { - toggleRepo, - toggleWorktree, - navigateToWorktree, - navigateToTab, - deleteWorktree, - handleKillSession, - handleOpenWorkspaceCleanup, - handleKillOrphans, - openSpaceResults - } = actions - - return ( - event.preventDefault()} - // Why: xterm focus must not dismiss the resource manager after tab activation. - onFocusOutside={(event) => event.preventDefault()} - > -
-
- - - {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')} - -
- -
- - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} - - - - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} - - -
-
- - {daemonUnreachable && ( -
- -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4', - 'Daemon is not responding' - )} -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6', - 'Resource snapshots and terminal sessions are unavailable.' - )} -
-
- -
- )} - - {!daemonUnreachable && sessionsOnlyError && ( -
- - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78', - 'Terminal sessions unavailable. The list may be stale.' - )} - -
- )} - - {resourceSnapshot && ( -
-
- - - - {formatCpu(totalCpu)} - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae', - 'Combined CPU load. Values above 100% mean more than one core is working at once.' - )} - - - · - - - - {formatMemory(totalMemory)}{' '} - - {memoryMetricCopy.summaryLabel} - - - - - {memoryMetricCopy.description} - - -
- {orphanCount > 0 && ( - - {orphanCount === 1 - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31', - '{{value0}} orphan', - { value0: orphanCount } - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3', - '{{value0}} orphans', - { value0: orphanCount } - )} - - )} -
- )} - - {/* Why: fixed height prevents list expansion and polling from moving the popover. */} -
- {(unifiedRepos.length > 0 || resourceSnapshot) && ( -
- -
-
- - -
- -
-
- )} - -
- {unifiedRepos.length > 0 && ( - - )} - - {unifiedRepos.length === 0 && resourceSnapshot && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0', - 'Nothing running right now' - )} -
- )} - - {resourceSnapshot && ( - setAppCollapsed((value) => !value)} - /> - )} - - {!resourceSnapshot && !daemonUnreachable && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} -
- )} -
-
- -
- - {orphanCount > 0 ? ( - - ) : null} -
- - -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx b/src/renderer/src/components/status-bar/ResourceUsageTree.tsx deleted file mode 100644 index 3914bab48df..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { useWorktreeMap } from '../../store/selectors' -import { translate } from '@/i18n/i18n' -import type { - UnifiedProjectGroup, - UnifiedSessionRow, - UnifiedWorktreeRow -} from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { sortResourceUsageProjectGroups, sortResourceUsageWorktrees } from './resource-usage-sort' -import { ResourceUsageMetricPair, ROW_TRAILING_GUTTER_CLS } from './ResourceUsageMetrics' -import { ResourceUsageWorktreeRow } from './ResourceUsageWorktreeRow' - -export function ResourceUsageTree({ - repos, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - activeWorktreeId, - toggleWorktree, - navigateToWorktree, - navigateToTab, - onDelete, - onKillSession -}: { - repos: UnifiedProjectGroup[] - sortOption: ResourceUsageSortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - activeWorktreeId: string | null - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string, paneKey: string | null) => void - onDelete: (worktreeId: string) => void - onKillSession: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const worktreeById = useWorktreeMap() - const sortedRepos = useMemo(() => { - const grouped = sortResourceUsageProjectGroups(repos, sortOption) - return grouped.map((repo) => ({ - ...repo, - worktrees: sortResourceUsageWorktrees(repo.worktrees, sortOption) - })) - }, [repos, sortOption]) - const renderWorktree = (worktree: UnifiedWorktreeRow): React.JSX.Element => { - const storeRecord = worktreeById.get(worktree.worktreeId) ?? null - return ( - toggleWorktree(worktree.worktreeId)} - onNavigate={() => navigateToWorktree(worktree.worktreeId)} - onDelete={() => onDelete(worktree.worktreeId)} - onKillSession={onKillSession} - navigateToTab={navigateToTab} - /> - ) - } - - if (sortedRepos.length === 1) { - return <>{sortedRepos[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {sortedRepos.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - - {group.repoName} - - {group.hasRemoteChildren && ( - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} - - )} - -
- - -
-
-
- {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx b/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx deleted file mode 100644 index 567be1d8a4b..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react' -import { PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsageTrigger({ - iconOnly, - spaceScanReady, - projection -}: { - iconOnly: boolean - spaceScanReady: boolean - projection: ResourceUsageProjection -}): React.JSX.Element { - const { - daemonUnreachable, - resourceManagerAriaLabel, - memBadgeLabel, - triggerSessionCount, - orphanCount, - resourceManagerTooltipLines - } = projection - - return ( - - - - - - - -
- {resourceManagerTooltipLines.map((line) => ( -
- {line.text} -
- ))} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx b/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx deleted file mode 100644 index 741863d11f1..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' -import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' -import { isResourceSessionActivationKey } from './resource-session-navigation' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -export function ResourceUsageSessionRow({ - session, - worktreeId, - onNavigate, - onKill -}: { - session: UnifiedSessionRow - worktreeId: string - onNavigate: (tabId: string, paneKey: string | null) => void - onKill: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const clickable = session.tabId !== null && session.bound - const handleClick = (): void => { - if (clickable && session.tabId) { - onNavigate(session.tabId, session.paneKey) - } - } - - return ( -
{ - if (isResourceSessionActivationKey(event.key)) { - event.preventDefault() - handleClick() - } - } - : undefined - } - data-worktree-id={worktreeId} - > - - - {session.label} - - - {/* Why: the shared gutter aligns columns while keeping orphan kills visible. */} - - - -
- ) -} - -function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { - const label = browser.title?.trim() || browser.label?.trim() || browser.url - return ( -
- - {label} - - -
- ) -} - -export function ResourceUsageWorktreeRow({ - worktree, - storeRecord, - activeWorktreeId, - isCollapsed, - onToggle, - onNavigate, - onDelete, - onKillSession, - navigateToTab -}: { - worktree: UnifiedWorktreeRow - storeRecord: Worktree | null - activeWorktreeId: string | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onDelete: () => void - onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string, paneKey: string | null) => void -}): React.JSX.Element { - const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 - const isSynthetic = - worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID - const isNavigable = !isSynthetic - const showWorktreeActions = - !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId - const isMainWorktree = storeRecord?.isMainWorktree ?? false - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasResources ? ( - - ) : ( - - )} - -
-
- - - - {showWorktreeActions && ( -
- - - - - - {isMainWorktree - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a', - 'The main workspace cannot be deleted.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458', - 'Delete workspace.' - )} - - -
- )} -
- - -
-
- {!isCollapsed && - worktree.sessions.map((session) => ( - - ))} - {!isCollapsed && - worktree.browsers.map((browser) => )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/resource-usage-sort.ts b/src/renderer/src/components/status-bar/resource-usage-sort.ts deleted file mode 100644 index e66e5c8e458..00000000000 --- a/src/renderer/src/components/status-bar/resource-usage-sort.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metric, UnifiedProjectGroup, UnifiedWorktreeRow } from './resource-usage-merge-types' - -export type ResourceUsageSortOption = 'memory' | 'cpu' | 'name' - -function compareMetricDesc(left: Metric, right: Metric): number { - // Why: remote null metrics stay behind sampled rows for every sort direction. - if (left === null && right === null) { - return 0 - } - if (left === null) { - return 1 - } - if (right === null) { - return -1 - } - return right - left -} - -export function sortResourceUsageWorktrees( - list: UnifiedWorktreeRow[], - sort: ResourceUsageSortOption -): UnifiedWorktreeRow[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.worktreeName.localeCompare(right.worktreeName)) - } - return copy -} - -export function sortResourceUsageProjectGroups( - groups: UnifiedProjectGroup[], - sort: ResourceUsageSortOption -): UnifiedProjectGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.repoName.localeCompare(right.repoName)) - } - return copy -} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts b/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts deleted file mode 100644 index bfb0314cfca..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useAppStore } from '../../store' -import { useDaemonActions } from '../shared/useDaemonActions' -import type { UnifiedSessionRow } from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { - getResourceUsageAllWorktrees, - getResourceUsageBrowserTabsByWorktree, - getResourceUsageDeferredSshSessionIdsByTabId, - getResourceUsagePtyIdsByTabId, - getResourceUsageRepos, - getResourceUsageRuntimePaneTitlesByTabId, - getResourceUsageTerminalLayoutsByTabId, - getResourceUsageTabsByWorktree -} from './resource-usage-open-slices' -import { - resolveResourceUsageSpaceScanReady, - type ResourceUsageSpaceScanSnapshot -} from './resource-usage-space-scan-ready' -import type { ResourceSessionBindingInputs } from './resource-session-bindings' -import { useResourceSessionInventory } from './use-resource-session-inventory' - -const POLL_MS = 2_000 - -export function useResourceUsageFoundation() { - const snapshot = useAppStore((state) => state.memorySnapshot) - const memorySnapshotError = useAppStore((state) => state.memorySnapshotError) - const fetchSnapshot = useAppStore((state) => state.fetchMemorySnapshot) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const setActiveView = useAppStore((state) => state.setActiveView) - const openModal = useAppStore((state) => state.openModal) - const openSpacePage = useAppStore((state) => state.openSpacePage) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - const activeView = useAppStore((state) => state.activeView) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const workspaceSpaceScannedAt = useAppStore( - (state) => state.workspaceSpaceAnalysis?.scannedAt ?? null - ) - const workspaceSpaceScanning = useAppStore((state) => state.workspaceSpaceScanning) - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - const [appCollapsed, setAppCollapsed] = useState(true) - const { - sessionInventory, - sessionsError, - refreshSessions, - clearSessionsError, - removeSession, - removeSessions - } = useResourceSessionInventory(workspaceSessionReady) - const sessions = sessionInventory.sessions - const [killConfirm, setKillConfirm] = useState(null) - const [killing, setKilling] = useState(false) - const [spaceScanSnapshot, setSpaceScanSnapshot] = useState( - () => ({ - ready: false, - previousScanning: workspaceSpaceScanning, - lastSeenScannedAt: workspaceSpaceScannedAt - }) - ) - // Why: title and binding maps churn; the closed trigger selects stable sentinels. - const runtimePaneTitlesByTabId = useAppStore((state) => - getResourceUsageRuntimePaneTitlesByTabId(state, open) - ) - const repos = useAppStore((state) => getResourceUsageRepos(state, open)) - const allWorktrees = useAppStore((state) => getResourceUsageAllWorktrees(state, open)) - const tabsByWorktree = useAppStore((state) => getResourceUsageTabsByWorktree(state, open)) - const browserTabsByWorktree = useAppStore((state) => - getResourceUsageBrowserTabsByWorktree(state, open) - ) - const ptyIdsByTabId = useAppStore((state) => getResourceUsagePtyIdsByTabId(state, open)) - const terminalLayoutsByTabId = useAppStore((state) => - getResourceUsageTerminalLayoutsByTabId(state, open) - ) - const deferredSshSessionIdsByTabId = useAppStore((state) => - getResourceUsageDeferredSshSessionIdsByTabId(state, open) - ) - const resourceSnapshot = snapshot - const resourceSessionBindings = useMemo( - () => ({ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - }), - [ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - ] - ) - const popoverBodyRef = useRef(null) - const popoverBodyFocusFrameRef = useRef(null) - const mountedRef = useMountedRef() - const cancelPopoverBodyFocusFrame = useCallback((): void => { - if (popoverBodyFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(popoverBodyFocusFrameRef.current) - popoverBodyFocusFrameRef.current = null - }, []) - const setPopoverBodyNode = useCallback( - (node: HTMLDivElement | null): void => { - if (!node) { - cancelPopoverBodyFocusFrame() - } - popoverBodyRef.current = node - }, - [cancelPopoverBodyFocusFrame] - ) - const daemonActions = useDaemonActions({ - onRestartSettled: () => { - clearSessionsError() - void fetchSnapshot() - void refreshSessions() - } - }) - const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ - snapshot: spaceScanSnapshot, - open, - activeView, - scannedAt: workspaceSpaceScannedAt, - scanning: workspaceSpaceScanning - }) - if ( - nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready || - nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning || - nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt - ) { - setSpaceScanSnapshot(nextSpaceScanSnapshot) - } - const spaceScanReady = nextSpaceScanSnapshot.ready - - // Why: seed RAM after session restore so the closed badge does not require a click. - useEffect(() => { - if (workspaceSessionReady) { - void fetchSnapshot() - } - }, [workspaceSessionReady, fetchSnapshot]) - - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - void refreshSessions() - const memTimer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => { - window.clearInterval(memTimer) - } - }, [open, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (!open) { - clearSessionsError() - } - }, [open, clearSessionsError]) - - return { - snapshot, - memorySnapshotError, - workspaceSessionReady, - setActiveView, - openModal, - openSpacePage, - recordFeatureInteraction, - activeWorktreeId, - open, - setOpen, - sortOption, - setSortOption, - collapsedRepos, - setCollapsedRepos, - collapsedWorktrees, - setCollapsedWorktrees, - appCollapsed, - setAppCollapsed, - sessionInventory, - sessionsError, - refreshSessions, - removeSession, - removeSessions, - sessions, - killConfirm, - setKillConfirm, - killing, - setKilling, - runtimePaneTitlesByTabId, - repos, - allWorktrees, - tabsByWorktree, - browserTabsByWorktree, - resourceSnapshot, - resourceSessionBindings, - popoverBodyRef, - popoverBodyFocusFrameRef, - mountedRef, - cancelPopoverBodyFocusFrame, - setPopoverBodyNode, - daemonActions, - spaceScanReady - } -} - -export type ResourceUsageFoundation = ReturnType diff --git a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts b/src/renderer/src/components/status-bar/use-resource-usage-projection.ts deleted file mode 100644 index 5b8145d58e0..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' -import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import { countUnboundDaemonSessions } from './resource-session-bindings' -import { - getResourceManagerAriaLabel, - getResourceManagerTooltipLines -} from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' -import { formatMemory } from './ResourceUsageMetrics' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function useResourceUsageProjection(foundation: ResourceUsageFoundation) { - const { - repos, - allWorktrees, - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - browserTabsByWorktree, - workspaceSessionReady, - sessionInventory, - sessionsError, - memorySnapshotError, - snapshot, - spaceScanReady - } = foundation - - const repoDisplayNameById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const display = repo.displayName?.trim() - if (display) { - map.set(repo.id, display) - } - } - return map - }, [repos]) - - // Why: connectionId is the only honest signal that a repo runs over SSH. - const repoConnectionIdById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - map.set(repo.id, repo.connectionId ?? null) - } - return map - }, [repos]) - - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') - } - return map - }, [repos]) - - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) - const worktreeById = useMemo( - () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), - [allWorktrees] - ) - const [oldWorkspaceCount, setOldWorkspaceCount] = useState(0) - useEffect(() => { - setOldWorkspaceCount(countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now())) - }, [allWorktrees, repoById]) - - // Why: the closed segment must not merge on keystroke-driven store updates. - const unifiedRepos = useMemo( - () => - open - ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { - ...resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - }) - : [], - [ - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - ] - ) - - const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady) { - return 0 - } - return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - - const triggerSessionCount = sessionInventory.count - const memoryMetricCopy = getResourceMemoryMetricCopy( - resourceSnapshot?.processMemoryMetric ?? 'rss' - ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) - - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) - const sessionsOnlyError = sessionsError && memorySnapshotError === null - const resourceManagerTooltipLines = getResourceManagerTooltipLines({ - memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` - : memBadgeLabel, - sessionCount: triggerSessionCount, - spaceScanReady - }) - const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: triggerSessionCount, - spaceScanReady - }) - - return { - oldWorkspaceCount, - unifiedRepos, - orphanCount, - triggerSessionCount, - memoryMetricCopy, - totalMemory, - totalCpu, - memBadgeLabel, - daemonUnreachable, - sessionsOnlyError, - resourceManagerTooltipLines, - resourceManagerAriaLabel - } -} - -export type ResourceUsageProjection = ReturnType diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx new file mode 100644 index 00000000000..52f1f167e50 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +function item(name: string, sizeBytes: number): WorkspaceSpaceItem { + return { name, path: `/workspace/${name}`, kind: 'directory', sizeBytes } +} + +function worktree(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function renderedRowNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('span.font-medium')).map( + (node) => node.textContent ?? '' + ) +} + +afterEach(cleanup) + +describe('WorkspaceSpaceBreakdownList', () => { + it('renders one row per counted top-level item, including the omitted aggregate', () => { + const { container } = render( + + ) + + const names = renderedRowNames(container) + expect(names).toEqual(['node_modules', 'src', 'Other top-level items (7)']) + // The header count labels this list: the 7 omitted items are one aggregate row. + expect(container.textContent).toContain('9 top-level items') + expect(names.length - 1 + 7).toBe(9) + }) + + it('scales the size bars against the omitted aggregate when it is the largest item', () => { + const { container } = render( + + ) + + const widths = Array.from(container.querySelectorAll('div[style]')).map( + (node) => node.style.width + ) + expect(widths).toEqual(['25%', '100%']) + }) + + it('omits the aggregate row when nothing was omitted', () => { + const { container } = render( + + ) + + expect(renderedRowNames(container)).toEqual(['src']) + expect(container.textContent).toContain('1 top-level items') + }) + + it('shows the omitted aggregate rather than an empty state when every item was omitted', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('No files found.') + expect(renderedRowNames(container)).toEqual(['Other top-level items (4)']) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index cda1a638c04..01530e17ba8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -48,8 +48,24 @@ export function WorkspaceSpaceBreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 666db63968f..08b4b4bc9d9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -19,7 +19,7 @@ import { sortWorkspaceSpaceRows } from './workspace-space-presentation' import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' -import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' +import { getWorkspaceDecisionDetails } from './workspace-space-decision-details' import { getWorkspaceSpaceDeleteState, getWorkspaceSpaceGitStatusForScan @@ -452,6 +452,117 @@ describe('workspace space presentation helpers', () => { expect(details.reviewLabel).toBeNull() }) + it('hides only a matching suppressed GitHub review from workspace decisions', () => { + const matching = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 12, + state: 'open', + status: 'success', + title: 'Suppressed PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const different = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 13, + state: 'open', + status: 'success', + title: 'Different PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + + expect(matching.reviewLabel).toBeNull() + expect(different.reviewLabel).toBe('PR #13 Open, success') + }) + + it('preserves explicit links and non-GitHub reviews in workspace decisions', () => { + const cachedReview = { + number: 12, + state: 'open', + status: 'success', + title: 'Review' + } + const explicit = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'github' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: 12, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const gitLab = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'gitlab' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12, + linkedGitLabMR: 12 + }) + ] + ]) + }) + ) + + expect(explicit.reviewLabel).toBe('PR #12 Open, success') + expect(gitLab.reviewLabel).toBe('PR #12 Open, success') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', @@ -484,6 +595,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts index 8dacd66f084..bacc3988bb8 100644 --- a/src/renderer/src/components/task-page-github-issue-creation.test.ts +++ b/src/renderer/src/components/task-page-github-issue-creation.test.ts @@ -3,8 +3,12 @@ import { readTaskPageSource } from './task-page-source-family.test-support' import { readFileSync } from 'node:fs' const taskPageSource = readTaskPageSource('use-task-page-github-issue-creation.ts') -const newIssueStateSource = readFileSync( - new URL('./task-page/hooks/use-task-page-github-new-issue-state.ts', import.meta.url), +const newIssueDraftSource = readFileSync( + new URL('./use-task-page-github-issue-draft.ts', import.meta.url), + 'utf8' +) +const newIssueRepoResetSource = readFileSync( + new URL('./task-page-new-issue-draft.ts', import.meta.url), 'utf8' ) @@ -18,8 +22,12 @@ function issueCreationSection(): string { describe('TaskPage GitHub issue creation', () => { it('keeps issue creation targeted to the first selected repo on a fresh mount', () => { - expect(newIssueStateSource).toContain('(selectedRepos[0]?.id ?? null)') - expect(newIssueStateSource).toContain('newIssueRepoId !== null') + expect(newIssueDraftSource).toContain( + 'selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null' + ) + // Why: a repo leaving the selection mid-draft must reset to the first selected repo. + expect(newIssueRepoResetSource).toContain('newIssueRepoId === null') + expect(newIssueRepoResetSource).toContain('return { repoId: selectedRepoIds[0] ?? null }') }) it('covers the complete remote oversized-body recovery timeout envelope', () => { diff --git a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts index 9f557a10b7d..785f503436f 100644 --- a/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts +++ b/src/renderer/src/components/task-page-github-list-scroll-restore.test.ts @@ -6,7 +6,7 @@ import { startGitHubListScrollRestore, supersedeGitHubListScrollRestore, type GitHubListRestoreWrite -} from './task-page/github/github-list-scroll-restore' +} from './task-page-github-list-scroll-restore' type FakeResizeObserver = { targets: Set diff --git a/src/renderer/src/components/task-page/github/github-list-scroll-restore.ts b/src/renderer/src/components/task-page-github-list-scroll-restore.ts similarity index 100% rename from src/renderer/src/components/task-page/github/github-list-scroll-restore.ts rename to src/renderer/src/components/task-page-github-list-scroll-restore.ts diff --git a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts index f9ebe0ac6fb..bcd9ee73d97 100644 --- a/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts +++ b/src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts @@ -2,9 +2,8 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const newLinearIssueDialogSources = [ - './task-page/dialogs/new-linear-issue-dialog.tsx', - './task-page/dialogs/new-linear-issue-more-pickers.tsx', - './task-page/dialogs/new-linear-issue-status-assignee.tsx' + './TaskPageLinearIssueDialog.tsx', + './TaskPageLinearIssueAttributes.tsx' ].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')) /** The extracted Linear "New Issue" dialog and its attribute pickers. */ diff --git a/src/renderer/src/components/task-page-task-creation-drafts.test.ts b/src/renderer/src/components/task-page-task-creation-drafts.test.ts index d69257347d7..9933050d1b8 100644 --- a/src/renderer/src/components/task-page-task-creation-drafts.test.ts +++ b/src/renderer/src/components/task-page-task-creation-drafts.test.ts @@ -7,19 +7,19 @@ import { const taskPageSource = readTaskPageSourceFamily() const linearCreateDialogsSource = readFileSync( - new URL('./task-page/hooks/use-task-page-linear-create-dialogs.ts', import.meta.url), + new URL('./use-task-page-linear-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateDialogSource = readFileSync( - new URL('./task-page/hooks/use-task-page-jira-create-dialog.tsx', import.meta.url), + new URL('./use-task-page-jira-creation-state.ts', import.meta.url), 'utf8' ) const jiraCreateSubmitSource = readFileSync( - new URL('./task-page/hooks/use-task-page-create-jira-submit.ts', import.meta.url), + new URL('./use-task-page-jira-issue-creation.ts', import.meta.url), 'utf8' ) const draftWriterSource = readFileSync( - new URL('./task-page/dialogs/task-creation-draft-writers.ts', import.meta.url), + new URL('./task-page-draft-storage.tsx', import.meta.url), 'utf8' ) const draftRetentionSource = [linearCreateDialogsSource, jiraCreateDialogSource].join('\n') @@ -49,11 +49,11 @@ describe('TaskPage Linear/Jira creation drafts', () => { it('restores dismissed typed text when each dialog reopens', () => { const linearFilters = readFileSync( - new URL('./task-page/chrome/task-page-linear-filters.tsx', import.meta.url), + new URL('./TaskPageLinearFilters.tsx', import.meta.url), 'utf8' ) const jiraFilters = readFileSync( - new URL('./task-page/chrome/task-page-jira-filters.tsx', import.meta.url), + new URL('./TaskPageJiraFilters.tsx', import.meta.url), 'utf8' ) expect(linearFilters).toContain("setNewLinearProjectName(draft?.name ?? '')") @@ -92,7 +92,7 @@ describe('TaskPage Linear/Jira creation drafts', () => { const jiraIssueSection = sectionBetween( jiraCreateSubmitSource, 'const handleCreateNewJiraIssue', - 'return { handleCreateNewJiraIssue }' + 'const nextModel' ) expect(jiraIssueSection).toContain('} catch (error) {') expect(jiraIssueSection).toContain( diff --git a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts index 7d1c9625efb..22ef41a8bd3 100644 --- a/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts +++ b/src/renderer/src/components/task-page-workspace-composer-boundary.test.ts @@ -9,10 +9,6 @@ import { const TASK_PAGE_SOURCE = readTaskPageSourceFamily() const WORKSPACE_ACTIONS_SOURCE = readTaskPageSource('use-task-page-workspace-actions.ts') const COMPOSER_ACTIONS_SOURCE = readTaskPageSource('use-task-page-composer-actions.ts') -const USE_ITEM_ACTIONS_SOURCE = readFileSync( - join(__dirname, 'task-page/hooks/use-task-page-use-item-actions.ts'), - 'utf8' -) const PROJECT_VIEW_SOURCE = readFileSync( join(__dirname, 'github-project', 'ProjectViewWrapper.tsx'), 'utf8' @@ -79,7 +75,7 @@ describe('TaskPage workspace creation source boundaries', () => { expect(section).toContain("recordFeatureInteraction('github-tasks')") expect(section).toContain('openComposerForItem(item)') expect(section).not.toContain('createGitHubWorkItemWorkspaceInBackground') - expect(USE_ITEM_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') + expect(WORKSPACE_ACTIONS_SOURCE).not.toContain('@/lib/github-work-item-background-create') expect(TASK_PAGE_SOURCE).not.toContain('@/lib/github-work-item-background-create') }) @@ -118,11 +114,8 @@ describe('TaskPage workspace creation source boundaries', () => { }) it('uses the shared composer handler from GitHub detail and start-new actions', () => { - const detail = readFileSync(join(__dirname, 'task-page/github/github-detail-host.tsx'), 'utf8') - const actions = readFileSync( - join(__dirname, 'task-page/github/github-work-item-row-actions.tsx'), - 'utf8' - ) + const detail = readFileSync(join(__dirname, 'TaskPageContent.tsx'), 'utf8') + const actions = readFileSync(join(__dirname, 'TaskPageGitHubRows.tsx'), 'utf8') expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2) expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2) }) diff --git a/src/renderer/src/components/task-page/github/github-work-item-table.tsx b/src/renderer/src/components/task-page/github/github-work-item-table.tsx index 0f6e1ab6e8e..c19a2d34201 100644 --- a/src/renderer/src/components/task-page/github/github-work-item-table.tsx +++ b/src/renderer/src/components/task-page/github/github-work-item-table.tsx @@ -27,7 +27,7 @@ import type { TaskPageGitHubWorkItemMutationRunner } from './github-work-item-mu import { supersedeGitHubListScrollRestore, type GitHubListRestoreWrite -} from './github-list-scroll-restore' +} from '@/components/task-page-github-list-scroll-restore' import { GithubWorkItemRows } from './github-work-item-rows' import { PaginationBar } from '../pagination/pagination-bar' diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts b/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts index d85a7e3d0b4..19815e9da3f 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-github-list-state.ts @@ -9,7 +9,7 @@ import { sortWorkItemsByNumber } from '../../../../../shared/work-items' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { Repo } from '../../../../../shared/repo-types' import type { TaskViewPresetId } from '../../../../../shared/ui-chrome-types' -import type { GitHubListRestoreWrite } from '@/components/task-page/github/github-list-scroll-restore' +import type { GitHubListRestoreWrite } from '@/components/task-page-github-list-scroll-restore' import type { AppState } from '@/store/types' export function useTaskPageGitHubListState({ diff --git a/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts new file mode 100644 index 00000000000..22f88cdbf20 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-editor-shortcuts.ts @@ -0,0 +1,67 @@ +import type { KeybindingActionId } from '../../../shared/keybindings' +import { useAppStore } from '../store' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' +import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions' + +type EditorShortcutContext = { + event: KeyboardEvent + floatingWorkspaceFocused: boolean + matchShortcut: (actionId: KeybindingActionId) => boolean + notifyTerminalCapture: (actionId: KeybindingActionId) => void +} + +// Returns true only when the chord was consumed, so unclaimed editor chords still +// fall through to the remaining workspace shortcuts. +export function handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture +}: EditorShortcutContext): boolean { + // Save active editor file — fallback for when focus is outside the editor (tab bar/sidebar); editor-local handlers own save when the editor is focused. + if (!event.repeat && matchShortcut('editor.save')) { + const target = event.target as HTMLElement | null + const inEditor = + target?.closest('.monaco-editor, [contenteditable]') !== null || + target?.closest('textarea:not(.xterm-helper-textarea), input') !== null + if (!inEditor) { + const state = useAppStore.getState() + const floatingPanelOwnsEvent = + isEventTargetInsideFloatingWorkspacePanel(event.target) || floatingWorkspaceFocused + const requestedFileId = getEditorCmdSaveFileId(state, floatingPanelOwnsEvent) + if (requestedFileId) { + event.preventDefault() + notifyTerminalCapture('editor.save') + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, { + detail: { fileId: requestedFileId } + }) + ) + return true + } + } + } + // Why: long/structured files need a discoverable unwrap path without Settings (#9974). + if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { + const state = useAppStore.getState() + if (state.activeTabType === 'editor' && state.activeFileId) { + event.preventDefault() + notifyTerminalCapture('editor.toggleWordWrap') + // Why: diff surfaces use diffWordWrap; plain editors use editorWordWrap (#10086). + const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) + if (activeFile?.mode === 'diff') { + const wrapOn = state.settings?.diffWordWrap === true + void state.updateSettings({ diffWordWrap: !wrapOn }) + } else { + const wrapOn = state.settings?.editorWordWrap !== false + void state.updateSettings({ editorWordWrap: !wrapOn }) + } + return true + } + } + return false +} diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts new file mode 100644 index 00000000000..015b9708a11 --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, + type EditorRequestCmdSaveDetail +} from './editor/editor-autosave' +import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown' +import type { TerminalActivationController } from './use-terminal-activation-actions' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + floatingFocused: false, + targetInsideFloatingPanel: false +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('../hooks/ipc-tab-switch', () => ({ + handleSwitchRecentTab: vi.fn(), + handleSwitchTab: vi.fn(), + handleSwitchTabAcrossAllTypes: vi.fn(), + handleSwitchTerminalTab: vi.fn() +})) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + createFloatingWorkspaceBrowserTab: vi.fn(), + createFloatingWorkspaceMarkdownTab: vi.fn(), + createFloatingWorkspaceTerminalTab: vi.fn(), + handleEmptyFloatingWorkspacePanelCloseShortcut: () => false, + isEventTargetInsideFloatingWorkspacePanel: () => mocks.targetInsideFloatingPanel, + isFloatingWorkspacePanelFocused: () => mocks.floatingFocused, + switchFloatingWorkspaceTab: vi.fn() +})) +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) +vi.mock('./terminal-agent-tab-shortcut', () => ({ + resolveTerminalAgentTabShortcut: () => ({ actionId: null, agent: null }) +})) + +const controller = { + activeWorktreeId: 'repo-1::/repo/worktree', + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleNewAgentTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + keybindings: undefined, + mobileEmulatorEnabled: false, + terminalShortcutPolicy: 'orca-first' +} as unknown as TerminalActivationController + +function pressCmdS(): (EditorRequestCmdSaveDetail | undefined)[] { + const details: (EditorRequestCmdSaveDetail | undefined)[] = [] + const listener = (event: Event): void => { + details.push((event as CustomEvent).detail ?? undefined) + } + window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + const target = document.createElement('div') + document.body.appendChild(target) + const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, cancelable: true }) + Object.defineProperty(event, 'target', { value: target }) + try { + handleTerminalWorkspaceKeyDown(event, controller, 'darwin') + } finally { + window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, listener) + target.remove() + } + return details +} + +describe('handleTerminalWorkspaceKeyDown editor.save', () => { + beforeEach(() => { + mocks.floatingFocused = false + mocks.targetInsideFloatingPanel = false + mocks.state = { + activeView: 'terminal', + activeTabType: 'editor', + activeFileId: 'file-1', + getActiveTab: () => null + } + }) + + it('dispatches the save request with the resolved file id', () => { + expect(pressCmdS()).toEqual([{ fileId: 'file-1' }]) + }) + + it('resolves the floating panel editor when the panel owns the event', () => { + mocks.targetInsideFloatingPanel = true + mocks.state.getActiveTab = (worktreeId: string) => + worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? { contentType: 'editor', entityId: 'floating-file' } + : null + expect(pressCmdS()).toEqual([{ fileId: 'floating-file' }]) + }) + + it('does not swallow the chord outside the workspace view', () => { + mocks.state.activeView = 'tasks' + expect(pressCmdS()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-workspace-keydown.ts b/src/renderer/src/components/terminal-workspace-keydown.ts index 522a203ffff..dd6e1e70878 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.ts @@ -3,7 +3,6 @@ import type { KeybindingActionId } from '../../../shared/keybindings' import { keybindingMatchesAction } from '../../../shared/keybindings' import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy' import { useAppStore } from '../store' -import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor/editor-autosave' import { handleSwitchRecentTab, handleSwitchTab, @@ -20,9 +19,15 @@ import { switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { + ensureClientCreationActionAllowed, + showClientCreationActionError +} from '@/lib/client-creation-action-error' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { translate } from '@/i18n/i18n' import { getKeybindingContext } from './terminal-workspace-model' import { resolveTerminalAgentTabShortcut } from './terminal-agent-tab-shortcut' +import { handleTerminalWorkspaceEditorShortcut } from './terminal-workspace-editor-shortcuts' import type { TerminalActivationController } from './use-terminal-activation-actions' export function handleTerminalWorkspaceKeyDown( @@ -101,14 +106,26 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && matchShortcut('tab.reopenClosed')) { event.preventDefault() notifyTerminalCapture('tab.reopenClosed') - useAppStore.getState().reopenClosedTab(activeWorktreeId) + try { + useAppStore.getState().reopenClosedTab(activeWorktreeId) + } catch (error) { + showClientCreationActionError(error) + } return } if (!event.repeat && matchShortcut('tab.newBrowser')) { event.preventDefault() notifyTerminalCapture('tab.newBrowser') + const browserWorkspaceId = floatingWorkspaceFocused + ? FLOATING_TERMINAL_WORKTREE_ID + : activeWorktreeId + if (!ensureClientCreationActionAllowed(browserWorkspaceId, 'managed-browser')) { + return + } if (floatingWorkspaceFocused) { - void createFloatingWorkspaceBrowserTab(useAppStore.getState()) + void createFloatingWorkspaceBrowserTab(useAppStore.getState()).catch( + showClientCreationActionError + ) return } handleNewBrowserTab() @@ -117,41 +134,23 @@ export function handleTerminalWorkspaceKeyDown( if (!event.repeat && mobileEmulatorEnabled && matchShortcut('tab.newSimulator')) { event.preventDefault() notifyTerminalCapture('tab.newSimulator') + if (!ensureClientCreationActionAllowed(activeWorktreeId, 'mobile-emulator')) { + return + } if (!floatingWorkspaceFocused) { handleNewSimulatorTab() } return } - if (!event.repeat && matchShortcut('editor.save')) { - const target = event.target as HTMLElement | null - const inEditor = - target?.closest('.monaco-editor, [contenteditable]') !== null || - target?.closest('textarea:not(.xterm-helper-textarea), input') !== null - if (!inEditor) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.save') - window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT)) - return - } - } - } - if (!event.repeat && matchShortcut('editor.toggleWordWrap')) { - const state = useAppStore.getState() - if (state.activeTabType === 'editor' && state.activeFileId) { - event.preventDefault() - notifyTerminalCapture('editor.toggleWordWrap') - const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) - if (activeFile?.mode === 'diff') { - const wrapOn = state.settings?.diffWordWrap === true - void state.updateSettings({ diffWordWrap: !wrapOn }) - } else { - const wrapOn = state.settings?.editorWordWrap !== false - void state.updateSettings({ editorWordWrap: !wrapOn }) - } - return - } + if ( + handleTerminalWorkspaceEditorShortcut({ + event, + floatingWorkspaceFocused, + matchShortcut, + notifyTerminalCapture + }) + ) { + return } if (!event.repeat && matchShortcut('tab.newMarkdown')) { event.preventDefault() diff --git a/src/renderer/src/components/use-task-page-github-detail.ts b/src/renderer/src/components/use-task-page-github-detail.ts index 22e0b2a2f85..b2c61f64bde 100644 --- a/src/renderer/src/components/use-task-page-github-detail.ts +++ b/src/renderer/src/components/use-task-page-github-detail.ts @@ -11,6 +11,7 @@ import { import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { GitLabWorkItem } from '../../../shared/gitlab-types' import { getTaskPageRepoCacheInput, getTaskPageRepoSourceContext } from './task-page-source-context' +import { startGitHubListScrollRestore } from './task-page-github-list-scroll-restore' function getTaskPageScrollTop( scrollRef: React.RefObject, @@ -38,6 +39,7 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { githubListScrollRef, githubListScrollTopRef, pendingGithubScrollRestoreRef, + githubListRestoreWriteRef, taskListPositionRef } = model // Why: the dialog's "Use" button routes through the same direct-launch flow as the row-level "Use" CTA so behavior is consistent regardless of entry point. @@ -73,67 +75,28 @@ export function useTaskPageGitHubDetail(model: TaskPageGitHubListStateModel) { ? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem) : null useLayoutEffect(() => { - const scrollTop = pendingGithubScrollRestoreRef.current - const scrollElement = githubListScrollRef.current - if (scrollTop === null || !scrollElement || !pages[currentPage]) { + const target = pendingGithubScrollRestoreRef.current + if (target === null || !githubListScrollRef.current || !pages[currentPage]) { return } - let frame: number | null = null - let timeout: number | null = null - let observer: ResizeObserver | null = null - const clearScheduledRestore = (): void => { - if (frame !== null) { - window.cancelAnimationFrame(frame) - frame = null - } - if (timeout !== null) { - window.clearTimeout(timeout) - timeout = null - } - observer?.disconnect() - } - const restore = (): void => { - const committedScrollElement = githubListScrollRef.current - if (!committedScrollElement || pendingGithubScrollRestoreRef.current !== scrollTop) { - return - } - committedScrollElement.scrollTop = scrollTop - githubListScrollTopRef.current = scrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop - } - if (Math.abs(committedScrollElement.scrollTop - scrollTop) < 1) { - pendingGithubScrollRestoreRef.current = null - clearScheduledRestore() - } - } - observer = new ResizeObserver(restore) - for (const child of scrollElement.children) { - observer.observe(child) - } - restore() - if (pendingGithubScrollRestoreRef.current === scrollTop) { - frame = window.requestAnimationFrame(restore) - timeout = window.setTimeout(() => { - if (pendingGithubScrollRestoreRef.current === scrollTop) { - const committedScrollTop = getTaskPageScrollTop(githubListScrollRef, 0) - githubListScrollTopRef.current = committedScrollTop - taskListPositionRef.current = { - contextKey: githubResumeContextKey, - page: currentPage, - scrollTop: committedScrollTop - } - pendingGithubScrollRestoreRef.current = null + return startGitHubListScrollRestore({ + target, + scrollElementRef: githubListScrollRef, + pendingRestoreRef: pendingGithubScrollRestoreRef, + restoreWriteRef: githubListRestoreWriteRef, + onScrollTopApplied: (scrollTop) => { + githubListScrollTopRef.current = scrollTop + taskListPositionRef.current = { + contextKey: githubResumeContextKey, + page: currentPage, + scrollTop } - clearScheduledRestore() - }, 5_000) - } - return clearScheduledRestore + } + }) }, [ currentPage, dialogWorkItem, + githubListRestoreWriteRef, githubListScrollRef, githubListScrollTopRef, githubResumeContextKey, diff --git a/src/renderer/src/components/use-task-page-github-list-state.ts b/src/renderer/src/components/use-task-page-github-list-state.ts index 7a5e6b31253..03ff48702c7 100644 --- a/src/renderer/src/components/use-task-page-github-list-state.ts +++ b/src/renderer/src/components/use-task-page-github-list-state.ts @@ -10,6 +10,7 @@ import { } from '@/components/task-page-github-resume-cache' import { sortWorkItemsByNumber } from '../../../shared/work-items' import { useAppStore } from '@/store' +import type { GitHubListRestoreWrite } from './task-page-github-list-scroll-restore' import { getTaskPageRepoSourceContext } from './task-page-source-context' export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const { @@ -99,6 +100,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { const githubListScrollRef = useRef(null) const githubListScrollTopRef = useRef(0) const pendingGithubScrollRestoreRef = useRef(null) + const githubListRestoreWriteRef = useRef(null) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) const [countedTotalPages, setCountedTotalPages] = useState(null) @@ -218,6 +220,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { githubListScrollRef: typeof githubListScrollRef githubListScrollTopRef: typeof githubListScrollTopRef pendingGithubScrollRestoreRef: typeof pendingGithubScrollRestoreRef + githubListRestoreWriteRef: typeof githubListRestoreWriteRef paginationLoading: typeof paginationLoading setPaginationLoading: typeof setPaginationLoading loadingTargetPage: typeof loadingTargetPage @@ -275,6 +278,7 @@ export function useTaskPageGitHubListState(model: TaskPageProviderStateModel) { nextModel.githubListScrollRef = githubListScrollRef nextModel.githubListScrollTopRef = githubListScrollTopRef nextModel.pendingGithubScrollRestoreRef = pendingGithubScrollRestoreRef + nextModel.githubListRestoreWriteRef = githubListRestoreWriteRef nextModel.paginationLoading = paginationLoading nextModel.setPaginationLoading = setPaginationLoading nextModel.loadingTargetPage = loadingTargetPage diff --git a/src/renderer/src/components/use-task-page-jira-issue-creation.ts b/src/renderer/src/components/use-task-page-jira-issue-creation.ts index 0ca6118831f..c1760e1c11d 100644 --- a/src/renderer/src/components/use-task-page-jira-issue-creation.ts +++ b/src/renderer/src/components/use-task-page-jira-issue-creation.ts @@ -97,6 +97,14 @@ export function useTaskPageJiraIssueCreation(model: TaskPageLinearIssueCreationM } }) .catch(() => {}) + } catch (error) { + if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate('auto.components.TaskPage.aec5feeb69', 'Failed to create Jira issue.') + ) + } } finally { if (submitProviderRuntimeContextKey === providerRuntimeContextKeyRef.current) { setNewJiraIssueSubmitting(false) diff --git a/src/renderer/src/components/use-terminal-create-actions.test.tsx b/src/renderer/src/components/use-terminal-create-actions.test.tsx new file mode 100644 index 00000000000..b12fd7730c6 --- /dev/null +++ b/src/renderer/src/components/use-terminal-create-actions.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientCreationActionAvailability } from '@/lib/client-creation-action-policy' +import { useTerminalCreateActions } from './use-terminal-create-actions' +import type { TerminalColdActivationController } from './terminal-cold-activation' + +const mocks = vi.hoisted(() => ({ + browserAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + simulatorAvailability: { + state: 'enabled', + provider: 'local-client' + } as ClientCreationActionAvailability, + state: {} as Record, + toastError: vi.fn(), + createBrowserTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openMobileEmulatorTab: vi.fn() +})) + +vi.mock('../store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('sonner', () => ({ + toast: { error: (...args: unknown[]) => mocks.toastError(...args), message: vi.fn() } +})) +vi.mock('@/lib/client-creation-action-policy', () => ({ + getClientCreationActionPolicy: () => ({ + 'managed-browser': mocks.browserAvailability, + 'mobile-emulator': mocks.simulatorAvailability + }) +})) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: () => false +})) +vi.mock('@/lib/open-mobile-emulator-tab', () => ({ + openMobileEmulatorTab: (...args: unknown[]) => mocks.openMobileEmulatorTab(...args) +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: vi.fn() })) +vi.mock('@/lib/duplicate-browser-tab-options', () => ({ + buildDuplicatedBrowserTabOptions: () => ({}) +})) +vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ + browserWorkspaceHasRemoteOwner: () => false +})) +vi.mock('./tab-bar/tab-create-entry-action', () => ({ openTabBarEntry: vi.fn() })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./terminal-workspace-model', () => ({ + getActiveWorktreeRuntimeEnvironmentId: () => null +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' + +function renderActions() { + return renderHook(() => + useTerminalCreateActions({ + activeWorktreeId: WORKTREE_ID, + createBrowserTab: mocks.createBrowserTab, + createTab: vi.fn(), + openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn() + } as unknown as TerminalColdActivationController) + ).result.current +} + +describe('useTerminalCreateActions creation gates', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.browserAvailability = { state: 'enabled', provider: 'local-client' } + mocks.simulatorAvailability = { state: 'enabled', provider: 'local-client' } + mocks.state = { + activeGroupIdByWorktree: {}, + groupsByWorktree: {}, + browserDefaultUrl: 'about:blank', + browserTabsByWorktree: { [WORKTREE_ID]: [{ id: 'browser-1', url: 'https://example.com' }] } + } + }) + + it('toasts instead of creating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleNewBrowserTab() + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('toasts instead of duplicating a browser tab when the provider forbids it', () => { + mocks.browserAvailability = { state: 'hidden', reason: 'no browser here' } + renderActions().handleDuplicateBrowserTab('browser-1') + expect(mocks.toastError).toHaveBeenCalledWith('no browser here') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('reports a rejected workspace browser open instead of leaving it unhandled', async () => { + mocks.state.activeGroupIdByWorktree = { [WORKTREE_ID]: 'group-1' } + mocks.openNewBrowserTabInActiveWorkspace.mockRejectedValue(new Error('runtime says no')) + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + renderActions().handleNewBrowserTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + process.off('unhandledRejection', unhandled) + expect(mocks.toastError).toHaveBeenCalledWith('runtime says no') + expect(unhandled).not.toHaveBeenCalled() + }) + + it('reports a rejected simulator open instead of leaving it unhandled', async () => { + mocks.openMobileEmulatorTab.mockRejectedValue(new Error('emulator says no')) + renderActions().handleNewSimulatorTab() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mocks.toastError).toHaveBeenCalledWith('emulator says no') + }) +}) diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 57bbdf2c1be..927e8e52b04 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -12,6 +12,8 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { showClientCreationActionError } from '@/lib/client-creation-action-error' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { translate } from '@/i18n/i18n' import { getActiveWorktreeRuntimeEnvironmentId } from './terminal-workspace-model' @@ -122,7 +124,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr void openMobileEmulatorTab(activeWorktreeId, { placement: 'rightSplit', targetGroupId: targetGroupId ?? undefined - }) + }).catch(showClientCreationActionError) }, [activeWorktreeId]) const handleNewBrowserTab = useCallback(() => { @@ -133,22 +135,31 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id if (targetGroupId) { - void openNewBrowserTabInActiveWorkspace(targetGroupId) + void openNewBrowserTabInActiveWorkspace(targetGroupId).catch(showClientCreationActionError) return } - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (browserAvailability.provider === 'paired-runtime' && runtimeEnvironmentId) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, url: defaultUrl - }) + }).catch(showClientCreationActionError) return } createBrowserTab(activeWorktreeId, defaultUrl, { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), - focusAddressBar: true + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) }) }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) @@ -168,8 +179,16 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr return } const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && + browserAvailability.provider === 'paired-runtime' && + runtimeEnvironmentId && browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId) ) { void createWebRuntimeSessionBrowserTab({ @@ -177,12 +196,17 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId - }) + }).catch(showClientCreationActionError) return } - createBrowserTab(activeWorktreeId, source.url, { - ...buildDuplicatedBrowserTabOptions(source) - }) + try { + createBrowserTab(activeWorktreeId, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + } catch (error) { + showClientCreationActionError(error) + } }, [activeWorktreeId, createBrowserTab] ) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index b5065c9355a..01733c3c09c 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -2,11 +2,14 @@ import { useEffect } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + countEvictionExemptTabRoutes, + formatEvictionExemptRouteCounts, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, type TerminalWorktreeRetentionCandidate } from './terminal-pane/terminal-hidden-worktree-retention' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs' import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture' import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics' @@ -91,10 +94,18 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v const evictableTabIds = selectForceParkEvictableTabIds(forceParkedTabs, (tab) => exemptTabIds.has(tab.id) ) + // Why routed + breadcrumbed: only per-route counts in a field bundle + // can say whether fail-open ids or unresolved snapshot capability + // dominates the degenerate all-exempt force-park (which frees no heap). if (evictableTabIds.length === 0 && forceParkedTabs.length > 0) { + const exemptRouteCounts = countEvictionExemptTabRoutes(forceParkedTabs, worktreeId) warnTerminalLifecycleAnomaly('retention force-park freed no panes', { worktreeId, - reason: `exemptTabs=${forceParkedTabs.length}` + reason: `exemptTabs=${forceParkedTabs.length} ${formatEvictionExemptRouteCounts(exemptRouteCounts)}` + }) + recordRendererCrashBreadcrumb('terminal_force_park_freed_no_panes', { + exemptTabs: forceParkedTabs.length, + ...exemptRouteCounts }) } if ( diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 68ea6fcb8d8..9b365e9d885 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -1,13 +1,21 @@ import { useMemo, useRef } from 'react' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' +import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' +import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' export function useTerminalWorkspaceFoundation() { + const terminalTopologyProjectionRef = useRef | null>(null) + terminalTopologyProjectionRef.current ??= createTerminalWorktreeTopologyProjection() const mountedWorktreeIdsRef = useRef(new Set()) const browserGuestWorktreeRecencyRef = useRef([]) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) @@ -39,7 +47,12 @@ export function useTerminalWorkspaceFoundation() { [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] ) const activeView = useAppStore((state) => state.activeView) - const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) + // Why: terminal titles are leaf chrome. The root host only subscribes to + // mount/parking semantics; a real transition publishes fresh tab objects, + // while LiveTerminalTabBar reads title-only updates from the active bucket. + const tabsByWorktree = useAppStore((state) => + terminalTopologyProjectionRef.current!.project(state.tabsByWorktree) + ) const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false diff --git a/src/renderer/src/components/use-terminal-workspace-projection.ts b/src/renderer/src/components/use-terminal-workspace-projection.ts index 50672658c8f..a42031e7b11 100644 --- a/src/renderer/src/components/use-terminal-workspace-projection.ts +++ b/src/renderer/src/components/use-terminal-workspace-projection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '../store' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { useTerminalProviderSnapshotCapability } from './terminal/use-terminal-provider-snapshot-capability' import { getEffectiveLayoutForWorktree as getEffectiveLayout } from './terminal/split-group-mount' import { useContextualTour } from './contextual-tours/use-contextual-tour' @@ -65,6 +66,9 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor const worktreeBrowserTabs = renderedActiveWorktreeId ? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []) : [] + // Why: this strip only renders before the worktree has a layout, which is exactly when a paired + // client can have opened a page the host never has. Without a row here it stays uncloseable. + const worktreeClientHostedBrowserRows = useClientHostedBrowserRows(renderedActiveWorktreeId ?? '') const getEffectiveLayoutForWorktree = useCallback( (worktreeId: string) => getEffectiveLayout(worktreeId, layoutByWorktree, groupsByWorktree, activeGroupIdByWorktree), @@ -100,6 +104,7 @@ export function useTerminalWorkspaceProjection(controller: TerminalWorkspaceStor titlebarTabsTarget, worktreeFiles, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, getEffectiveLayoutForWorktree, effectiveActiveLayout, activeWorktreeBrowserTabIdsKey, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index bf12b2c0c28..12ca698f192 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -1,5 +1,5 @@ import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata' import type { Repo } from '../../../shared/repo-types' @@ -410,10 +410,11 @@ describe('settings navigation metadata', () => { const testDir = import.meta.dirname // Why: the section tables live in sibling settings-navigation-* modules, so reading only the // hook would scan a file that no longer holds the imports this guard exists to police. + // Walk recursively so a later split that nests the modules cannot shrink this guard. const sourceFiles = [ 'useSettingsNavigationMetadata.ts', - ...readdirSync(testDir).filter( - (name) => name.startsWith('settings-navigation-') && name.endsWith('.ts') + ...readdirSync(testDir, { recursive: true, encoding: 'utf8' }).filter( + (name) => basename(name).startsWith('settings-navigation-') && name.endsWith('.ts') ) ] expect(sourceFiles.length).toBeGreaterThan(1) diff --git a/src/renderer/src/lib/client-creation-action-error.ts b/src/renderer/src/lib/client-creation-action-error.ts new file mode 100644 index 00000000000..e0c70afd22e --- /dev/null +++ b/src/renderer/src/lib/client-creation-action-error.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { + getClientCreationActionPolicy, + type ClientCreationAction +} from './client-creation-action-policy' + +export function showClientCreationActionError(error: unknown): void { + toast.error(error instanceof Error ? error.message : String(error)) +} + +// Why: action paths must surface the policy's reason; the visibility gate alone +// leaves shortcut-driven creation failing silently. +export function ensureClientCreationActionAllowed( + worktreeId: string | null, + action: ClientCreationAction +): boolean { + const availability = getClientCreationActionPolicy(useAppStore.getState(), worktreeId)[action] + if (availability.state !== 'enabled') { + toast.error(availability.reason) + return false + } + return true +} diff --git a/src/renderer/src/lib/cmd-j-match-relevance.ts b/src/renderer/src/lib/cmd-j-match-relevance.ts deleted file mode 100644 index 79b830783a4..00000000000 --- a/src/renderer/src/lib/cmd-j-match-relevance.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - resolveWorktreeBranchLabel, - resolveWorktreeDisplayName -} from './worktree-default-display-name' -import type { MatchRange, PaletteSearchResult } from './worktree-palette-search' -import type { Worktree } from '../../../shared/worktree/types' - -/** A missing match sorts after every concrete field match. */ -export const NO_MATCH_RELEVANCE = Number.MAX_SAFE_INTEGER - -export type PaletteRelevanceFieldTier = 0 | 1 | 2 - -export type PaletteRelevanceField = { - text: string - ranges: readonly MatchRange[] - tier: PaletteRelevanceFieldTier -} - -const NON_WORD_CHARACTER = /[^\p{L}\p{M}\p{N}]/u -const POSITION_RANKS = 4 - -function positionRank(text: string, range: MatchRange): number { - if (range.start === 0) { - return range.end >= text.trimEnd().length ? 0 : 1 - } - return NON_WORD_CHARACTER.test(text[range.start - 1] ?? '') ? 2 : 3 -} - -export function scorePaletteRelevance(fields: readonly PaletteRelevanceField[]): number { - let best = NO_MATCH_RELEVANCE - for (const field of fields) { - for (const range of field.ranges) { - best = Math.min(best, field.tier * POSITION_RANKS + positionRank(field.text, range)) - } - } - return best -} - -export function getWorktreeMatchRelevance( - match: PaletteSearchResult, - worktree: Worktree, - repoName: string -): number { - return scorePaletteRelevance([ - { - text: resolveWorktreeDisplayName(worktree), - ranges: match.displayNameRanges, - tier: 0 - }, - { - text: resolveWorktreeBranchLabel(worktree), - ranges: match.branchRanges, - tier: 1 - }, - { - text: match.supportingText?.text ?? '', - ranges: match.supportingText?.matchRanges ?? [], - tier: 2 - }, - { text: repoName, ranges: match.repoRanges, tier: 2 } - ]) -} - -/** Structural shape shared by browser, simulator, and workspace-tab results. */ -export type OpenTabRelevanceInput = { - title: string - titleRanges: readonly MatchRange[] - secondaryText: string - secondaryRanges: readonly MatchRange[] - worktreeName: string - worktreeRanges: readonly MatchRange[] - repoName: string - repoRanges: readonly MatchRange[] - workspaceLabel?: string | null - workspaceRanges?: readonly MatchRange[] - typeAliasMatch?: { text: string; ranges: readonly MatchRange[] } | null -} - -export function getOpenTabMatchRelevance(result: OpenTabRelevanceInput): number { - return scorePaletteRelevance([ - { text: result.title, ranges: result.titleRanges, tier: 0 }, - { text: result.secondaryText, ranges: result.secondaryRanges, tier: 1 }, - { - text: result.typeAliasMatch?.text ?? '', - ranges: result.typeAliasMatch?.ranges ?? [], - tier: 1 - }, - { - text: result.workspaceLabel ?? '', - ranges: result.workspaceRanges ?? [], - tier: 2 - }, - { text: result.worktreeName, ranges: result.worktreeRanges, tier: 2 }, - { text: result.repoName, ranges: result.repoRanges, tier: 2 } - ]) -} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index c2a22267344..e17e5806914 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -15,7 +15,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { applyNativeChatLaunchDraftResolved } from '../native-chat-launch-draft-runtime-resolution' import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' -import { isWebOnlyMirroredTerminalTab } from './mobile-session-tab-helpers' +import { isWebOnlyMirroredTerminalTab } from './mobile-session-surfaces' import { resolveRuntimeTerminalTitle } from './sync-projections' import { collectAmbiguousTerminalTabIds, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts index e1621493832..5e24fa4d4dd 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts @@ -2,7 +2,7 @@ import type { AppState } from '@/store/types' import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-types' import type { Tab } from '../../../../shared/tab-types' import type { MobileSessionWorktreeInputs } from './types' -import { isUnifiedTabActiveInActiveGroup } from './mobile-session-tab-helpers' +import { isUnifiedTabActiveInActiveGroup } from './mobile-session-surfaces' export function buildMobileBrowserTab( inputs: MobileSessionWorktreeInputs, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts index 3d10aa48dc4..6c6e32f820d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -9,7 +9,7 @@ import { isFileActiveEditorSurface, isMobileFileDiffSource, isUnifiedTabActiveInActiveGroup -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' export function buildMobileMarkdownTab( inputs: MobileSessionWorktreeInputs, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts index f590bc6f226..65ad4acf721 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts @@ -6,7 +6,7 @@ import { type VisibleTabRef } from '../../components/tab-bar/group-tab-order' import type { MobileSessionWorktreeInputs } from './types' -import { isEditorSurfaceTab } from './mobile-session-tab-helpers' +import { isEditorSurfaceTab } from './mobile-session-surfaces' export function getEditorUnifiedTabsForWorktree( inputs: Pick diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index 16c2f4d0da3..d69885a0f8c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -21,7 +21,7 @@ import { captureMountedTerminalSurfaces, narrowRecordByKeys } from './mobile-ses import { getRuntimeLeafIdsForTerminal, resolveMobileTabWideAgentHintLeafId -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) { diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts index 91e89a9cb73..11061906bc5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -22,7 +22,7 @@ import { isMobilePublishableBrowserWorkspace, isMobilePublishableOpenFile, isWebOnlyMirroredTerminalTab -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' import { appendFallbackEditorTabsToGroups, buildMobileSessionGroupProjection, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts similarity index 91% rename from src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts rename to src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index 9012a0fc0d1..216d2dd0448 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -91,10 +91,15 @@ export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]) ) } +/** + * Why a workspace document is held back: it is served to one desktop guest through a grant no + * mobile client holds, so there is nothing on the other side that could render it — and the wire + * has no tab kind for it, so an old client would take it for an ordinary browser tab and offer + * navigation for a page that has no URL. Host and phone parity ships behind capability negotiation. + */ export function isMobilePublishableBrowserWorkspace( workspace: NonNullable[number] ): boolean { - // Document previews are served through a desktop-only grant. return !workspace.docLocation } diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts index 0d364c92970..b1c7fe4b54f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -16,7 +16,7 @@ import { getRuntimeLeafIdsForTerminal, resolveMobileTabWideAgentHintLeafId, isUnifiedTabActiveInActiveGroup -} from './mobile-session-tab-helpers' +} from './mobile-session-surfaces' import { resolveRuntimeTerminalTitle } from './sync-projections' import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 911348fae64..0627bccee06 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -2,12 +2,10 @@ import type { AppState } from '@/store/types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { - EMPTY_AGENT_STATUS_BY_PANE_KEY, EMPTY_BROWSER_PAGES_BY_WORKSPACE, EMPTY_BROWSER_TABS_BY_WORKTREE, graphState } from './graph-state' -import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection' export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { // Some callers/tests build partial pre-browser states; treat missing slices as empty. @@ -147,11 +145,3 @@ export function stableHashString(value: string): string { } return `draft:${value.length}:${(hash >>> 0).toString(16)}` } - -export function buildRuntimeMobileAgentStatusProjectionForState( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined -): string { - return buildRuntimeMobileAgentStatusProjection( - agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY - ) -} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts index 68c06e05c9d..ad5c8641abb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts @@ -6,7 +6,7 @@ import { appendTabGroupLayout, pruneTabGroupLayout, tabGroupLayoutEqual -} from './layout-helpers' +} from './tab-group-layout-tree' type ActiveStateContext = ReturnType diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 6263d9d8467..f14b8a26747 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -1,10 +1,10 @@ import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index' -import { chooseTargetGroupId, buildHostGroupIdByTabId } from './layout-helpers' +import { chooseTargetGroupId, buildHostGroupIdByTabId } from './tab-group-layout-tree' import { buildMirroredBrowserTabs, browserWorkspaceHasRemoteEnvironmentPage, browserWorkspaceHasClientHostedEnvironmentPage -} from './browser-helpers' +} from './mirrored-browser-tabs' import { buildMirroredEditorTabs } from './tab-builders' import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts index ad5a1a0b5ef..d85b7c6954a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts @@ -11,7 +11,7 @@ import { toWebTerminalSurfaceTabId } from '../web-runtime-session' import { clearHostSessionTabIdMappings, setHostSessionTabIdMapping } from './tracking-mappings' import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' import { resolveWebSessionReorderedOrder } from '../web-session-reorder-intent' -import { mapHostRecentTabIds } from './layout-helpers' +import { mapHostRecentTabIds } from './tab-group-layout-tree' import { pushRecentTabId, sanitizeRecentTabIds } from './state-equality-core' export function buildHostToLocalTabIdMap({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts similarity index 99% rename from src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts rename to src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts index 8597547cc0c..8464ba504d7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts @@ -8,7 +8,7 @@ import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state' import { readBrowserClientHostId } from '../browser-client-host-identity' import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' import { browserPageEqual } from './state-equality-tabs' -import { collectLayoutGroupIds } from './layout-helpers' +import { collectLayoutGroupIds } from './tab-group-layout-tree' import { buildBrowserUnifiedTab } from './tab-builders' import { isReadyBrowserTab } from './terminal-surfaces' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-group-layout-tree.ts similarity index 100% rename from src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts rename to src/renderer/src/runtime/web-session-tabs-sync/tab-group-layout-tree.ts diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index cbd3b991a67..0d96ca524ed 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -10,12 +10,12 @@ import { boundRecentlyRetiredAgentStatusPaneKeys, movePaneKeyedRecord, removePaneKeys -} from './agent-status-map-helpers' +} from './agent-status-pane-keyed-records' import { getLeafIdFromPaneKey, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' export function createAgentStatusAuthorityActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-capacity-eviction.ts b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts new file mode 100644 index 00000000000..fa82528a169 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-capacity-eviction.ts @@ -0,0 +1,99 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { RetainedAgentEntry } from './agent-status-contract' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' + +const MAX_RETAINED_AGENTS = 500 + +export function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + +// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). +export const MAX_LIVE_AGENT_STATUSES = 500 + +type PaneLiveness = 'live' | 'dead' | 'unprovable' + +// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). +export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { + const rootedLeafKeys = new Set() + const rootedTabIds = new Set() + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (!layout?.root) { + continue + } + rootedTabIds.add(tabId) + const stack: TerminalPaneLayoutNode[] = [layout.root] + while (stack.length > 0) { + const node = stack.pop()! + if (node.type === 'leaf') { + rootedLeafKeys.add(`${tabId}:${node.leafId}`) + } else { + stack.push(node.first, node.second) + } + } + } + return (paneKey) => { + if (rootedLeafKeys.has(paneKey)) { + return 'live' + } + const tabId = getTabIdFromPaneKey(paneKey) + return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' + } +} + +// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. +export function capLiveAgentStatusesInPlace( + freshLive: Record, + protectedPaneKey: string, + buildClassifier: () => (paneKey: string) => PaneLiveness, + now: number, + maxEntries = MAX_LIVE_AGENT_STATUSES +): string[] { + const keys = Object.keys(freshLive) + let overflow = keys.length - maxEntries + if (overflow <= 0) { + return [] + } + const classify = buildClassifier() + const evictedPaneKeys: string[] = [] + const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { + for (const key of keys) { + if (overflow <= 0) { + break + } + if (key === protectedPaneKey || !(key in freshLive)) { + continue + } + const liveness = classify(key) + if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { + continue + } + delete freshLive[key] + overflow -= 1 + evictedPaneKeys.push(key) + } + } + // Prefer rows that are provably dead or too stale to represent a live agent. + sweep( + (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) + // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. + if (overflow > 0) { + sweep(() => true) + } + return evictedPaneKeys +} diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts index 78047c13560..352a86526df 100644 --- a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -1,9 +1,7 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { - collectWorktreeIdsForConnection, - pruneMigrationUnsupportedEntries -} from './agent-status-map-helpers' +import { collectWorktreeIdsForConnection } from './agent-status-connection-worktree-scope' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' /** Actions for removing transient rows and migration-era cache entries. */ export function createAgentStatusCleanupActions( diff --git a/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts new file mode 100644 index 00000000000..e4e94c16049 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts @@ -0,0 +1,41 @@ +import type { AppState } from '../types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId +} from '../../../../shared/execution-host' + +// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never +// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host +// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a +// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same +// path on two hosts yields one shared id that must not clear another host's live rows. +export function collectWorktreeIdsForConnection( + state: AppState, + connectionId: string +): Set { + const hostIdsOnConnection = new Set( + state.repos + .filter((repo) => repo.connectionId === connectionId) + .map((repo) => getRepoExecutionHostId(repo)) + ) + if (hostIdsOnConnection.size === 0) { + return new Set() + } + const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) + const onConnection = new Set() + const onOtherHost = new Set() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + const repo = repoById.get(repoId) + for (const worktree of worktrees) { + const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) + ? onConnection + : onOtherHost + bucket.add(worktree.id) + } + } + // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. + for (const id of onOtherHost) { + onConnection.delete(id) + } + return onConnection +} diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index fdaaa450b52..c918793a303 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -6,11 +6,11 @@ import type { import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { retainedAgentEntryFromLive, shouldReplaceRetainedWithLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority' function removeAcknowledgement( diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index fc24f06add8..5d8387e9bf0 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -1,11 +1,11 @@ import type { AppState } from '../types' import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { boundRecentlyClosedAgentStatusTabIds, - boundRecentlyRetiredAgentStatusPaneKeys, - pruneMigrationUnsupportedEntries -} from './agent-status-map-helpers' -import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-helpers' + boundRecentlyRetiredAgentStatusPaneKeys +} from './agent-status-pane-keyed-records' +import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-key-tab-binding' /** Slices that only the fold touched, so the batch commits as a MERGE. */ export function buildAgentStatusBatchPatch( diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts index 67832edfab6..254ae37f4d1 100644 --- a/src/renderer/src/store/slices/agent-status-launch-actions.ts +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -1,15 +1,15 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract' -import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-sleeping-records' import { getLaunchConfigForEntry, + getLaunchConfigForStatusMetadata, launchConfigRegistryEntriesEqual, normalizeLaunchConfigRegistrationMetadata, registryEntryMatchesStatus } from './agent-status-launch-config' -import { getLaunchConfigForStatusMetadata } from './agent-status-map-helpers' -import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' export function createAgentStatusLaunchActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-launch-config.ts b/src/renderer/src/store/slices/agent-status-launch-config.ts index ff265bc3ff6..89c544de87b 100644 --- a/src/renderer/src/store/slices/agent-status-launch-config.ts +++ b/src/renderer/src/store/slices/agent-status-launch-config.ts @@ -7,9 +7,10 @@ import { } from '../../../../shared/agent-session-resume' import type { AgentLaunchConfigRegistryEntry, - AgentLaunchConfigRegistrationMetadata + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata } from './agent-status-contract' -import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' import { launchConfigsEqual } from './agent-status-recovery-equivalence' export function normalizeLaunchConfigRegistrationMetadata( @@ -140,3 +141,23 @@ export function getLaunchConfigForEntry( ? sleepingRecord.launchConfig : undefined } + +export function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index f534c5efb54..a9407da5c13 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -9,14 +9,15 @@ import type { import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' import { buildAgentStatusLiveEntry, - type AgentStatusLiveEntryBuild + type AgentStatusLiveEntryBuild, + type AgentStatusLiveEntryRejection } from './agent-status-live-entry-builder' import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' import { agentStatusTabAlreadyHasProtectedOrGeneratedTitle, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { getAgentRowGeneratedTitleText, getOrcaDispatchTaskId, @@ -49,7 +50,7 @@ export function createAgentStatusLiveActions( ) { return } - let built: AgentStatusLiveEntryBuild | null = null + let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null set((state) => { built = buildAgentStatusLiveEntry({ state, @@ -61,14 +62,16 @@ export function createAgentStatusLiveActions( metadata, updatedAt }) - return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + return built.entry ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state }) // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. - const builtResult = built as AgentStatusLiveEntryBuild | null - if (!builtResult) { - // Keep standalone calls' deferred freshness contract even when a stale - // event is rejected by the reducer. - requestFreshness(false) + const builtResult = built as AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null + if (!builtResult?.entry) { + // Keep standalone calls' deferred freshness contract when a stale event is rejected, but a + // suppressed inherited-terminal frame returns without buying the deferred O(entries) scan. + if (builtResult?.reason !== 'suppressed-inherited-terminal') { + requestFreshness(false) + } return } const { entry } = builtResult diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 14c74b8d23f..3ef257edb6b 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -26,8 +26,8 @@ import type { AgentStatusTiming } from './agent-status-contract' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' -import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' +import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' export type AgentStatusLiveEntryBuild = { @@ -52,6 +52,11 @@ export type AgentStatusLiveEntryBuild = { boundaryResolved: boolean } +export type AgentStatusLiveEntryRejection = { + entry: null + reason: 'stale' | 'suppressed-inherited-terminal' +} + export type AgentStatusLiveEntryArgs = { state: AppState paneKey: string @@ -63,14 +68,14 @@ export type AgentStatusLiveEntryArgs = { updatedAt: number } -/** Build one accepted live row and the derived map-update facts. */ +/** Build one accepted live row and the derived map-update facts, or say why the frame was rejected. */ export function buildAgentStatusLiveEntry( args: AgentStatusLiveEntryArgs -): AgentStatusLiveEntryBuild | null { +): AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection { const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args const existing = state.agentStatusByPaneKey[paneKey] if (existing && updatedAt < existing.updatedAt) { - return null + return { entry: null, reason: 'stale' } } const effectiveTitle = terminalTitle ?? existing?.terminalTitle let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] @@ -141,7 +146,7 @@ export function buildAgentStatusLiveEntry( incomingState: payload.state }) ) { - return null + return { entry: null, reason: 'suppressed-inherited-terminal' } } const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] const runtimeMergedOrchestration = runtimeOrchestration diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index 4dd2691699e..485296e240d 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -10,9 +10,12 @@ import type { import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' -import { findAgentPaneWorktreeId, isAgentCompletionState } from './agent-status-pane-helpers' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' -import { sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { + findAgentPaneWorktreeId, + isAgentCompletionState +} from './agent-status-pane-key-tab-binding' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' +import { sleepingRecordFromEntry } from './agent-status-sleeping-records' export type AgentStatusLiveFacts = { existingSleepingRecord: SleepingAgentSessionRecord | undefined diff --git a/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts new file mode 100644 index 00000000000..233a7c0121b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusPayload } from './agent-status-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { createAgentStatusLiveActions } from './agent-status-live-actions' + +const NOW = new Date('2026-04-09T12:00:00.000Z').getTime() +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function existingEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'parent turn', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function setup(existing: AgentStatusEntry) { + const state = { + agentStatusByPaneKey: { [PANE_KEY]: existing }, + recentlyRetiredAgentStatusPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {} + } as unknown as AppState + const requestFreshness = vi.fn() + const runtime = { + get: () => state, + set: vi.fn((update) => { + if (typeof update === 'function') { + update(state) + } + }), + applyGeneratedTabTitleUpdate: vi.fn(), + requestFreshness, + transactAgentStatuses: vi.fn() + } as unknown as AgentStatusRuntime + return { requestFreshness, actions: createAgentStatusLiveActions(runtime) } +} + +function payload(overrides: Partial = {}): AgentStatusPayload { + return { state: 'done', prompt: 'child hook', ...overrides } as AgentStatusPayload +} + +describe('setAgentStatus freshness requests on rejected frames', () => { + it('skips the deferred freshness scan when an inherited terminal status is suppressed', () => { + // A nested child hook inherits ORCA_PANE_KEY, so its `done` is dropped while the parent works. + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'codex' }), undefined, { + updatedAt: NOW + 1 + }) + + expect(requestFreshness).not.toHaveBeenCalled() + }) + + it('still requests deferred freshness when a stale frame is rejected', () => { + const { requestFreshness, actions } = setup(existingEntry()) + + actions.setAgentStatus(PANE_KEY, payload({ agentType: 'claude' }), undefined, { + updatedAt: NOW - 1 + }) + + expect(requestFreshness).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts index 2bae1cb0db0..96c8c0c97b2 100644 --- a/src/renderer/src/store/slices/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -1,6 +1,9 @@ import type { AppState } from '../types' -import { capLiveAgentStatusesInPlace, classifyPaneKeyLiveness } from './agent-status-pane-helpers' -import { removePaneKeys } from './agent-status-map-helpers' +import { + capLiveAgentStatusesInPlace, + classifyPaneKeyLiveness +} from './agent-status-capacity-eviction' +import { removePaneKeys } from './agent-status-pane-keyed-records' import { recoveryRecordMatches } from './agent-status-recovery-equivalence' import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' diff --git a/src/renderer/src/store/slices/agent-status-map-helpers.ts b/src/renderer/src/store/slices/agent-status-map-helpers.ts deleted file mode 100644 index 7ebf55bbad0..00000000000 --- a/src/renderer/src/store/slices/agent-status-map-helpers.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { AppState } from '../types' -import type { - AgentStatusOrchestrationContext, - MigrationUnsupportedPtyEntry -} from '../../../../shared/agent-status-types' -import { - getRepoExecutionHostId, - getWorktreeExecutionHostId -} from '../../../../shared/execution-host' -import { getTabIdFromPaneKey } from './agent-status-pane-helpers' -import { registryEntryMatchesStatus } from './agent-status-launch-config' -import type { AgentLaunchConfigStatusMetadata } from './agent-status-contract' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' - -export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 - -// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates -// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. -export function boundRecentlyClosedAgentStatusTabIds( - existing: Record, - tabId: string -): Record { - const next: Record = {} - for (const key of Object.keys(existing)) { - if (key !== tabId) { - next[key] = true - } - } - next[tabId] = true - const keys = Object.keys(next) - if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { - delete next[stale] - } - } - return next -} - -export function boundRecentlyRetiredAgentStatusPaneKeys( - existing: Record, - paneKeys: readonly string[] -): Record { - const additions = new Set(paneKeys) - const next: Record = {} - for (const key of Object.keys(existing)) { - if (!additions.has(key)) { - next[key] = true - } - } - for (const paneKey of additions) { - next[paneKey] = true - } - const keys = Object.keys(next) - for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { - delete next[stale] - } - return next -} - -export function movePaneKeyedRecord( - record: Record, - fromPaneKey: string, - toPaneKey: string, - transform: (value: T) => T = (value) => value -): Record { - const value = record[fromPaneKey] - if (value === undefined || fromPaneKey === toPaneKey) { - return record - } - const next = { ...record } - delete next[fromPaneKey] - next[toPaneKey] = transform(value) - return next -} - -export function removePaneKeys( - record: Record, - paneKeys: ReadonlySet -): Record { - const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) - if (matchingKeys.length === 0) { - return record - } - const next = { ...record } - for (const key of matchingKeys) { - delete next[key] - } - return next -} - -export function getLaunchConfigForStatusMetadata( - state: AppState, - metadata: AgentLaunchConfigStatusMetadata -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] - return registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: metadata.paneKey, - agentType: metadata.agentType, - tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, - terminalHandle: metadata.terminalHandle, - launchToken: metadata.launchToken, - providerSession: metadata.providerSession, - existingProviderSession: metadata.existingProviderSession, - providerSessionChanged: metadata.providerSessionChanged ?? false - }) - ? registryEntry?.launchConfig - : undefined -} - -export function pruneMigrationUnsupportedEntries( - entries: Record, - predicate: (entry: MigrationUnsupportedPtyEntry) => boolean -): { next: Record; changed: boolean } { - let changed = false - const next: Record = {} - for (const [ptyId, entry] of Object.entries(entries)) { - if (predicate(entry)) { - changed = true - continue - } - next[ptyId] = entry - } - return { next: changed ? next : entries, changed } -} - -export function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.dispatchStatus === b.dispatchStatus && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} - -export function orchestrationMapsEqual( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) { - return false - } - return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) -} - -export function mergeCurrentOrchestrationContext( - existing: AgentStatusOrchestrationContext | undefined, - current: AgentStatusOrchestrationContext -): AgentStatusOrchestrationContext { - if (!existing) { - return current - } - const sameDispatch = - existing.taskId === current.taskId && existing.dispatchId === current.dispatchId - if (!sameDispatch) { - return current - } - if (current.dispatchStatus !== undefined) { - return orchestrationContextsEqual(existing, current) ? existing : current - } - const merged = { ...existing, ...current } - return orchestrationContextsEqual(existing, merged) ? existing : merged -} - -// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never -// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host -// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a -// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same -// path on two hosts yields one shared id that must not clear another host's live rows. -export function collectWorktreeIdsForConnection( - state: AppState, - connectionId: string -): Set { - const hostIdsOnConnection = new Set( - state.repos - .filter((repo) => repo.connectionId === connectionId) - .map((repo) => getRepoExecutionHostId(repo)) - ) - if (hostIdsOnConnection.size === 0) { - return new Set() - } - const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) - const onConnection = new Set() - const onOtherHost = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - const repo = repoById.get(repoId) - for (const worktree of worktrees) { - const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) - ? onConnection - : onOtherHost - bucket.add(worktree.id) - } - } - // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. - for (const id of onOtherHost) { - onConnection.delete(id) - } - return onConnection -} diff --git a/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts b/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts new file mode 100644 index 00000000000..13f1b1375ad --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-migration-unsupported-entries.ts @@ -0,0 +1,17 @@ +import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' + +export function pruneMigrationUnsupportedEntries( + entries: Record, + predicate: (entry: MigrationUnsupportedPtyEntry) => boolean +): { next: Record; changed: boolean } { + let changed = false + const next: Record = {} + for (const [ptyId, entry] of Object.entries(entries)) { + if (predicate(entry)) { + changed = true + continue + } + next[ptyId] = entry + } + return { next: changed ? next : entries, changed } +} diff --git a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts index 2d038ebf29e..8a1672953bf 100644 --- a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts +++ b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts @@ -11,7 +11,7 @@ import { import { mergeCurrentOrchestrationContext, orchestrationMapsEqual -} from './agent-status-map-helpers' +} from './agent-status-orchestration-context' export function createAgentStatusOrchestrationActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-orchestration-context.ts b/src/renderer/src/store/slices/agent-status-orchestration-context.ts new file mode 100644 index 00000000000..312f191f496 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-orchestration-context.ts @@ -0,0 +1,49 @@ +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' + +export function orchestrationContextsEqual( + a: AgentStatusOrchestrationContext, + b: AgentStatusOrchestrationContext +): boolean { + return ( + a.taskId === b.taskId && + a.dispatchId === b.dispatchId && + a.dispatchStatus === b.dispatchStatus && + a.taskTitle === b.taskTitle && + a.displayName === b.displayName && + a.parentTerminalHandle === b.parentTerminalHandle && + a.parentPaneKey === b.parentPaneKey && + a.coordinatorHandle === b.coordinatorHandle && + a.orchestrationRunId === b.orchestrationRunId + ) +} + +export function orchestrationMapsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) +} + +export function mergeCurrentOrchestrationContext( + existing: AgentStatusOrchestrationContext | undefined, + current: AgentStatusOrchestrationContext +): AgentStatusOrchestrationContext { + if (!existing) { + return current + } + const sameDispatch = + existing.taskId === current.taskId && existing.dispatchId === current.dispatchId + if (!sameDispatch) { + return current + } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } + const merged = { ...existing, ...current } + return orchestrationContextsEqual(existing, merged) ? existing : merged +} diff --git a/src/renderer/src/store/slices/agent-status-pane-helpers.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts similarity index 62% rename from src/renderer/src/store/slices/agent-status-pane-helpers.ts rename to src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index 49e0fc53af6..2377cd52950 100644 --- a/src/renderer/src/store/slices/agent-status-pane-helpers.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -4,103 +4,9 @@ import type { AgentType, ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' - -const MAX_RETAINED_AGENTS = 500 - -export function capRetainedAgents( - retained: Record, - maxEntries = MAX_RETAINED_AGENTS -): Record { - const keys = Object.keys(retained) - if (keys.length <= maxEntries) { - return retained - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = retained[key] - } - return capped -} - -// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). -export const MAX_LIVE_AGENT_STATUSES = 500 - -type PaneLiveness = 'live' | 'dead' | 'unprovable' - -// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). -export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { - const rootedLeafKeys = new Set() - const rootedTabIds = new Set() - for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (!layout?.root) { - continue - } - rootedTabIds.add(tabId) - const stack: TerminalPaneLayoutNode[] = [layout.root] - while (stack.length > 0) { - const node = stack.pop()! - if (node.type === 'leaf') { - rootedLeafKeys.add(`${tabId}:${node.leafId}`) - } else { - stack.push(node.first, node.second) - } - } - } - return (paneKey) => { - if (rootedLeafKeys.has(paneKey)) { - return 'live' - } - const tabId = getTabIdFromPaneKey(paneKey) - return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' - } -} - -// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. -export function capLiveAgentStatusesInPlace( - freshLive: Record, - protectedPaneKey: string, - buildClassifier: () => (paneKey: string) => PaneLiveness, - now: number, - maxEntries = MAX_LIVE_AGENT_STATUSES -): string[] { - const keys = Object.keys(freshLive) - let overflow = keys.length - maxEntries - if (overflow <= 0) { - return [] - } - const classify = buildClassifier() - const evictedPaneKeys: string[] = [] - const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { - for (const key of keys) { - if (overflow <= 0) { - break - } - if (key === protectedPaneKey || !(key in freshLive)) { - continue - } - const liveness = classify(key) - if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { - continue - } - delete freshLive[key] - overflow -= 1 - evictedPaneKeys.push(key) - } - } - // Prefer rows that are provably dead or too stale to represent a live agent. - sweep( - (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS - ) - // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. - if (overflow > 0) { - sweep(() => true) - } - return evictedPaneKeys -} export function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { for (const prefix of tabPrefixes) { diff --git a/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts new file mode 100644 index 00000000000..1e28710c564 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-pane-keyed-records.ts @@ -0,0 +1,76 @@ +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 + +// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates +// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. +export function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + +export function boundRecentlyRetiredAgentStatusPaneKeys( + existing: Record, + paneKeys: readonly string[] +): Record { + const additions = new Set(paneKeys) + const next: Record = {} + for (const key of Object.keys(existing)) { + if (!additions.has(key)) { + next[key] = true + } + } + for (const paneKey of additions) { + next[paneKey] = true + } + const keys = Object.keys(next) + for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { + delete next[stale] + } + return next +} + +export function movePaneKeyedRecord( + record: Record, + fromPaneKey: string, + toPaneKey: string, + transform: (value: T) => T = (value) => value +): Record { + const value = record[fromPaneKey] + if (value === undefined || fromPaneKey === toPaneKey) { + return record + } + const next = { ...record } + delete next[fromPaneKey] + next[toPaneKey] = transform(value) + return next +} + +export function removePaneKeys( + record: Record, + paneKeys: ReadonlySet +): Record { + const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) + if (matchingKeys.length === 0) { + return record + } + const next = { ...record } + for (const key of matchingKeys) { + delete next[key] + } + return next +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 36e1f71ab09..b8a88b9bca6 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -17,10 +17,10 @@ import { findAgentPaneWorktreeId, getTabIdFromPaneKey, isRecentlyClosedAgentStatusTab -} from './agent-status-pane-helpers' -import { removePaneKeys } from './agent-status-map-helpers' +} from './agent-status-pane-key-tab-binding' +import { removePaneKeys } from './agent-status-pane-keyed-records' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { copyLaunchConfig } from './agent-status-recovery-helpers' +import { copyLaunchConfig } from './agent-status-sleeping-records' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index d3109b3cb88..1f7a88fbb7e 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -5,13 +5,13 @@ import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-re import { removeSleepingRecordsReplacedByManualWorktreeSleep, sleepingRecordFromEntry -} from './agent-status-recovery-helpers' +} from './agent-status-sleeping-records' import { recoveryRecordTargetsSameSession, sleepingRecordsEquivalentIgnoringCaptureTime } from './agent-status-recovery-equivalence' import { getLaunchConfigForEntry } from './agent-status-launch-config' -import { findAgentPaneWorktreeId } from './agent-status-pane-helpers' +import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' export function createAgentStatusRecoveryActions( diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts index d5c0b1cd37b..4587f919d36 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-collection.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -8,7 +8,7 @@ import { normalizePaneKeySet, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' import { carryOverAutomaticResumeBlock, isValidCompletedAgentHibernationEntry, @@ -17,7 +17,7 @@ import { normalizeSleepingAgentSessionCollectOptions, sleepingRecordFromEntry, type CollectSleepingAgentSessionRecordsOptions -} from './agent-status-recovery-helpers' +} from './agent-status-sleeping-records' import { getLaunchConfigForEntry } from './agent-status-launch-config' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' diff --git a/src/renderer/src/store/slices/agent-status-retention-actions.ts b/src/renderer/src/store/slices/agent-status-retention-actions.ts index 884a4ca6fa4..ac7cb5b7b48 100644 --- a/src/renderer/src/store/slices/agent-status-retention-actions.ts +++ b/src/renderer/src/store/slices/agent-status-retention-actions.ts @@ -1,8 +1,8 @@ import type { RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' -import { capRetainedAgents } from './agent-status-pane-helpers' +import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' +import { capRetainedAgents } from './agent-status-capacity-eviction' export function createAgentStatusRetentionActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts similarity index 98% rename from src/renderer/src/store/slices/agent-status-recovery-helpers.ts rename to src/renderer/src/store/slices/agent-status-sleeping-records.ts index 9b7a07c4471..e5887ac8626 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -8,7 +8,7 @@ import { type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { findTabForAgentEntry } from './agent-status-pane-helpers' +import { findTabForAgentEntry } from './agent-status-pane-key-tab-binding' export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { return { diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index f6820296735..4bafe7dc381 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -1,13 +1,13 @@ import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' -import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { normalizePaneKeySet, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive, shouldReplaceRetainedWithLive -} from './agent-status-pane-helpers' +} from './agent-status-pane-key-tab-binding' export function createAgentStatusWorktreeDropActions( runtime: AgentStatusRuntime diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 8093c2be805..6a1ed10025c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,16 +51,16 @@ export { collectSleepingAgentSessionRecordsForWorktree, collectHibernatedCompletionEvidenceForWorktree } from './agent-status-recovery-collection' -export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-recovery-helpers' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-sleeping-records' export { buildAgentStatusTabPrefixDropPatch, type AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' -export { MAX_LIVE_AGENT_STATUSES } from './agent-status-pane-helpers' +export { MAX_LIVE_AGENT_STATUSES } from './agent-status-capacity-eviction' export { RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX -} from './agent-status-map-helpers' +} from './agent-status-pane-keyed-records' export const createAgentStatusSlice: StateCreator = ( storeSet, diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts index 42c1aadac03..6a36b740c64 100644 --- a/src/renderer/src/store/slices/linear/linear-slice-scope.ts +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -10,11 +10,7 @@ import { type LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { - getLinearCacheGeneration, - getLinearMutationGeneration, - linearRequestState -} from './linear-slice-request-state' +import { getLinearCacheGeneration, getLinearMutationGeneration } from './linear-slice-request-state' export function normalizeListAttributeFilter( attributeFilter?: LinearIssueAttributeFilter | null @@ -78,25 +74,3 @@ export function getLinearReadScope( export function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key } - -/** Read the current request generation without exposing mutable implementation details. */ -export function linearReadGenerationSnapshot(): { - cacheGeneration: number - mutationGeneration: number -} { - return { - cacheGeneration: getLinearCacheGeneration(), - mutationGeneration: getLinearMutationGeneration() - } -} - -/** Keep the status request handle private to the request-state module. */ -export function getInflightStatusRequest(): { contextKey: string; promise: Promise } | null { - return linearRequestState.inflightStatusRequest -} - -export function setInflightStatusRequest( - request: { contextKey: string; promise: Promise } | null -): void { - linearRequestState.inflightStatusRequest = request -} diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index fba107882a5..b1401e7d416 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -7,11 +7,17 @@ import { import { translate } from '@/i18n/i18n' import { collectAcknowledgedAgentNotificationId, - createAgentSendTargetModeInstanceId, latestAgentTurnTimestamp, resolvePaneKeyWorktreeIdFromTabs, usableTimestamp -} from './ui-slice-agent-helpers' +} from './ui-slice-agent-notification-acknowledgement' + +let agentSendTargetModeInstanceCounter = 0 + +function createAgentSendTargetModeInstanceId(): string { + agentSendTargetModeInstanceCounter += 1 + return `${Date.now()}:${agentSendTargetModeInstanceCounter}` +} export function createUiAgentActions( set: UISliceSet, diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts similarity index 88% rename from src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts rename to src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts index b9b578d5476..e7b600a2ec8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts @@ -53,10 +53,3 @@ export function latestAgentTurnTimestamp(entry: { } return latest } - -let agentSendTargetModeInstanceCounter = 0 - -export function createAgentSendTargetModeInstanceId(): string { - agentSendTargetModeInstanceCounter += 1 - return `${Date.now()}:${agentSendTargetModeInstanceCounter}` -} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-contextual-tour-progression.ts similarity index 100% rename from src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts rename to src/renderer/src/store/slices/ui/ui-slice-contextual-tour-progression.ts diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts index 3cc57ce8361..27756409c93 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -3,7 +3,7 @@ import { mergeFeatureInteractionState, mergeContextualTourSeenIds } from './ui-slice-hydration-values' -import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-tour-helpers' +import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-contextual-tour-progression' import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' export function createUiFeatureActions(set: UISliceSet, get: UISliceGet): Partial { diff --git a/src/shared/keybindings/definitions-core-1.ts b/src/shared/keybindings/definitions-core-1.ts index b757a4127e4..fe8797c7684 100644 --- a/src/shared/keybindings/definitions-core-1.ts +++ b/src/shared/keybindings/definitions-core-1.ts @@ -1,3 +1,5 @@ +// Ordered slices of one registry: `definitions.ts` concatenates core-1..4 in order and that order +// is the palette order, so the boundaries follow line count, not theme — do not regroup thematically. import type { KeybindingDefinition } from './types' import { platformBindings } from './definitions-support'