Merge origin/main into native-chat-entrypoint-routing

This commit is contained in:
Merge Sim
2026-09-01 13:20:03 -07:00
311 changed files with 2667 additions and 22272 deletions
@@ -87,7 +87,7 @@ const parent = `${head}~1`
const CANDIDATES = [
'src/main/git/status.ts',
'src/shared/agent-hook-listener.ts',
'src/renderer/src/components/TaskPage.tsx'
'src/renderer/src/components/task-page/TaskPage.tsx'
]
const files = []
@@ -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. */
+27 -21
View File
@@ -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 }
@@ -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 = `<!DOCTYPE html>
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -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;
@@ -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);
}
})();
</script>
</body>
</html>`
@@ -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.
`
@@ -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);
}
})();
</script>
</body>
</html>`
@@ -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);
}
`
@@ -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');
@@ -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) {
@@ -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();
}
}
`
@@ -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) {
@@ -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);
`
@@ -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();
});
}
`
@@ -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;
@@ -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'
@@ -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'")
+54 -18
View File
@@ -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<string, unknown> {
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<string, unknown> = 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<string, unknown> = 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)
})
})
@@ -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
}
@@ -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
@@ -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<void> {
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<void> {
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<void> {
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
@@ -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<string, string> = {
'.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<boolean> {
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()
}
@@ -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<string, string> = {
'.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<void> {
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<void> {
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<void> {
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<boolean> {
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
@@ -23,7 +23,7 @@ import {
getLocalAgentRuntimeTarget,
getLocalTextGenerationTarget,
getRepoForSourceControlAi
} from './filesystem-worktree-helpers'
} from './filesystem-source-control-ai-targets'
export function registerFilesystemGitCommitGenerationHandlers(
context: FilesystemHandlerContext
@@ -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 {
@@ -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'
@@ -27,7 +27,7 @@ import {
getLocalAgentRuntimeTarget,
getLocalTextGenerationTarget,
getRepoForSourceControlAi
} from './filesystem-worktree-helpers'
} from './filesystem-source-control-ai-targets'
export function registerFilesystemGitPullRequestGenerationHandlers(
context: FilesystemHandlerContext
@@ -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 {
@@ -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
@@ -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
@@ -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
@@ -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
}
@@ -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'
@@ -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')
@@ -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'
)
}
+25 -7
View File
@@ -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 })
@@ -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<typeof RelayInstallMarkerModule>()),
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<typeof vi.spyOn>
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)
})
})
@@ -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: {
@@ -1,6 +1,6 @@
import { ipcRenderer } from 'electron'
export const browserApiPart2 = {
export const browserPageInteractionAndSessionsApi = {
onContextMenuRequested: (
callback: (event: {
browserPageId: string
+6 -3
View File
@@ -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
}
@@ -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
@@ -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<unknown> => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
ipcRenderer.invoke('gh:repoSlug', args),
+3 -3
View File
@@ -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 }
@@ -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
@@ -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<{
+3 -3
View File
@@ -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 }
@@ -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')
}
}
-7
View File
@@ -1,7 +0,0 @@
import { ipcRenderer } from 'electron'
export const uiApiPart5 = {
notifyWindowRevealed: (): void => {
ipcRenderer.send('ui:window-revealed')
}
}
@@ -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),
@@ -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
@@ -12,7 +12,7 @@ import type {
RuntimeTerminalPresentation
} from '../../shared/runtime-types'
export const uiApiPart3 = {
export const uiTerminalAndSessionTabsApi = {
onCreateTerminal: (
callback: (data: {
requestId?: string
+8 -10
View File
@@ -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']
@@ -16,7 +16,7 @@ const Landing = lazy(() => import('../components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('../components/worktree-creation/WorktreeCreationPanel')
)
const TaskPage = lazy(() => import('../components/TaskPage'))
const TaskPage = lazy(() => import('../components/task-page/TaskPage'))
const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('../components/settings/Settings'))
-84
View File
@@ -1,84 +0,0 @@
import React from 'react'
import { useTaskPageStoreBindings } from './use-task-page-store-bindings'
import { useTaskPageRepoSelection } from './use-task-page-repo-selection'
import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts'
import { useTaskPageSourceAvailability } from './use-task-page-source-availability'
import { useTaskPageProviderState } from './use-task-page-provider-state'
import { useTaskPageGitHubListState } from './use-task-page-github-list-state'
import { useTaskPageGitHubDetail } from './use-task-page-github-detail'
import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation'
import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft'
import { useTaskPageDetailRouting } from './use-task-page-detail-routing'
import { useTaskPageLinearViewState } from './use-task-page-linear-view-state'
import { useTaskPageJiraListState } from './use-task-page-jira-list-state'
import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration'
import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata'
import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading'
import { useTaskPageLinearListSelection } from './use-task-page-linear-list-selection'
import { useTaskPageLinearListProjection } from './use-task-page-linear-list-projection'
import { useTaskPageLinearBoard } from './use-task-page-linear-board'
import { useTaskPageJiraListProjection } from './use-task-page-jira-list-projection'
import { useTaskPageLinearCreationState } from './use-task-page-linear-creation-state'
import { useTaskPageGitHubMutationState } from './use-task-page-github-mutation-state'
import { useTaskPageJiraCreationState } from './use-task-page-jira-creation-state'
import { useTaskPageJiraCreationMetadata } from './use-task-page-jira-creation-metadata'
import { useTaskPageGitHubListProjection } from './use-task-page-github-list-projection'
import { useTaskPageGitHubSearchPagination } from './use-task-page-github-search-pagination'
import { useTaskPageGitHubLandingRefresh } from './use-task-page-github-landing-refresh'
import { useTaskPageGitHubQuietRefresh } from './use-task-page-github-quiet-refresh'
import { useTaskPageSearchActions } from './use-task-page-search-actions'
import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions'
import { useTaskPageGitHubIssueCreation } from './use-task-page-github-issue-creation'
import { useTaskPageLinearProjectCreation } from './use-task-page-linear-project-creation'
import { useTaskPageLinearIssueCreation } from './use-task-page-linear-issue-creation'
import { useTaskPageJiraIssueCreation } from './use-task-page-jira-issue-creation'
import { useTaskPageGlobalEffects } from './use-task-page-global-effects'
import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects'
import { useTaskPageLinearInOrcaEffects } from './use-task-page-linear-in-orca-effects'
import { useTaskPageLinearCollectionEffects } from './use-task-page-linear-collection-effects'
import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects'
import { useTaskPageComposerActions } from './use-task-page-composer-actions'
import { TaskPageSurface } from './TaskPageSurface'
export default function TaskPage(): React.JSX.Element {
const stage1 = useTaskPageStoreBindings()
const stage2 = useTaskPageRepoSelection(stage1)
const stage3 = useTaskPageRuntimeHosts(stage2)
const stage4 = useTaskPageSourceAvailability(stage3)
const stage5 = useTaskPageProviderState(stage4)
const stage6 = useTaskPageGitHubListState(stage5)
const stage7 = useTaskPageGitHubDetail(stage6)
const stage8 = useTaskPageGitHubCacheReconciliation(stage7)
const stage9 = useTaskPageGitHubIssueDraft(stage8)
const stage10 = useTaskPageDetailRouting(stage9)
const stage11 = useTaskPageLinearViewState(stage10)
const stage12 = useTaskPageJiraListState(stage11)
const stage13 = useTaskPageResumeRestoration(stage12)
const stage14 = useTaskPageProviderMetadata(stage13)
const stage15 = useTaskPageGitLabLoading(stage14)
const stage16 = useTaskPageLinearListSelection(stage15)
const stage17 = useTaskPageLinearListProjection(stage16)
const stage18 = useTaskPageLinearBoard(stage17)
const stage19 = useTaskPageJiraListProjection(stage18)
const stage20 = useTaskPageLinearCreationState(stage19)
const stage21 = useTaskPageGitHubMutationState(stage20)
const stage22 = useTaskPageJiraCreationState(stage21)
const stage23 = useTaskPageJiraCreationMetadata(stage22)
const stage24 = useTaskPageGitHubListProjection(stage23)
const stage25 = useTaskPageGitHubSearchPagination(stage24)
const stage26 = useTaskPageGitHubLandingRefresh(stage25)
const stage27 = useTaskPageGitHubQuietRefresh(stage26)
const stage28 = useTaskPageSearchActions(stage27)
const stage29 = useTaskPageWorkspaceActions(stage28)
const stage30 = useTaskPageGitHubIssueCreation(stage29)
const stage31 = useTaskPageLinearProjectCreation(stage30)
const stage32 = useTaskPageLinearIssueCreation(stage31)
const stage33 = useTaskPageJiraIssueCreation(stage32)
const stage34 = useTaskPageGlobalEffects(stage33)
const stage35 = useTaskPageLinearListEffects(stage34)
const stage36 = useTaskPageLinearInOrcaEffects(stage35)
const stage37 = useTaskPageLinearCollectionEffects(stage36)
const stage38 = useTaskPageJiraListEffects(stage37)
const stage39 = useTaskPageComposerActions(stage38)
return <TaskPageSurface model={stage39} />
}
@@ -1,54 +0,0 @@
import type { GitHubPRPrimaryReviewer } from '@/components/github-pr-reviewer-display'
import React from 'react'
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import { Users } from 'lucide-react'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
export function ReviewChipAvatar({
reviewer,
avatarHost
}: {
reviewer: GitHubPRPrimaryReviewer | null
avatarHost?: string
}): React.JSX.Element {
if (reviewer?.login) {
// Why: review requests may contain only logins; use the PR host before falling back to initials.
const avatarUrl =
reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40`
return (
<GitHubUserAvatar
login={reviewer.login}
name={reviewer.name}
avatarUrl={avatarUrl}
title={reviewer.name ? `${reviewer.name} (${reviewer.login})` : reviewer.login}
className="size-5"
/>
)
}
return <Users className="size-5 shrink-0" />
}
export function GitHubAssigneeAvatar({
assignee
}: {
assignee: GitHubAssignableUser
}): React.JSX.Element {
if (assignee.avatarUrl) {
return (
<img
src={assignee.avatarUrl}
alt={assignee.login}
loading="lazy"
decoding="async"
title={assignee.name ? `${assignee.name} (${assignee.login})` : assignee.login}
className="size-5 rounded-full border border-border/40 bg-muted object-cover"
/>
)
}
return (
<span
title={assignee.login}
className="inline-flex size-5 items-center justify-center rounded-full border border-border/40 bg-muted text-[10px] font-medium text-muted-foreground"
>
{assignee.login.slice(0, 1).toUpperCase()}
</span>
)
}
@@ -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 <webview>, 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 (
<div
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${
effectiveActiveLayout
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
>
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
@@ -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 (
<div
// Why: already out of flow via the workbench container when hidden, so retention only
// has to drop `hidden` — it does not need to leave the flex column a second time.
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
renderedActiveWorktreeId ? '' : ' hidden'
renderedActiveWorktreeId
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
>
@@ -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<string, unknown>,
tabBarProps: [] as Record<string, unknown>[]
}))
vi.mock('../store', () => ({
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), {
getState: () => mocks.state
})
}))
vi.mock('./tab-bar/TabBar', () => ({
default: (props: Record<string, unknown>) => {
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(<TerminalTitlebarTabs controller={controller} />))
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([])
})
})
@@ -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<React.ComponentProps<typeof TabBar>, 'tabs'>
): React.JSX.Element {
const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS)
return <TabBar {...props} tabs={tabs} />
}
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(
<TabBar
tabs={tabs}
<LiveTerminalTabBar
activeTabId={activeTabId}
worktreeId={renderedActiveWorktreeId}
onActivate={handleActivateTab}
@@ -71,6 +82,7 @@ export function TerminalTitlebarTabs({
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={worktreeFiles}
browserTabs={worktreeBrowserTabs}
clientHostedBrowserRows={worktreeClientHostedBrowserRows}
activeFileId={activeFileId}
activeBrowserTabId={activeBrowserTabId}
activeSimulatorTabId={
@@ -56,6 +56,10 @@ const RETENTION_HELPER_SYMBOLS = [
// Every place that decides whether a browser guest keeps painting, and the helper it must use.
const RETENTION_SITES = new Map<string, readonly string[]>([
['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',
@@ -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('task-page/github/StatusCell.tsx'),
componentSource('task-page/github/AssigneesCell.tsx'),
componentSource('task-page/github/ReviewCell.tsx'),
componentSource('task-page/github/MergeCell.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('task-page/github/Rows.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('task-page/SourceBar.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('task-page/gitlab/ItemList.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('task-page/gitlab/ItemList.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('task-page/gitlab/ItemList.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)
})
@@ -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(
@@ -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()
@@ -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('task-page/github/StatusCell.tsx')
const assigneeSection = componentSource('task-page/github/AssigneesCell.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('task-page/github/ReviewCell.tsx')
const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts')
const mergeSection = componentSource('task-page/github/MergeCell.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')
})
@@ -46,10 +46,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => {
// Why: author chip must not ignore API avatar_url and only pass login.
expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/)
const reviewChip = readFileSync(
join(__dirname, '../task-page/github/github-assignee-avatars.tsx'),
'utf8'
)
const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8')
expect(reviewChip).toMatch(/GitHubUserAvatar/)
// Why: list chip must not hardcode github.com/{login}.png.
expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/)
@@ -77,20 +74,20 @@ describe('issue #8784 GHE avatar fallback (regression)', () => {
displayName: 'option.name'
},
{
file: 'task-page/github/github-assignee-avatars.tsx',
file: 'task-page/github/Avatars.tsx',
fn: 'GitHubAssigneeAvatar',
login: 'assignee.login',
displayName: 'assignee.name'
},
{
file: 'task-page/github/github-assignees-cell.tsx',
file: 'task-page/github/AssigneesCell.tsx',
fn: 'GHAssigneesCell',
login: 'user.login',
displayName: 'user.name'
},
{
file: 'task-page/github/pr-review-picker-panel.tsx',
fn: 'PRReviewPickerPanel',
file: 'task-page/github/ReviewerPicker.tsx',
fn: 'TaskPageGitHubReviewerPicker',
login: 'reviewer.login',
displayName: 'reviewer.name'
}
@@ -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')
@@ -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 (
<div className="px-3 py-1.5 pl-6 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageMetricPair cpu={values.cpu} memory={values.memory} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)
}
export function ResourceUsageAppSection({
app,
isCollapsed,
onToggle
}: {
app: AppMemory
isCollapsed: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<div className="border-t border-border/50">
<div className="flex items-center">
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e419d27083',
'Expand Orca'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.53dd5560ae',
'Collapse Orca'
)
}
aria-expanded={!isCollapsed}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')}
</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageSparkline samples={app.history} />
<ResourceUsageMetricPair cpu={app.cpu} memory={app.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!isCollapsed && (
<div className="border-t border-border/30">
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.81cd37af99',
'Main'
)}
values={app.main}
/>
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d406915b78',
'Renderer'
)}
values={app.renderer}
/>
{(app.other.cpu > 0 || app.other.memory > 0) && (
<AppSubRow
label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.0f9e50eb07',
'Other'
)}
values={app.other}
/>
)}
</div>
)}
</div>
)
}
@@ -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 (
<Dialog
open={killConfirm !== null}
onOpenChange={(next) => {
if (next) {
return
}
if (killing) {
return
}
setKillConfirm(null)
}}
>
<DialogContent
className="max-w-md"
showCloseButton={!killing}
onPointerDownOutside={(event) => {
if (killing) {
event.preventDefault()
}
}}
onEscapeKeyDown={(event) => {
if (killing) {
event.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0',
'Kill {{value0}}?',
{
value0:
killConfirm?.label ??
translate(
'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80',
'this session'
)
}
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49',
"Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone."
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setKillConfirm(null)} disabled={killing}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946d9f94d0',
'Cancel'
)}
</Button>
<Button variant="destructive" onClick={() => void runKillConfirmed()} disabled={killing}>
{killing ? <LoaderCircle className="size-4 animate-spin" /> : null}
{killing
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.41ae4fa725',
'Killing…'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b10695d6ce',
'Kill session'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
strokeWidth={1}
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted-foreground/70"
/>
</svg>
)
}
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 (
<div
className={cn(
METRIC_COLUMNS_CLS,
textClassName,
muted ? 'text-muted-foreground/50' : 'text-muted-foreground'
)}
>
<span className={CPU_COLUMN_CLS}>{formatMetricCpu(cpu)}</span>
<span className={MEM_COLUMN_CLS}>{formatMetricMemory(memory)}</span>
</div>
)
}
@@ -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 (
<PopoverContent
side="top"
align="end"
sideOffset={8}
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
// Why: xterm focus must not dismiss the resource manager after tab activation.
onFocusOutside={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')}
</span>
</div>
<div className="flex items-center gap-0.5">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<RotateCw className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
'Restart daemon'
)}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => daemonActions.setPending('killAll')}
disabled={daemonActions.isBusy}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
'Kill all sessions'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
{daemonUnreachable && (
<div className="flex items-start gap-2 border-b border-border bg-yellow-500/10 px-3 py-2 text-[11px] text-foreground">
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-yellow-500" />
<div className="flex-1">
<div className="font-medium">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4',
'Daemon is not responding'
)}
</div>
<div className="text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6',
'Resource snapshots and terminal sessions are unavailable.'
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => daemonActions.setPending('restart')}
disabled={daemonActions.isBusy}
>
<RotateCw className="mr-1 size-3" />
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.93b0de3c21',
'Restart'
)}
</Button>
</div>
)}
{!daemonUnreachable && sessionsOnlyError && (
<div
className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
role="status"
>
<AlertTriangle className="size-3 shrink-0 text-yellow-500" />
<span>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78',
'Terminal sessions unavailable. The list may be stale.'
)}
</span>
</div>
)}
{resourceSnapshot && (
<div className="px-3 py-2 border-b border-border flex items-baseline justify-between gap-3 text-xs tabular-nums">
<div className="flex items-baseline gap-3 min-w-0">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatCpu(totalCpu)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae',
'Combined CPU load. Values above 100% mean more than one core is working at once.'
)}
</TooltipContent>
</Tooltip>
<span className="text-muted-foreground/50">·</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="font-medium text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:rounded"
>
{formatMemory(totalMemory)}{' '}
<span className="font-normal text-muted-foreground">
{memoryMetricCopy.summaryLabel}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="z-[70] max-w-xs">
{memoryMetricCopy.description}
</TooltipContent>
</Tooltip>
</div>
{orphanCount > 0 && (
<span className="shrink-0 text-yellow-500" aria-live="polite">
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31',
'{{value0}} orphan',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3',
'{{value0}} orphans',
{ value0: orphanCount }
)}
</span>
)}
</div>
)}
{/* Why: fixed height prevents list expansion and polling from moving the popover. */}
<div ref={setPopoverBodyNode} tabIndex={-1} className="flex h-[420px] flex-col outline-none">
{(unifiedRepos.length > 0 || resourceSnapshot) && (
<div className="flex items-center justify-between px-3 py-1 bg-muted/30 border-b border-border/50 text-[10px] uppercase tracking-wide shrink-0">
<button
type="button"
onClick={() => setSortOption('name')}
className={cn(
'hover:text-foreground transition-colors',
sortOption === 'name' ? 'font-semibold text-foreground' : 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'name'}
>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.2aa2de6cb9',
'Name'
)}
</button>
<div className="flex items-center gap-2 shrink-0">
<div className={cn(METRIC_COLUMNS_CLS, 'text-[10px]')}>
<button
type="button"
onClick={() => setSortOption('cpu')}
className={cn(
CPU_COLUMN_CLS,
'hover:text-foreground transition-colors',
sortOption === 'cpu'
? 'font-semibold text-foreground'
: 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'cpu'}
>
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.298f4be7f2',
'CPU'
)}
</button>
<button
type="button"
onClick={() => setSortOption('memory')}
className={cn(
MEM_COLUMN_CLS,
'hover:text-foreground transition-colors',
sortOption === 'memory'
? 'font-semibold text-foreground'
: 'text-muted-foreground/80'
)}
aria-pressed={sortOption === 'memory'}
>
{memoryMetricCopy.columnLabel}
</button>
</div>
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
)}
<div className="flex-1 overflow-y-auto scrollbar-sleek">
{unifiedRepos.length > 0 && (
<ResourceUsageTree
repos={unifiedRepos}
sortOption={sortOption}
collapsedRepos={collapsedRepos}
toggleRepo={toggleRepo}
collapsedWorktrees={collapsedWorktrees}
activeWorktreeId={activeWorktreeId}
toggleWorktree={toggleWorktree}
navigateToWorktree={navigateToWorktree}
navigateToTab={navigateToTab}
onDelete={deleteWorktree}
onKillSession={handleKillSession}
/>
)}
{unifiedRepos.length === 0 && resourceSnapshot && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0',
'Nothing running right now'
)}
</div>
)}
{resourceSnapshot && (
<ResourceUsageAppSection
app={resourceSnapshot.app}
isCollapsed={appCollapsed}
onToggle={() => setAppCollapsed((value) => !value)}
/>
)}
{!resourceSnapshot && !daemonUnreachable && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
'Loading…'
)}
</div>
)}
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 shrink-0">
<button
type="button"
onClick={handleOpenWorkspaceCleanup}
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
<span className="min-w-0 truncate px-4 text-center">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
'Clean up workspaces'
)}
</span>
<ChevronRight className="absolute right-2.5 size-3.5 text-muted-foreground" aria-hidden />
</button>
{orphanCount > 0 ? (
<button
type="button"
onClick={() => void handleKillOrphans()}
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
{orphanCount === 1
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
'Kill {{value0}} orphan terminal',
{ value0: orphanCount }
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
'Kill {{value0}} orphan terminals',
{ value0: orphanCount }
)}
</button>
) : null}
</div>
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
</PopoverContent>
)
}
@@ -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<string>
toggleRepo: (repoId: string) => void
collapsedWorktrees: Set<string>
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 (
<ResourceUsageWorktreeRow
key={worktree.worktreeId}
worktree={worktree}
storeRecord={storeRecord}
activeWorktreeId={activeWorktreeId}
isCollapsed={collapsedWorktrees.has(worktree.worktreeId)}
onToggle={() => 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 (
<div key={group.repoId} className="border-b border-border/50 last:border-b-0">
<div className="flex items-center">
<button
type="button"
onClick={() => toggleRepo(group.repoId)}
className="pl-2 py-2 pr-0.5 transition-colors hover:bg-muted/50"
aria-label={
repoCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.b12e31dfcb',
'Expand repo'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.73a3fd68a9',
'Collapse repo'
)
}
>
{repoCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0 py-2 pr-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[11px] font-semibold uppercase tracking-wide truncate text-muted-foreground">
{group.repoName}
</span>
{group.hasRemoteChildren && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</span>
<div className="flex items-center gap-2 shrink-0">
<ResourceUsageMetricPair cpu={group.cpu} memory={group.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
</div>
{!repoCollapsed && (
<div className="border-t border-border/30">{group.worktrees.map(renderWorktree)}</div>
)}
</div>
)
})}
</>
)
}
@@ -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 (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
className="relative inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={
daemonUnreachable
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.59f178fe11',
'{{value0}}, daemon unreachable',
{ value0: resourceManagerAriaLabel }
)
: resourceManagerAriaLabel
}
>
{spaceScanReady ? (
<span
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
aria-hidden="true"
/>
) : null}
<MemoryStick className="size-3 text-muted-foreground" />
{!iconOnly && (
<>
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
{memBadgeLabel}
</span>
<span className="text-muted-foreground/50">·</span>
<Terminal className="size-3 text-muted-foreground" />
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
{orphanCount > 0 && (
<span className="text-yellow-500 ml-0.5">({orphanCount})</span>
)}
</span>
</>
)}
{iconOnly && triggerSessionCount > 0 && (
<span className="text-[11px] tabular-nums text-muted-foreground">
{triggerSessionCount}
</span>
)}
{daemonUnreachable && (
<AlertTriangle
className="size-3 text-yellow-500"
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.ca95d077db',
'Daemon unreachable'
)}
/>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
<div className="space-y-0.5">
{resourceManagerTooltipLines.map((line) => (
<div key={line.id} className={line.emphasized ? 'text-primary' : ''}>
{line.text}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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 (
<div
className={cn(
'group/sessrow flex items-center gap-2 pl-10 pr-3 py-1.5',
clickable && 'cursor-pointer hover:bg-accent/40'
)}
onClick={clickable ? handleClick : undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
onKeyDown={
clickable
? (event) => {
if (isResourceSessionActivationKey(event.key)) {
event.preventDefault()
handleClick()
}
}
: undefined
}
data-worktree-id={worktreeId}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
session.bound ? 'bg-emerald-500' : 'bg-muted-foreground/40'
)}
/>
<span className="text-[11px] text-muted-foreground truncate min-w-0 flex-1">
{session.label}
</span>
<ResourceUsageMetricPair cpu={session.cpu} memory={session.memory} size="small" />
{/* Why: the shared gutter aligns columns while keeping orphan kills visible. */}
<span className={ROW_TRAILING_GUTTER_CLS}>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onKill(session)
}}
className={cn(
'rounded p-0.5 text-muted-foreground transition-opacity hover:bg-destructive/10 hover:text-destructive',
session.bound &&
'can-hover:opacity-0 group-hover/sessrow:opacity-100 group-focus-within/sessrow:opacity-100 focus-visible:opacity-100'
)}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.fa6d36758d',
'Kill session {{value0}}',
{ value0: session.sessionId }
)}
>
<X className="size-3" />
</button>
</span>
</div>
)
}
function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element {
const label = browser.title?.trim() || browser.label?.trim() || browser.url
return (
<div className="flex items-center gap-2 pl-10 pr-3 py-1.5">
<Globe className="size-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{label}</span>
<ResourceUsageMetricPair cpu={null} memory={null} size="small" />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
)
}
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 (
<div className="border-b border-border/20 last:border-b-0">
<div className="group/wtrow flex items-center ml-2 transition-colors hover:bg-muted/60">
{hasResources ? (
<button
type="button"
onClick={onToggle}
className="pl-2 py-2 pr-0.5 shrink-0"
aria-label={
isCollapsed
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.c4a8968bdd',
'Expand workspace'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.bbcd9b7b85',
'Collapse workspace'
)
}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
) : (
<span
className="pl-2 py-2 pr-0.5 shrink-0 w-[calc(0.5rem+0.75rem+0.125rem)]"
aria-hidden
/>
)}
<button
type="button"
onClick={onNavigate}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.d659d71d2d',
'Resume workspace {{value0}}',
{ value0: rowLabel }
)}
className="flex-1 min-w-0 py-2 pr-2 pl-1 text-left flex items-center gap-1.5"
disabled={!isNavigable}
>
<span className="text-xs font-medium truncate">{rowLabel}</span>
{worktree.isRemote && (
<span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/70">
{translate(
'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1',
'· remote'
)}
</span>
)}
</button>
<div className="flex items-center gap-2 shrink-0 pr-3">
<div className="relative">
<span
className={cn(
'block transition-opacity',
showWorktreeActions &&
'group-hover/wtrow:opacity-0 group-hover/wtrow:pointer-events-none group-focus-within/wtrow:opacity-0 group-focus-within/wtrow:pointer-events-none [@media(hover:none)]:opacity-0 [@media(hover:none)]:pointer-events-none'
)}
aria-hidden={showWorktreeActions ? undefined : true}
>
<ResourceUsageSparkline samples={worktree.history} />
</span>
{showWorktreeActions && (
<div className="absolute inset-0 flex items-center justify-end gap-0.5 can-hover:opacity-0 can-hover:pointer-events-none transition-opacity group-hover/wtrow:opacity-100 group-hover/wtrow:pointer-events-auto group-focus-within/wtrow:opacity-100 group-focus-within/wtrow:pointer-events-auto">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onDelete}
disabled={isMainWorktree}
aria-label={translate(
'auto.components.status.bar.ResourceUsageStatusSegment.16bc3c998a',
'Delete workspace {{value0}}',
{ value0: rowLabel }
)}
className={cn(
'p-0.5 rounded text-muted-foreground transition-colors',
isMainWorktree
? 'opacity-40 cursor-not-allowed'
: 'hover:bg-destructive/10 hover:text-destructive'
)}
>
<Trash2 className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={4}
className="z-[70] max-w-[200px] text-pretty"
>
{isMainWorktree
? translate(
'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a',
'The main workspace cannot be deleted.'
)
: translate(
'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458',
'Delete workspace.'
)}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<ResourceUsageMetricPair cpu={worktree.cpu} memory={worktree.memory} />
<span className={ROW_TRAILING_GUTTER_CLS} aria-hidden />
</div>
</div>
{!isCollapsed &&
worktree.sessions.map((session) => (
<ResourceUsageSessionRow
key={session.sessionId}
session={session}
worktreeId={worktree.worktreeId}
onNavigate={navigateToTab}
onKill={onKillSession}
/>
))}
{!isCollapsed &&
worktree.browsers.map((browser) => <BrowserRow key={browser.id} browser={browser} />)}
</div>
)
}
@@ -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
}
@@ -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<ResourceUsageSortOption>('memory')
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
const [appCollapsed, setAppCollapsed] = useState(true)
const {
sessionInventory,
sessionsError,
refreshSessions,
clearSessionsError,
removeSession,
removeSessions
} = useResourceSessionInventory(workspaceSessionReady)
const sessions = sessionInventory.sessions
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
const [killing, setKilling] = useState(false)
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
() => ({
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<ResourceSessionBindingInputs>(
() => ({
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
}),
[
ptyIdsByTabId,
tabsByWorktree,
terminalLayoutsByTabId,
deferredSshSessionIdsByTabId,
workspaceSessionReady
]
)
const popoverBodyRef = useRef<HTMLDivElement | null>(null)
const popoverBodyFocusFrameRef = useRef<number | null>(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<typeof useResourceUsageFoundation>
@@ -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<string, string>()
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<string, string | null>()
for (const repo of repos) {
map.set(repo.id, repo.connectionId ?? null)
}
return map
}, [repos])
const repoRuntimeScopedById = useMemo(() => {
const map = new Map<string, boolean>()
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<typeof useResourceUsageProjection>
@@ -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>): 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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({
topLevelItems: [item('node_modules', 400), item('src', 100)],
omittedTopLevelItemCount: 7,
omittedTopLevelSizeBytes: 900
})}
/>
)
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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({
topLevelItems: [item('src', 250)],
omittedTopLevelItemCount: 3,
omittedTopLevelSizeBytes: 1000
})}
/>
)
const widths = Array.from(container.querySelectorAll<HTMLElement>('div[style]')).map(
(node) => node.style.width
)
expect(widths).toEqual(['25%', '100%'])
})
it('omits the aggregate row when nothing was omitted', () => {
const { container } = render(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({ topLevelItems: [item('src', 250)] })}
/>
)
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(
<WorkspaceSpaceBreakdownList
isScanning={false}
worktree={worktree({ omittedTopLevelItemCount: 4, omittedTopLevelSizeBytes: 80 })}
/>
)
expect(container.textContent).not.toContain('No files found.')
expect(renderedRowNames(container)).toEqual(['Other top-level items (4)'])
})
})
@@ -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 (
<div className="min-h-72 rounded-lg border border-border/70 bg-background/35">
<div className="border-b border-border/60 px-4 py-3">
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({
)}
</span>
</div>
) : worktree.topLevelItems.length === 0 ? (
) : topLevelItemCount === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079',
@@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({
{worktree.topLevelItems.slice(0, 12).map((item) => (
<BreakdownRow key={`${item.path}:${item.name}`} item={item} maxSize={maxChildSize} />
))}
{omittedItem ? <BreakdownRow item={omittedItem} maxSize={maxChildSize} /> : null}
</div>
</div>
)}
@@ -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' }),
@@ -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', () => {
@@ -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<Element>
@@ -41,10 +41,12 @@ export function mergeReviewerSuggestions(
byLogin.set(key, user)
continue
}
if (!existing.avatarUrl && user.avatarUrl) {
// Why: seeds carry login only; backfill display fields from the metadata query.
if ((!existing.avatarUrl && user.avatarUrl) || (!existing.name && user.name)) {
byLogin.set(key, {
...existing,
avatarUrl: user.avatarUrl
avatarUrl: existing.avatarUrl || user.avatarUrl,
name: existing.name ?? user.name
})
}
}
@@ -4,7 +4,7 @@ import {
mergeReviewerSuggestions,
resolveTaskPullRequestRepo,
sameOptionalGitHubOwnerRepo
} from './github-reviewer-suggestions'
} from './task-page-github-review-model'
describe('sameOptionalGitHubOwnerRepo', () => {
it('treats two missing values as equal', () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { isGitLabIssueFilter, isGitLabMRFilter } from './gitlab-task-filters'
import { isGitLabIssueFilter, isGitLabMRFilter } from './task-page-source-context'
describe('isGitLabMRFilter', () => {
it('accepts MR statuses including all', () => {
@@ -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'
'./task-page/linear/IssueDialog.tsx',
'./task-page/linear/IssueAttributes.tsx'
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8'))
/** The extracted Linear "New Issue" dialog and its attribute pickers. */
@@ -1,14 +1,16 @@
import { describe, expect, it } from 'vitest'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { LinearIssue } from '../../../shared/linear/issue-types'
import {
findLinearWorkflowStateForStatus,
getLinearStatusSectionState,
mergeLinearCollectionResults
} from './task-page-linear-issue-model'
import {
compareLinearIssues,
findLinearWorkflowStateForStatus,
getLinearIssueGridTemplate,
getLinearPriorityRank,
getLinearStatusSectionState,
groupLinearIssues,
mergeLinearCollectionResults
} from './linear-issue-grouping'
groupLinearIssues
} from './task-page-linear-jira-list-model'
function issue(
overrides: Partial<LinearIssue> & Pick<LinearIssue, 'id' | 'identifier'>
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './repo-source-context'
import type { Repo } from '../../../../../shared/repo-types'
import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './task-page-source-context'
import type { Repo } from '../../../shared/repo-types'
describe('buildGitLabProviderIdentity', () => {
it('splits namespace and project and builds the web URL', () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { TaskPageRepoSourceState } from '@/components/task-page-cache-selectors'
import { hasDivergentSources, hasUpstreamCandidateDivergence } from './repo-source-divergence'
import { hasDivergentSources, hasUpstreamCandidateDivergence } from './task-page-draft-storage'
function state(
sources: Partial<NonNullable<TaskPageRepoSourceState['sources']>>
@@ -7,7 +7,8 @@ import {
readHookDefinitions
} from './refactor-hook-contract.test-support'
const TASK_PAGE_SOURCE_PATTERN = /^(?:TaskPage.*\.tsx|use-task-page-.*\.ts|task-page-.*\.tsx?)$/
const TASK_PAGE_FLAT_SOURCE_PATTERN = /^(?:use-task-page-.*\.ts|task-page-.*\.tsx?)$/
const TASK_PAGE_DIRECTORY = 'task-page'
const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([
'task-page-draft-storage.tsx',
'task-page-github-landing-refresh-run.tsx',
@@ -19,18 +20,34 @@ const TASK_PAGE_EXTRACTED_LOWERCASE_FILES = new Set([
'task-page-source-context.tsx'
])
export const TASK_PAGE_SOURCE_FILES = readdirSync(__dirname)
.filter(
(name) =>
TASK_PAGE_SOURCE_PATTERN.test(name) &&
!name.includes('.test.') &&
!name.includes('.test-support.')
function isSourceFile(name: string): boolean {
return !name.includes('.test.') && !name.includes('.test-support.')
}
// Why: the components/task-page tree nests by provider, so a flat readdir would silently
// return an empty family and turn every ratchet built on it into a no-op.
function readTaskPageDirectory(relativeDirectory: string): string[] {
return readdirSync(join(__dirname, relativeDirectory), { withFileTypes: true }).flatMap(
(entry) => {
const relativePath = `${relativeDirectory}/${entry.name}`
if (entry.isDirectory()) {
return readTaskPageDirectory(relativePath)
}
return /\.tsx?$/.test(entry.name) && isSourceFile(entry.name) ? [relativePath] : []
}
)
.sort()
}
export const TASK_PAGE_SOURCE_FILES = [
...readdirSync(__dirname).filter(
(name) => TASK_PAGE_FLAT_SOURCE_PATTERN.test(name) && isSourceFile(name)
),
...readTaskPageDirectory(TASK_PAGE_DIRECTORY)
].sort()
export const TASK_PAGE_REFACTOR_SOURCE_FILES = TASK_PAGE_SOURCE_FILES.filter(
(name) =>
/^TaskPage.*\.tsx$/.test(name) ||
(name.startsWith(`${TASK_PAGE_DIRECTORY}/`) && name.endsWith('.tsx')) ||
/^use-task-page-.*\.ts$/.test(name) ||
TASK_PAGE_EXTRACTED_LOWERCASE_FILES.has(name)
)
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { readTaskPageSource } from './task-page-source-family.test-support'
const CONTENT_SOURCE = readTaskPageSource('TaskPageContent.tsx')
const SOURCE_BAR_SOURCE = readTaskPageSource('TaskPageSourceBar.tsx')
const CONTENT_SOURCE = readTaskPageSource('task-page/Content.tsx')
const SOURCE_BAR_SOURCE = readTaskPageSource('task-page/SourceBar.tsx')
const SOURCE_CONTEXT_SOURCE = readTaskPageSource('task-page-source-context.tsx')
const RUNTIME_HOSTS_SOURCE = readTaskPageSource('use-task-page-runtime-hosts.ts')
const GITHUB_DETAIL_SOURCE = readTaskPageSource('use-task-page-github-detail.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('./task-page/linear/Filters.tsx', import.meta.url),
'utf8'
)
const jiraFilters = readFileSync(
new URL('./task-page/chrome/task-page-jira-filters.tsx', import.meta.url),
new URL('./task-page/jira/Filters.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(
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version'
import type { ExecutionHostRegistryEntry } from '../../../../../shared/execution-host-registry'
import { getTaskSourceHostAvailabilityForHost } from './task-source-host-availability'
import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import { getTaskSourceHostAvailabilityForHost } from './task-page-source-context'
// Why: a complete typed entry keeps these cases honest when the registry shape changes.
function hostEntry(
@@ -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, 'task-page/Content.tsx'), 'utf8')
const actions = readFileSync(join(__dirname, 'task-page/github/Rows.tsx'), 'utf8')
expect(detail.match(/onUse=\{\(item\) => \{/g)).toHaveLength(2)
expect(actions.match(/onSelect=\{\(\) => handleUseWorkItem\(item\)\}/g)).toHaveLength(2)
})
@@ -1,11 +1,11 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import PullRequestPage from '@/components/PullRequestPage'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import { TaskPageGitHubList } from './TaskPageGitHubList'
import { TaskPageGitLabTodoList } from './TaskPageGitLabTodoList'
import { TaskPageGitLabItemList } from './TaskPageGitLabItemList'
import { TaskPageJiraContent } from './TaskPageJiraContent'
import { TaskPageGitHubList } from './github/List'
import { TaskPageGitLabTodoList } from './gitlab/TodoList'
import { TaskPageGitLabItemList } from './gitlab/ItemList'
import { TaskPageJiraContent } from './jira/Content'
export function TaskPageContent({
model
}: {
@@ -1,6 +1,6 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageListChrome } from './TaskPageListChrome'
import { TaskPageContent } from './TaskPageContent'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { TaskPageListChrome } from './ListChrome'
import { TaskPageContent } from './Content'
export function TaskPageFrame({
model
}: {
@@ -1,9 +1,9 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { cn } from '@/lib/utils'
import { TaskPageSourceBar } from './TaskPageSourceBar'
import { TaskPageSourceBar } from './SourceBar'
import { AlertCircle } from 'lucide-react'
import { TaskPageGitHubModeControls } from './TaskPageGitHubModeControls'
import { TaskPageProviderFilters } from './TaskPageProviderFilters'
import { TaskPageGitHubModeControls } from './github/ModeControls'
import { TaskPageProviderFilters } from './ProviderFilters'
export function TaskPageListChrome({
model
}: {
@@ -1,8 +1,8 @@
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
import { TaskPageGitHubFilters } from './TaskPageGitHubFilters'
import { TaskPageLinearFilters } from './TaskPageLinearFilters'
import { TaskPageJiraFilters } from './TaskPageJiraFilters'
import { TaskPageGitLabFilters } from './TaskPageGitLabFilters'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { TaskPageGitHubFilters } from './github/Filters'
import { TaskPageLinearFilters } from './linear/Filters'
import { TaskPageJiraFilters } from './jira/Filters'
import { TaskPageGitLabFilters } from './gitlab/Filters'
export function TaskPageProviderFilters({
model
}: {

Some files were not shown because too many files have changed in this diff Show More