Merge current main into clean terminal speed stack

# Conflicts:
#	src/main/daemon/daemon-pty-adapter.ts
#	src/main/daemon/headless-emulator.test.ts
#	src/main/daemon/headless-emulator.ts
#	src/main/runtime/orca-runtime.ts
This commit is contained in:
Jinwoo-H
2026-06-18 18:15:59 -07:00
157 changed files with 7387 additions and 1663 deletions
+1
View File
@@ -9,6 +9,7 @@
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/wsl.ts"
],
"compilerOptions": {
@@ -88,6 +88,7 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -1306,6 +1307,7 @@ export default function SessionScreen() {
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
const ref = getTerminalRef(handle)
// Why: previously we set `initializedHandlesRef` even when the
// WebView wasn't mounted yet (ref=null). The init message went
@@ -1320,7 +1322,7 @@ export default function SessionScreen() {
})
return
}
ref.init(cols, rows, initialData)
ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
setTerminalModes((prev) =>
@@ -1424,8 +1426,9 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true)
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
} else {
getTerminalRef(handle)?.resize(cols, rows)
}
@@ -0,0 +1,64 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
const BROWSER_KEYS = ['Enter', 'Backspace', 'Tab', 'Escape'] as const
type Props = {
disabled: boolean
onKeypress: (key: string) => void
}
export function MobileBrowserKeyRow({ disabled, onKeypress }: Props): React.JSX.Element {
return (
<View style={styles.keyRow}>
{BROWSER_KEYS.map((key) => (
<Pressable
key={key}
style={({ pressed }) => [
styles.keyButton,
pressed && styles.keyButtonPressed,
disabled && styles.disabled
]}
disabled={disabled}
onPress={() => onKeypress(key)}
>
<Text style={[styles.keyButtonText, disabled && styles.disabledText]}>
{key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
</Text>
</Pressable>
))}
</View>
)
}
const styles = StyleSheet.create({
keyRow: {
flexDirection: 'row',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs
},
keyButton: {
minHeight: 30,
minWidth: 42,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm
},
keyButtonPressed: {
backgroundColor: colors.borderSubtle
},
keyButtonText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
+26 -43
View File
@@ -36,6 +36,11 @@ import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest
} from './browser-screencast-request'
import {
MobileBrowserPointerModifiers,
type BrowserPointerModifier
} from './MobileBrowserPointerModifiers'
import { MobileBrowserKeyRow } from './MobileBrowserKeyRow'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -150,6 +155,7 @@ export function MobileBrowserPane({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dialog, setDialog] = useState<BrowserDialogState | null>(null)
const [pointerModifiers, setPointerModifiers] = useState<BrowserPointerModifier[]>([])
const [zoom, setZoom] = useState<BrowserZoomState>(DEFAULT_ZOOM)
const [layout, setLayout] = useState<BrowserTouchLayout | null>(null)
const [appActive, setAppActive] = useState(AppState.currentState === 'active')
@@ -652,6 +658,7 @@ export function MobileBrowserPane({
x: point.x,
y: point.y,
button,
modifiers: pointerModifiers,
...(button === 'left'
? {
radius: computeBrowserTouchClickRadiusCss(
@@ -665,7 +672,7 @@ export function MobileBrowserPane({
},
{ suppressError: true, timeoutMs: 5_000 }
)
if (clickResult !== null) {
if (clickResult !== null || pointerModifiers.length > 0) {
return
}
try {
@@ -687,9 +694,17 @@ export function MobileBrowserPane({
// actionable failures still surface through navigation/stream errors.
}
},
[client, pageParams, sendBrowserRequest]
[client, pageParams, pointerModifiers, sendBrowserRequest]
)
const togglePointerModifier = useCallback((modifier: BrowserPointerModifier) => {
setPointerModifiers((current) =>
current.includes(modifier)
? current.filter((candidate) => candidate !== modifier)
: [...current, modifier]
)
}, [])
const sendWheel = useCallback(
(point: BrowserPoint, screenDx: number, screenDy: number) => {
const base = pageParams()
@@ -1223,24 +1238,15 @@ export function MobileBrowserPane({
{ paddingBottom: bottomInset, transform: [{ translateY: -keyboardLift }] }
]}
>
<View style={styles.keyRow}>
{['Enter', 'Backspace', 'Tab', 'Escape'].map((key) => (
<Pressable
key={key}
style={({ pressed }) => [
styles.keyButton,
pressed && styles.keyButtonPressed,
controlsDisabled && styles.disabled
]}
disabled={controlsDisabled}
onPress={() => void sendKeypress(key)}
>
<Text style={[styles.keyButtonText, controlsDisabled && styles.disabledText]}>
{key === 'Backspace' ? '⌫' : key === 'Escape' ? 'Esc' : key}
</Text>
</Pressable>
))}
</View>
<MobileBrowserPointerModifiers
disabled={controlsDisabled}
selectedModifiers={pointerModifiers}
onToggle={togglePointerModifier}
/>
<MobileBrowserKeyRow
disabled={controlsDisabled}
onKeypress={(key) => void sendKeypress(key)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.keyboardInput}
@@ -1628,29 +1634,6 @@ const styles = StyleSheet.create({
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
keyRow: {
flexDirection: 'row',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs
},
keyButton: {
minHeight: 30,
minWidth: 42,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm
},
keyButtonPressed: {
backgroundColor: colors.borderSubtle
},
keyButtonText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -0,0 +1,95 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export type BrowserPointerModifier = 'cmd' | 'ctrl' | 'alt' | 'shift'
const BROWSER_POINTER_MODIFIERS: { id: BrowserPointerModifier; label: string }[] = [
{ id: 'cmd', label: 'Cmd' },
{ id: 'ctrl', label: 'Ctrl' },
{ id: 'alt', label: 'Alt' },
{ id: 'shift', label: 'Shift' }
]
type Props = {
disabled: boolean
selectedModifiers: BrowserPointerModifier[]
onToggle: (modifier: BrowserPointerModifier) => void
}
export function MobileBrowserPointerModifiers({
disabled,
selectedModifiers,
onToggle
}: Props): React.JSX.Element {
return (
<View style={styles.modifierRow}>
{BROWSER_POINTER_MODIFIERS.map((modifier) => {
const selected = selectedModifiers.includes(modifier.id)
return (
<Pressable
key={modifier.id}
style={({ pressed }) => [
styles.keyButton,
selected && styles.keyButtonSelected,
pressed && !selected && styles.keyButtonPressed,
disabled && styles.disabled
]}
disabled={disabled}
onPress={() => onToggle(modifier.id)}
accessibilityRole="button"
accessibilityState={{ selected, disabled }}
accessibilityLabel={`${modifier.label} click modifier`}
>
<Text
style={[
styles.keyButtonText,
selected && styles.keyButtonTextSelected,
disabled && styles.disabledText
]}
>
{modifier.label}
</Text>
</Pressable>
)
})}
</View>
)
}
const styles = StyleSheet.create({
modifierRow: {
flexDirection: 'row',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs
},
keyButton: {
minHeight: 30,
minWidth: 42,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm
},
keyButtonPressed: {
backgroundColor: colors.borderSubtle
},
keyButtonSelected: {
backgroundColor: colors.textPrimary
},
keyButtonText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
keyButtonTextSelected: {
color: colors.bgBase
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
+17 -2
View File
@@ -4,11 +4,13 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import { XTERM_HTML } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
type TerminalOscLinks = TerminalOscLinkRange[]
export type TerminalModes = {
bracketedPasteMode: boolean
@@ -46,7 +48,13 @@ export type TerminalSelectionEvents = {
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (cols: number, rows: number, initialData?: string, preserveScroll?: boolean) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
@@ -276,7 +284,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
write(data: string) {
postMessage({ type: 'write', data })
},
init(cols: number, rows: number, initialData?: string, preserveScroll?: boolean) {
init(
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Resolve any prior in-flight ready first so awaiters from the
@@ -298,6 +312,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
@@ -45,6 +45,10 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
expect(new Set(ids).size).toBe(ids.length)
})
it('keeps all built-in terminal keys byte-backed', () => {
expect(TERMINAL_ACCESSORY_KEYS.every((key) => key.bytes.length > 0)).toBe(true)
})
it('keeps repeat behavior explicit for built-in terminal keys', () => {
const repeatableIds = new Set([
'backspace',
+12 -23
View File
@@ -294,27 +294,23 @@ function buildShortcutBytes(key: string, modifiers: TerminalShortcutModifier[]):
return buildCsiTildeShortcut(csiTilde, modifiers)
}
if (key === 'tab') {
if (
hasModifier(modifiers, 'shift') &&
!hasModifier(modifiers, 'ctrl') &&
!hasModifier(modifiers, 'alt')
) {
if (modifiers.includes('shift') && !modifiers.includes('ctrl') && !modifiers.includes('alt')) {
return `${ESC}[Z`
}
const bytes = '\t'
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'escape') {
const bytes = ESC
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'enter') {
const bytes = '\r'
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (key === 'backspace') {
const bytes = hasModifier(modifiers, 'ctrl') ? '\b' : '\x7f'
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
const bytes = modifiers.includes('ctrl') ? '\b' : '\x7f'
return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
if (isPrintableShortcutKey(key)) {
return buildPrintableShortcutBytes(key, modifiers)
@@ -326,16 +322,16 @@ function buildPrintableShortcutBytes(
key: string,
modifiers: TerminalShortcutModifier[]
): string | null {
const shifted = hasModifier(modifiers, 'shift') ? applyShift(key) : key
const shifted = modifiers.includes('shift') ? applyShift(key) : key
let bytes = shifted
if (hasModifier(modifiers, 'ctrl')) {
if (modifiers.includes('ctrl')) {
const ctrlBytes = controlBytesForPrintable(shifted)
if (ctrlBytes == null) {
return null
}
bytes = ctrlBytes
}
return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes
return modifiers.includes('alt') ? `${ESC}${bytes}` : bytes
}
function buildCsiFinalShortcut(final: string, modifiers: TerminalShortcutModifier[]): string {
@@ -350,13 +346,13 @@ function buildCsiTildeShortcut(code: number, modifiers: TerminalShortcutModifier
function csiModifierParameter(modifiers: TerminalShortcutModifier[]): number {
let parameter = 1
if (hasModifier(modifiers, 'shift')) {
if (modifiers.includes('shift')) {
parameter += 1
}
if (hasModifier(modifiers, 'alt')) {
if (modifiers.includes('alt')) {
parameter += 2
}
if (hasModifier(modifiers, 'ctrl')) {
if (modifiers.includes('ctrl')) {
parameter += 4
}
return parameter
@@ -413,10 +409,3 @@ function displayKeyLabel(key: string): string {
}
return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key
}
function hasModifier(
modifiers: TerminalShortcutModifier[],
modifier: TerminalShortcutModifier
): boolean {
return modifiers.includes(modifier)
}
@@ -0,0 +1,18 @@
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
export type { TerminalOscLinkRange }
export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] {
return (
Array.isArray(value) &&
value.every(
(entry) =>
entry != null &&
typeof entry === 'object' &&
Number.isInteger((entry as TerminalOscLinkRange).row) &&
Number.isInteger((entry as TerminalOscLinkRange).startCol) &&
Number.isInteger((entry as TerminalOscLinkRange).endCol) &&
typeof (entry as TerminalOscLinkRange).uri === 'string'
)
)
}
+60 -7
View File
@@ -47,7 +47,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/css/xterm.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/css/xterm.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -66,7 +66,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
transform-origin: top left;
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; }
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
@@ -192,12 +192,17 @@ export const XTERM_HTML = `<!DOCTYPE html>
<button id="sel-menu-all">Select All</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/lib/xterm.min.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.10.0-beta.285/lib/addon-unicode11.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.20.0-beta.284/lib/addon-webgl.js"></script>
<script>
(function() {
var surface = document.getElementById('terminal-surface');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa);
var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e);
var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f);
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var scrollIndicator = document.getElementById('scroll-indicator');
@@ -269,6 +274,8 @@ export const XTERM_HTML = `<!DOCTYPE html>
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
var sgrMousePixelsMode = false;
var initialOscLinks = [], initialOscLinkRowOffset = 0;
var initialOscLinkEvictionReady = false;
var mouseModeScanTail = '';
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
@@ -552,8 +559,36 @@ export const XTERM_HTML = `<!DOCTYPE html>
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(data);
writeQueue.push(normalizeStatusDotPresentation(data));
}
function nextQueuedWrite() {
@@ -621,7 +656,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
pumpWrites(terminalGeneration);
}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll) {
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
// Why: a width-reflow re-stream rewraps the same content at new cols.
// Distance-from-bottom (rows) is the only stable anchor across reflow,
@@ -632,6 +667,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
var gen = terminalGeneration;
ready = false;
resetWriteQueue();
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
initRows = rows || 24;
@@ -653,6 +689,9 @@ export const XTERM_HTML = `<!DOCTYPE html>
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var oldTerm = term;
var oldSurface = surface;
var nextSurface = null;
@@ -675,8 +714,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
fontFamily: '"Menlo", "Consolas", "DejaVu Sans Mono", monospace',
fontFamily: '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace',
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
disableStdin: true,
cursorBlink: false,
@@ -686,6 +727,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
allowProposedApi: true
});
term.open(surface);
if (window.WebglAddon && window.WebglAddon.WebglAddon) {
try { var webglAddon = new window.WebglAddon.WebglAddon(); term.loadAddon(webglAddon); if (webglAddon.onContextLoss) webglAddon.onContextLoss(function() { try { webglAddon && webglAddon.dispose && webglAddon.dispose(); } catch (e) {} }); } catch (e) {}
}
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
}
@@ -713,6 +758,9 @@ export const XTERM_HTML = `<!DOCTYPE html>
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {}
}
captureInitialOscLinkTexts();
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = true;
applyFitScale('init-replay');
notify({ type: 'ready', cols: cols, rows: rows });
});
@@ -815,7 +863,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
if (handledMessageIds.length > 256) handledMessageIds.shift();
}
if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll);
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
@@ -833,12 +881,16 @@ export const XTERM_HTML = `<!DOCTYPE html>
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue();
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
initialOscLinks = [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
resetEvictionCounter();
@@ -936,6 +988,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
@@ -1,4 +1,5 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
export type TerminalWebViewCommand =
| { type: 'write'; id?: number; data: string }
@@ -8,6 +9,7 @@ export type TerminalWebViewCommand =
cols: number
rows: number
initialData?: string
oscLinks?: TerminalOscLinkRange[]
terminalTheme?: RuntimeMobileTerminalTheme
fontScale?: number
// Why: width-reflow re-streams replay the same content rewrapped at new
@@ -19,8 +19,7 @@ function bodyMarkup(): string {
}
// Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells.
function makeTerminal(line: string) {
const lines = [line]
function makeTerminal(lineRef: { current: string }) {
return {
cols: 80,
rows: 24,
@@ -32,11 +31,11 @@ function makeTerminal(line: string) {
active: {
viewportY: 0,
baseY: 0,
length: lines.length,
length: 1,
cursorY: 0,
type: 'normal' as const,
getLine(row: number) {
const text = lines[row]
const text = row === 0 ? lineRef.current : undefined
if (text === undefined) {
return null
}
@@ -73,11 +72,17 @@ function makeTerminal(line: string) {
type Posted = Array<Record<string, unknown>>
function boot(line: string): { posted: Posted } {
type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string }
function boot(
line: string,
oscLinks?: OscLinkRange[]
): { posted: Posted; setLine: (line: string) => void } {
const posted: Posted = []
const lineRef = { current: line }
const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown }
w.Terminal = function () {
return makeTerminal(line)
return makeTerminal(lineRef)
}
w.ReactNativeWebView = {
postMessage(s: string) {
@@ -89,10 +94,15 @@ function boot(line: string): { posted: Posted } {
new Function(iifeSource())()
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '' })
data: JSON.stringify({ type: 'init', cols: 80, rows: 24, initialData: '', oscLinks })
})
)
return { posted }
return {
posted,
setLine: (nextLine: string) => {
lineRef.current = nextLine
}
}
}
function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void {
@@ -114,6 +124,7 @@ describe('terminal WebView tap routing', () => {
const URL_LINE = 'visit https://example.com/foo now'
const tapX = 12 * 8 * 0.3125
const tapY = 2
const screenXForCol = (col: number): number => col * 8 * 0.3125
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true })
@@ -152,6 +163,40 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo')
})
it('opens first-load OSC links from snapshot metadata on the exact cell range', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted } = boot('issue #1234 done', oscLinks)
await settle()
fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234')
})
it('does not open snapshot OSC links from adjacent terminal cells', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted } = boot('issue #1234 done', oscLinks)
await settle()
fireTouch('touchstart', [{ x: screenXForCol(12), y: tapY }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
})
it('does not open stale snapshot OSC links after the row text changes', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted, setLine } = boot('issue #1234 done', oscLinks)
await settle()
setLine('issue plain done')
fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
})
it('does not post open-url for a scroll gesture past the tap slop', async () => {
const { posted } = boot(URL_LINE)
await settle()
@@ -1,26 +1,133 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
const terminalWebViewSource = readFileSync(
new URL('./TerminalWebView.tsx', import.meta.url),
'utf8'
)
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
function extractStatusDotNormalizer() {
const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' var PRIVATE_MODE_SCAN_TAIL_LIMIT')
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
const functionEnd = terminalHtmlSource.indexOf('\n\n function enqueueWrite', functionStart)
expect(declarationStart).toBeGreaterThanOrEqual(0)
expect(declarationEnd).toBeGreaterThan(declarationStart)
expect(functionStart).toBeGreaterThan(declarationEnd)
expect(functionEnd).toBeGreaterThan(functionStart)
return `${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
}
function normalizeStatusDotChunks(chunks: string[]) {
const context: { chunks: string[]; output?: string } = { chunks }
new Script(`
${extractStatusDotNormalizer()}
output = chunks.map(function(chunk) { return normalizeStatusDotPresentation(chunk); }).join('');
`).runInNewContext(context)
return context.output ?? ''
}
describe('TerminalWebView text zoom', () => {
it('pins textZoom to 100 so Android system font scale cannot inflate glyphs past xterm cell metrics', () => {
const start = source.indexOf('<WebView')
const start = terminalWebViewSource.indexOf('<WebView')
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf('/>', start)
const end = terminalWebViewSource.indexOf('/>', start)
expect(end).toBeGreaterThan(start)
const webViewProps = source.slice(start, end)
const webViewProps = terminalWebViewSource.slice(start, end)
expect(webViewProps).toContain('textZoom={100}')
})
it('keeps the HTML source object stable so parent renders do not reload xterm', () => {
const start = source.indexOf('<WebView')
const start = terminalWebViewSource.indexOf('<WebView')
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf('/>', start)
const end = terminalWebViewSource.indexOf('/>', start)
expect(end).toBeGreaterThan(start)
const webViewProps = source.slice(start, end)
expect(source).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
const webViewProps = terminalWebViewSource.slice(start, end)
expect(terminalWebViewSource).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
})
it('forces the Claude status dot to text presentation before xterm writes', () => {
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
expect(terminalHtmlSource).toContain('var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)')
expect(terminalHtmlSource).toContain('TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)')
expect(terminalHtmlSource).toContain(
'EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)'
)
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
)
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
const dot = String.fromCharCode(0x23fa)
const textSelector = String.fromCharCode(0xfe0e)
const emojiSelector = String.fromCharCode(0xfe0f)
const textDot = dot + textSelector
expect(normalizeStatusDotChunks([dot])).toBe(textDot)
expect(normalizeStatusDotChunks([dot + emojiSelector])).toBe(textDot)
expect(normalizeStatusDotChunks([dot + textSelector])).toBe(textDot)
expect(normalizeStatusDotChunks([dot + textSelector + emojiSelector])).toBe(textDot)
expect(normalizeStatusDotChunks([dot, emojiSelector, ' ready'])).toBe(`${textDot} ready`)
expect(normalizeStatusDotChunks([dot, textSelector, ' ready'])).toBe(`${textDot} ready`)
expect(normalizeStatusDotChunks([dot, textSelector, emojiSelector, ' ready'])).toBe(
`${textDot} ready`
)
expect(normalizeStatusDotChunks([dot, emojiSelector, textSelector, ' ready'])).toBe(
`${textDot} ready`
)
expect(normalizeStatusDotChunks([dot + textSelector, emojiSelector, ' ready'])).toBe(
`${textDot} ready`
)
expect(normalizeStatusDotChunks([dot + emojiSelector, textSelector, ' ready'])).toBe(
`${textDot} ready`
)
})
it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => {
const initStart = terminalHtmlSource.indexOf('function init(')
const initReplay = terminalHtmlSource.indexOf(
'var replayData = normalizeInitialData(initialData)'
)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
expect(initStart).toBeGreaterThanOrEqual(0)
expect(initReplay).toBeGreaterThan(initStart)
expect(clearStart).toBeGreaterThanOrEqual(0)
expect(clearEnd).toBeGreaterThan(clearStart)
expect(terminalHtmlSource.slice(initStart, initReplay)).toContain(
'statusDotPendingSelector = false'
)
expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain(
'statusDotPendingSelector = false'
)
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlSource).toContain('@xterm/xterm@6.1.0-beta.285')
expect(terminalHtmlSource).toContain('@xterm/addon-unicode11@0.10.0-beta.285')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
})
it('uses the newer WebGL-capable xterm stack and desktop font fallbacks', () => {
expect(terminalHtmlSource).toContain('@xterm/addon-webgl@0.20.0-beta.284')
expect(terminalHtmlSource).toContain('"SF Mono", "Menlo", "Monaco", "Cascadia Mono"')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
expect(terminalHtmlSource).toContain('new window.WebglAddon.WebglAddon()')
})
})
@@ -59,20 +59,57 @@ export const URL_TAP_WEBVIEW_JS = `
}
function oscLinkAtViewportPoint(clientX, clientY) {
try {
var svc = oscLinkService();
if (!svc || !svc.getLinkData) return null;
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
var line = term.buffer.active.getLine(cell.row);
if (!line) return null;
var bufCell = line.getCell(cell.col);
var urlId = bufCell && bufCell.extended && bufCell.extended.urlId;
if (!urlId) return null;
var urlId = oscLinkIdAtCell(line, cell.col);
if (!urlId) return initialOscLinkAtCell(cell.row, cell.col);
var svc = oscLinkService();
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
var data = svc.getLinkData(urlId);
var uri = data && data.uri;
return uri && /^https?:/i.test(uri) ? uri : null;
} catch (e) { return null; }
}
function initialOscLinkAtCell(row, col) {
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue;
if (link.row < initialOscLinkRowOffset) continue;
var shiftedRow = link.row - initialOscLinkRowOffset;
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri;
}
return null;
}
function captureInitialOscLinkTexts() {
if (!Array.isArray(initialOscLinks)) return;
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
if (!link || typeof link.text === 'string') continue;
link.text = initialOscLinkTextAtRow(link, link.row);
}
}
function initialOscLinkTextStillMatches(link, row) {
if (typeof link.text !== 'string') return false;
return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text;
}
function initialOscLinkTextAtRow(link, row) {
try {
var lineText = getLineText(row);
var start = cellColToStringIndex(row, link.startCol);
var end = cellColToStringIndex(row, link.endCol);
return lineText.slice(start, end);
} catch (e) {
return '';
}
}
function oscLinkIdAtCell(line, col) {
try {
var bufCell = line.getCell(col);
return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0;
} catch (e) { return 0; }
}
function notifyTerminalSurfaceTap(originX, originY) {
var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.80-rc.1",
"version": "1.4.80-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+12 -8
View File
@@ -1,12 +1,12 @@
---
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees/workspaces,
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree/workspace", "child workspace", "spawn codex/claude in a
workspace", "read/wait/send Orca terminal", "terminal send", "Orca browser", or "control the
browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs,
"Orca worktree", "child worktree", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "Orca browser", or "control
the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs,
Playwright, or Computer Use when the task touches Orca-managed state. Use
Computer Use for browser windows, webviews, or desktop UI outside Orca's
embedded browser.
@@ -40,7 +40,7 @@ Prefer `--json` for agent-driven calls. If the CLI is missing, say so explicitly
## Worktrees
An Orca worktree/workspace is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.
An Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.
Common commands:
@@ -55,6 +55,8 @@ orca worktree ps --json
orca worktree current --json
orca worktree show --worktree <selector> --json
orca worktree create --repo id:<repoId> --name related-task --json
orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json
orca worktree create --repo id:<repoId> --name folder-child --parent-worktree folder:<folderId> --json
orca worktree create --name child-task --agent codex --prompt "hi" --json
orca worktree create --name independent-task --no-parent --json
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
@@ -66,11 +68,13 @@ Selectors:
- `id:<worktreeId>`, `path:<absolutePath>`, `branch:<branchName>`, `issue:<number>`
- `active` / `current` for the enclosing Orca-managed worktree from the shell cwd
- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:<folderId>`, `worktree:<worktreeId>`, `id:folder:<folderId>`, `id:worktree:<worktreeId>`
Lineage rules:
- When creating from inside an Orca-managed worktree, Orca infers the current workspace as the parent when it can.
- Use `--parent-worktree active` when the child relationship should be explicit.
- When creating from inside an Orca-managed worktree or folder context, Orca infers the current parent context when it can.
- Use `--parent-worktree active` when the child worktree relationship should be explicit.
- Use `--parent-worktree folder:<folderId>` or `--parent-worktree worktree:<worktreeId>` when a folder or worktree parent context should be explicit.
- Use `--no-parent` only when the new work is independent.
- If `--repo` is omitted, Orca infers the repo from the current Orca worktree when possible.
@@ -89,7 +93,7 @@ orca worktree create --name task --run-hooks --json
- `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background.
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals.
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "codex"` and `orca terminal send` if a prompt is needed.
- `worktree create` creates a new checkout/workspace. For a fresh agent in the current checkout, use `orca terminal create --worktree active --command "codex" --json`.
- `worktree create` creates a new checkout. For a fresh agent in the current checkout, use `orca terminal create --worktree active --command "codex" --json`.
## Worktree Comments
+4 -4
View File
@@ -95,10 +95,10 @@ describe('formatCliError', () => {
ok: false,
error: {
code: 'LINEAGE_PARENT_NOT_FOUND',
message: 'Parent workspace was not found.',
message: 'Parent selector was not found.',
data: {
nextSteps: [
'Run `orca worktree list` and pass a valid --parent-worktree selector.',
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Retry with --no-parent to create without lineage.',
123
]
@@ -109,8 +109,8 @@ describe('formatCliError', () => {
expect(formatCliError(error)).toBe(
[
'Parent workspace was not found.',
'Next step: Run `orca worktree list` and pass a valid --parent-worktree selector.',
'Parent selector was not found.',
'Next step: Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Next step: Retry with --no-parent to create without lineage.'
].join('\n')
)
@@ -0,0 +1,60 @@
import { isWorkspaceKey } from '../../shared/workspace-scope'
import { getOptionalStringFlag } from '../flags'
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
import { getOptionalWorktreeSelector } from '../selectors'
export type CreateParentSelector = {
parentWorktree?: string
parentWorkspace?: string
}
const CREATE_PARENT_CONFLICT_MESSAGE = 'Choose either one parent selector or --no-parent.'
export function assertCreateParentFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError('invalid_argument', CREATE_PARENT_CONFLICT_MESSAGE)
}
const parentWorktree = flags.get('parent-worktree')
if (
flags.has('parent-worktree') &&
(typeof parentWorktree !== 'string' || parentWorktree === '')
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
}
}
function getWorkspaceKeyParentSelector(selector: string): string | undefined {
const rawSelector = selector.startsWith('id:') ? selector.slice('id:'.length) : selector
return isWorkspaceKey(rawSelector) ? rawSelector : undefined
}
export async function resolveCreateParentSelector(
flags: Map<string, string | boolean>,
cwd: string,
client: RuntimeClient
): Promise<CreateParentSelector> {
const rawParentWorktree = getOptionalStringFlag(flags, 'parent-worktree')
if (!rawParentWorktree) {
return {}
}
const parentWorkspace = getWorkspaceKeyParentSelector(rawParentWorktree)
if (parentWorkspace) {
// Why: create exposes one public parent flag, while the runtime still needs
// workspace keys to preserve folder/worktree lineage accurately.
return { parentWorkspace }
}
const parentWorktree = await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client)
const resolvedParentWorkspace = parentWorktree
? getWorkspaceKeyParentSelector(parentWorktree)
: undefined
if (resolvedParentWorkspace) {
// Why: active/current may resolve to a folder workspace pseudo-worktree id.
return { parentWorkspace: resolvedParentWorkspace }
}
return {
parentWorktree
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ function getLineageSourceLabel(source: string): string {
case 'explicit-cli-flag':
return 'explicit flag'
case 'active-workspace':
return 'active workspace'
return 'active context'
default:
return 'manual action'
}
+10 -29
View File
@@ -28,6 +28,10 @@ import {
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
} from '../worktree-project-target'
import {
assertCreateParentFlagsCompatible,
resolveCreateParentSelector
} from './worktree-create-parent-selector'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
type HookWarningResult = {
@@ -54,25 +58,13 @@ function printPreservedBranchWarning(result: PreservedBranchResult, json: boolea
}
}
function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void {
function assertParentWorktreeFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-worktree or --no-parent, not both.'
)
}
if (flags.has('parent-workspace') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-workspace or --no-parent, not both.'
)
}
if (flags.has('parent-workspace') && flags.has('parent-worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-workspace or --parent-worktree, not both.'
)
}
const parentWorktree = flags.get('parent-worktree')
if (
flags.has('parent-worktree') &&
@@ -80,13 +72,6 @@ function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
}
const parentWorkspace = flags.get('parent-workspace')
if (
flags.has('parent-workspace') &&
(typeof parentWorkspace !== 'string' || parentWorkspace === '')
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-workspace')
}
}
function getEnvParentWorkspace(): string | undefined {
@@ -211,20 +196,16 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatWorktreeShow)
},
'worktree create': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
assertCreateParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
? process.env.ORCA_TERMINAL_HANDLE
: undefined
const explicitParentWorktree = await getOptionalWorktreeSelector(
flags,
'parent-worktree',
cwd,
client
)
const explicitParentWorkspace = getPresentStringFlag(flags, 'parent-workspace')
const explicitParent = await resolveCreateParentSelector(flags, cwd, client)
const explicitParentWorktree = explicitParent.parentWorktree
const explicitParentWorkspace = explicitParent.parentWorkspace
const startupAgent = getOptionalStartupAgent(flags)
const setupDecision = getOptionalSetupDecision(flags)
const noParent = flags.get('no-parent') === true
@@ -277,7 +258,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatWorktreeShow)
},
'worktree set': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
assertParentWorktreeFlagsCompatible(flags)
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', {
allowNull: true
})
+7 -7
View File
@@ -194,7 +194,7 @@ Common Commands:
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
@@ -230,8 +230,7 @@ Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-workspace <selector> Parent workspace selector such as folder:<id> or worktree:<id>
--parent-worktree <selector> Parent worktree selector; create infers a child of the caller/current worktree by default
--parent-worktree <selector> Parent worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--no-parent Force no parent lineage for unrelated worktree creation/update
Terminal Send Options:
@@ -255,7 +254,7 @@ Behavior:
Use selectors for discovery and handles for repeated live terminal operations.
Agent Sessions And Worktrees:
\`worktree create --agent\` creates a new checkout/workspace with an agent.
\`worktree create --agent\` creates a new checkout with an agent.
To start a fresh agent in the current worktree, use:
orca terminal create --worktree active --command "codex"
@@ -433,6 +432,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'linear create' && flag === 'parent-current') {
return '--parent-current Use the current linked issue as parent'
}
if (command === 'worktree create' && flag === 'parent-worktree') {
return '--parent-worktree <selector> Parent selector such as active/current, id:<id>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<id>'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
}
@@ -479,10 +481,8 @@ export function formatFlagHelp(flag: string): string {
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-workspace':
'--parent-workspace <selector> Parent workspace selector such as folder:<id>',
'parent-worktree':
'--parent-worktree <selector> Parent selector; create infers the caller/current worktree by default',
'--parent-worktree <selector> Parent worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
+260 -48
View File
@@ -99,6 +99,7 @@ import {
normalizeWorktreeSelector
} from './index'
import { GLOBAL_FLAGS } from './args'
import { RuntimeRpcFailureError } from './runtime-client'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
describe('COMMAND_SPECS collision check', () => {
@@ -155,7 +156,7 @@ describe('orca root help', () => {
)
expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:')
expect(logSpy.mock.calls[0][0]).toContain(
'`worktree create --agent` creates a new checkout/workspace with an agent.'
'`worktree create --agent` creates a new checkout with an agent.'
)
expect(logSpy.mock.calls[0][0]).toContain(
'orca terminal create --worktree active --command "codex"'
@@ -221,13 +222,47 @@ describe('orca root help', () => {
expect(callMock).not.toHaveBeenCalled()
})
it('hides removed parent-workspace help and scopes create parent selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
logSpy.mockClear()
await main(['--help'], '/tmp/repo')
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).not.toContain('--parent-workspace')
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
logSpy.mockClear()
await main(['worktree', 'create', '--help'], '/tmp/repo')
const createHelp = String(logSpy.mock.calls[0][0])
expect(createHelp).not.toContain('--parent-workspace')
expect(createHelp).not.toContain('checkout/workspace')
expect(createHelp).not.toContain('caller workspace')
expect(createHelp).not.toContain('current workspace')
expect(createHelp).not.toContain('active Orca workspace')
expect(createHelp).not.toContain('folderWorkspaceId')
expect(createHelp).toContain('folder:<id>')
expect(createHelp).toContain('folder:<folderId>')
expect(createHelp).toContain('worktree:<id>')
logSpy.mockClear()
await main(['worktree', 'set', '--help'], '/tmp/repo')
const setHelp = String(logSpy.mock.calls[0][0])
expect(setHelp).not.toContain('--parent-workspace')
expect(setHelp).not.toContain('folder:<id>')
expect(setHelp).not.toContain('worktree:<id>')
expect(callMock).not.toHaveBeenCalled()
})
it('distinguishes new worktrees from fresh agent terminals in command help', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
logSpy.mockClear()
await main(['worktree', 'create', '--help'], '/tmp/repo')
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout/workspace')
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.')
expect(String(logSpy.mock.calls[0][0])).toContain(
'orca terminal create --worktree active --command "codex"'
)
@@ -1251,32 +1286,15 @@ describe('orca cli worktree awareness', () => {
})
})
it('passes an explicit parent workspace through worktree.create without cwd inference', async () => {
it('routes traditional parent-worktree selectors through parentWorktree', async () => {
queueFixtures(
callMock,
okFixture('req_create', {
worktree: {
...buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: 'folder:folder-1',
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
parentWorktreeId: 'repo-1::/tmp/repo/parent'
},
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: 'folder:folder-1',
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
@@ -1291,8 +1309,8 @@ describe('orca cli worktree awareness', () => {
'id:repo-1',
'--name',
'child',
'--parent-workspace',
'folder:folder-1',
'--parent-worktree',
'branch:feature/parent',
'--json'
],
'/tmp/repo/parent/src'
@@ -1307,13 +1325,80 @@ describe('orca cli worktree awareness', () => {
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: 'folder:folder-1',
parentWorktree: 'branch:feature/parent',
noParent: false,
callerTerminalHandle: undefined
})
})
it('routes workspace-key parent-worktree selectors through parentWorkspace', async () => {
const cases = [
{ selector: 'folder:folder-1', parentWorkspace: 'folder:folder-1' },
{
selector: 'worktree:repo-1::/tmp/repo/parent',
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
},
{ selector: 'id:folder:folder-1', parentWorkspace: 'folder:folder-1' },
{
selector: 'id:worktree:repo-1::/tmp/repo/parent',
parentWorkspace: 'worktree:repo-1::/tmp/repo/parent'
}
]
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const testCase of cases) {
callMock.mockReset()
queueFixtures(
callMock,
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: testCase.parentWorkspace,
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
testCase.selector,
'--json'
],
'/tmp/repo/parent/src'
)
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: testCase.parentWorkspace,
noParent: false,
callerTerminalHandle: undefined
})
}
})
it('passes folder workspace environment lineage through worktree.create', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:folder-1'
queueFixtures(
@@ -1399,6 +1484,68 @@ describe('orca cli worktree awareness', () => {
})
})
it('routes active/current folder workspace parent selectors through parentWorkspace on create', async () => {
const folderWorkspace = {
...buildWorktree('/tmp/folder', '', '', 'folder-workspace:group-1'),
id: 'folder:folder-1',
repoId: 'folder-workspace:group-1',
displayName: 'Folder'
}
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
for (const parentSelector of ['current', 'active']) {
callMock.mockReset()
queueFixtures(
callMock,
worktreeListFixture([folderWorkspace]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
workspaceLineage: {
childWorkspaceKey: 'worktree:repo-1::/tmp/repo/child',
childInstanceId: 'child-instance',
parentWorkspaceKey: 'folder:folder-1',
parentInstanceId: null,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
},
warnings: []
})
)
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
parentSelector,
'--json'
],
'/tmp/folder/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: 'folder:folder-1',
noParent: false,
callerTerminalHandle: undefined
})
}
})
it('rejects contradictory parent flags on worktree.create before resolving selectors', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -1422,40 +1569,44 @@ describe('orca cli worktree awareness', () => {
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-worktree or --no-parent, not both.'
'Choose either one parent selector or --no-parent.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects contradictory parent workspace flags on worktree.create', async () => {
it('rejects removed parent-workspace on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
const outputModes = [[], ['--json']]
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-workspace',
'folder:folder-1',
'--parent-worktree',
'current',
'--json'
],
'/tmp/not-managed'
)
for (const outputArgs of outputModes) {
logSpy.mockClear()
errSpy.mockClear()
process.exitCode = priorExitCode
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --parent-workspace or --parent-worktree, not both.'
)
expect(process.exitCode).toBe(1)
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-workspace',
'folder:folder-1',
...outputArgs
],
'/tmp/repo'
)
const output = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
expect(output).toContain('Unknown flag --parent-workspace for command: worktree create')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
}
process.exitCode = priorExitCode
})
@@ -1488,6 +1639,67 @@ describe('orca cli worktree awareness', () => {
process.exitCode = priorExitCode
})
it('reports runtime parent selector failures without hidden flag guidance', async () => {
callMock.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'req_create',
ok: false,
error: {
code: 'LINEAGE_PARENT_NOT_FOUND',
message: 'Parent selector was not found.',
data: {
nextSteps: [
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Retry with --no-parent to create without lineage.'
]
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-1',
'--name',
'child',
'--parent-worktree',
'folder:missing',
'--json'
],
'/tmp/repo'
)
const output = String(logSpy.mock.calls[0][0])
expect(callMock).toHaveBeenCalledWith('worktree.create', {
repo: 'id:repo-1',
name: 'child',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
parentWorkspace: 'folder:missing',
noParent: false,
callerTerminalHandle: undefined
})
expect(output).toContain('"ok": false')
expect(output).toContain('Parent selector was not found.')
expect(output).toContain('--parent-worktree selector')
expect(output).not.toContain('--parent-workspace')
expect(errSpy).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes no-parent through worktree.create and skips cwd inference', async () => {
queueFixtures(
callMock,
+7 -8
View File
@@ -102,7 +102,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
@@ -117,20 +117,19 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'linear-issue',
'comment',
'setup',
'parent-workspace',
'parent-worktree',
'no-parent',
'run-hooks',
'activate'
],
notes: [
'This creates a new checkout/workspace. For a fresh agent in an existing worktree, use `orca terminal create --worktree active --command "codex"` instead.',
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
'This creates a new checkout. For a fresh agent in an existing worktree, use `orca terminal create --worktree active --command "codex"` instead.',
'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.',
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
'For related work, use the inferred parent or pass --parent-workspace folder:<id> or worktree:<id>, or --parent-worktree active, to make the relationship explicit.',
'Use --no-parent when the new worktree should be independent of the current workspace.',
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
'For related work, use the inferred parent or pass --parent-worktree active, folder:<id>, or worktree:<id> to make the relationship explicit.',
'Use --no-parent when the new worktree should be independent of the current context.',
'By default this creates the worktree and its first terminal without switching the active Orca view.',
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
@@ -142,7 +141,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
'orca worktree create --repo id:<repoId> --name folder-child --parent-workspace folder:<folderWorkspaceId> --json',
'orca worktree create --repo id:<repoId> --name folder-child --parent-worktree folder:<folderId> --json',
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
'orca worktree create --repo id:<repoId> --name independent-task --no-parent --json'
]
+87
View File
@@ -0,0 +1,87 @@
import { randomUUID } from 'crypto'
const DISPATCH_TOKEN_TTL_MS = 30 * 60_000
type DispatchTokenRecord = {
automationId: string
runId: string
expiresAt: number
reservedBy?: string
inFlight: boolean
}
const dispatchTokens = new Map<string, DispatchTokenRecord>()
function pruneExpiredDispatchTokens(now = Date.now()): void {
for (const [token, record] of dispatchTokens) {
if (record.expiresAt <= now) {
dispatchTokens.delete(token)
}
}
}
export function createAutomationDispatchToken(automationId: string, runId: string): string {
pruneExpiredDispatchTokens()
const token = randomUUID()
dispatchTokens.set(token, {
automationId,
runId,
expiresAt: Date.now() + DISPATCH_TOKEN_TTL_MS,
inFlight: false
})
return token
}
export function beginAutomationDispatchTokenUse(args: {
automationId: string
runId: string
token: string
reservationId: string
}): boolean {
pruneExpiredDispatchTokens()
const record = dispatchTokens.get(args.token)
const valid =
record?.automationId === args.automationId &&
record.runId === args.runId &&
record.expiresAt > Date.now()
if (!valid) {
return false
}
if (record.reservedBy !== undefined && record.reservedBy !== args.reservationId) {
return false
}
if (record.inFlight) {
return false
}
record.reservedBy = args.reservationId
record.inFlight = true
return true
}
export function releaseAutomationDispatchTokenUse(args: {
token: string
reservationId: string
}): void {
const record = dispatchTokens.get(args.token)
if (record?.reservedBy === args.reservationId) {
record.inFlight = false
}
}
export function finishAutomationDispatchTokenUse(args: {
token: string
reservationId: string
}): void {
const record = dispatchTokens.get(args.token)
if (record?.reservedBy === args.reservationId) {
dispatchTokens.delete(args.token)
}
}
export function clearAutomationDispatchTokens(automationId: string, runId: string): void {
for (const [token, record] of dispatchTokens) {
if (record.automationId === automationId && record.runId === runId) {
dispatchTokens.delete(token)
}
}
}
@@ -0,0 +1,91 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Automation } from '../../shared/automations-types'
import type { Repo } from '../../shared/types'
import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create'
const repoPath = path.join('tmp', 'orca')
const repo: Repo = {
id: 'repo-1',
path: repoPath,
displayName: 'orca',
badgeColor: '#000',
addedAt: 1,
kind: 'git',
executionHostId: 'ssh:ssh-target-1'
}
const automation: Automation = {
id: 'automation-1',
name: 'Nightly review',
prompt: 'Review changes',
precheck: null,
agentId: 'codex',
runContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:ssh-target-1',
projectHostSetupId: 'setup-1',
repoId: 'repo-1',
path: repoPath
},
sourceContext: null,
projectId: 'legacy-repo-1',
executionTargetType: 'ssh',
executionTargetId: 'ssh-target-1',
schedulerOwner: 'remote_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: 'origin/main',
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
dtstart: 1,
enabled: true,
nextRunAt: 2,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 1,
updatedAt: 1
}
describe('headless automation workspace create args', () => {
it('stamps automation provenance for serve-mode new-per-run workspaces', () => {
const args = buildHeadlessAutomationWorktreeCreateArgs({
automation,
run: {
id: 'run-1',
title: 'Nightly review run',
scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5)
},
repo,
createdAt: 123
})
expect(args).toMatchObject({
repoSelector: 'repo-1',
name: 'auto-nightly-review-run-20260102T0304',
baseBranch: 'origin/main',
setupDecision: 'inherit',
activate: false,
createdWithAgent: 'codex',
startupAgent: 'codex',
startupPrompt: 'Review changes',
telemetrySource: 'unknown',
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'ssh',
executionTargetId: 'ssh-target-1',
projectId: 'project-1',
repoId: 'repo-1',
hostId: 'ssh:ssh-target-1'
}
})
})
})
@@ -0,0 +1,47 @@
import type { Automation, AutomationRun } from '../../shared/automations-types'
import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance'
import type { Repo } from '../../shared/types'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
type HeadlessAutomationRunForWorkspace = Pick<AutomationRun, 'id' | 'title' | 'scheduledFor'>
type RuntimeCreateManagedWorktreeArgs = Parameters<OrcaRuntimeService['createManagedWorktree']>[0]
export function buildHeadlessAutomationWorkspaceName(
runTitle: string,
scheduledFor: number
): string {
// Why: generated workspace names must stay deterministic and short enough for
// cross-provider branch/path displays while still carrying the run timestamp.
const slug = runTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
return `auto-${slug || 'run'}-${stamp}`
}
export function buildHeadlessAutomationWorktreeCreateArgs({
automation,
run,
repo,
createdAt = Date.now()
}: {
automation: Automation
run: HeadlessAutomationRunForWorkspace
repo: Repo
createdAt?: number
}): RuntimeCreateManagedWorktreeArgs {
return {
repoSelector: repo.id,
name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor),
baseBranch: automation.baseBranch ?? undefined,
setupDecision: 'inherit',
activate: false,
createdWithAgent: automation.agentId,
startupAgent: automation.agentId,
startupPrompt: automation.prompt,
telemetrySource: 'unknown',
automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt)
}
}
+7 -1
View File
@@ -14,6 +14,7 @@ import { runAutomationPrecheck } from './precheck-runner'
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
import { collectAutomationRunUsage } from './run-usage-collection'
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
@@ -131,6 +132,7 @@ export class AutomationService {
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
const run = this.store.updateAutomationRun(result)
clearAutomationDispatchTokens(run.automationId, run.id)
if (!isFinalRunStatus(run.status)) {
return run
}
@@ -231,7 +233,11 @@ export class AutomationService {
workspaceId: automation.workspaceId,
error: null
})
const payload: AutomationDispatchRequest = { automation, run: updated }
const payload: AutomationDispatchRequest = {
automation,
run: updated,
dispatchToken: createAutomationDispatchToken(automation.id, updated.id)
}
webContents.send('automations:dispatchRequested', payload)
return updated
}
@@ -0,0 +1,103 @@
import { z } from 'zod'
import type { Automation, AutomationRun } from '../../shared/automations-types'
import { getAutomationRunRepoId } from '../../shared/automation-run-identity'
import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance'
import type {
AutomationWorkspaceProvenance,
AutomationWorkspaceProvenanceRequest,
Repo
} from '../../shared/types'
import {
beginAutomationDispatchTokenUse,
finishAutomationDispatchTokenUse,
releaseAutomationDispatchTokenUse
} from './dispatch-tokens'
export type AutomationWorkspaceProvenanceAuthority = {
showAutomation: (id: string) => Automation
listAutomationRuns: (automationId?: string) => AutomationRun[]
}
export function invalidAutomationProvenanceRequest(): never {
throw new z.ZodError([
{
code: z.ZodIssueCode.custom,
path: ['automationProvenanceRequest'],
message: 'Invalid automation provenance request'
}
])
}
function repoSelectorMatchesAutomation(selector: string, repoId: string): boolean {
return selector === repoId || selector === `id:${repoId}`
}
export function resolveAutomationWorkspaceProvenance(args: {
authority: AutomationWorkspaceProvenanceAuthority
repoSelector: string
repo: Repo
request: AutomationWorkspaceProvenanceRequest | undefined
}): AutomationWorkspaceProvenance | undefined {
const { authority, repoSelector, repo, request } = args
if (!request) {
return undefined
}
let automation: Automation
try {
automation = authority.showAutomation(request.automationId)
} catch {
invalidAutomationProvenanceRequest()
}
const run = authority
.listAutomationRuns(request.automationId)
.find((entry) => entry.id === request.automationRunId)
const expectedRepoId = run?.runContext?.repoId ?? getAutomationRunRepoId(automation)
if (
!run ||
run.automationId !== automation.id ||
run.status !== 'dispatching' ||
run.workspaceId !== null ||
automation.workspaceMode !== 'new_per_run' ||
!repoSelectorMatchesAutomation(repoSelector, expectedRepoId)
) {
invalidAutomationProvenanceRequest()
}
if (
!beginAutomationDispatchTokenUse({
automationId: request.automationId,
runId: request.automationRunId,
token: request.dispatchToken,
reservationId: request.createRequestId
})
) {
invalidAutomationProvenanceRequest()
}
return buildAutomationWorkspaceProvenance(automation, run, repo)
}
export function releaseAutomationWorkspaceProvenanceRequest(
request: AutomationWorkspaceProvenanceRequest | undefined
): void {
if (!request) {
return
}
releaseAutomationDispatchTokenUse({
token: request.dispatchToken,
reservationId: request.createRequestId
})
}
export function finishAutomationWorkspaceProvenanceRequest(
request: AutomationWorkspaceProvenanceRequest | undefined
): void {
if (!request) {
return
}
finishAutomationDispatchTokenUse({
token: request.dispatchToken,
reservationId: request.createRequestId
})
}
@@ -455,6 +455,55 @@ describe('AgentBrowserBridge', () => {
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 10, y: 20 })
})
it('passes mobile click modifiers through to CDP mouse events', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
await bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd', 'shift'])
const mouseCalls = wc.debugger.sendCommand.mock.calls.filter(
(call) => call[0] === 'Input.dispatchMouseEvent'
)
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', modifiers: 12 })
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', modifiers: 12 })
})
it('keeps adjusted mobile tap coordinates but uses CDP for modifier clicks', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) => {
if (method === 'Runtime.evaluate') {
return { result: { value: { x: 12, y: 34, adjusted: true, handled: false } } }
}
return {}
})
webContentsFromIdMock.mockReturnValue(wc)
await expect(
bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18, ['cmd'])
).resolves.toEqual({
clicked: { x: 12, y: 34, button: 'left', adjusted: true, handled: false }
})
const evaluateCall = wc.debugger.sendCommand.mock.calls.find(
(call) => call[0] === 'Runtime.evaluate'
)
expect((evaluateCall?.[1] as { expression?: string } | undefined)?.expression).toContain(
'const allowDomActivation = false'
)
const mouseCalls = wc.debugger.sendCommand.mock.calls.filter(
(call) => call[0] === 'Input.dispatchMouseEvent'
)
expect(mouseCalls).toHaveLength(2)
expect(mouseCalls[0]?.[1]).toMatchObject({ type: 'mousePressed', x: 12, y: 34, modifiers: 4 })
expect(mouseCalls[1]?.[1]).toMatchObject({ type: 'mouseReleased', x: 12, y: 34, modifiers: 4 })
})
it('drops empty command queues after direct CDP commands finish', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockResolvedValue({})
+43 -6
View File
@@ -81,6 +81,8 @@ type ResolvedBrowserCommandTarget = {
webContentsId: number
}
export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift'
type AgentBrowserExecOptions = {
envOverrides?: NodeJS.ProcessEnv
timeoutMs?: number
@@ -233,6 +235,25 @@ function cdpMouseButtonMask(button: CdpMouseButton): number {
return 1
}
function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number {
if (!modifiers || modifiers.length === 0) {
return 0
}
let mask = 0
for (const modifier of modifiers) {
if (modifier === 'alt') {
mask |= 1
} else if (modifier === 'ctrl') {
mask |= 2
} else if (modifier === 'cmd') {
mask |= 4
} else if (modifier === 'shift') {
mask |= 8
}
}
return mask
}
function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint {
const point = value && typeof value === 'object' ? (value as Record<string, unknown>) : null
const x = point?.x
@@ -248,11 +269,17 @@ function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserCli
return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true }
}
function mobileTouchClickExpression(x: number, y: number, radius: number): string {
function mobileTouchClickExpression(
x: number,
y: number,
radius: number,
allowDomActivation: boolean
): string {
return `(() => {
const inputX = ${JSON.stringify(x)};
const inputY = ${JSON.stringify(y)};
const radius = ${JSON.stringify(radius)};
const allowDomActivation = ${JSON.stringify(allowDomActivation)};
const selector = [
'a[href]',
'button',
@@ -343,9 +370,12 @@ function mobileTouchClickExpression(x: number, y: number, radius: number): strin
break;
}
}
if (best && dispatchClick(best.target, best.x, best.y)) {
if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) {
return { x: best.x, y: best.y, adjusted: true, handled: true };
}
if (best) {
return { x: best.x, y: best.y, adjusted: true, handled: false };
}
return { x: inputX, y: inputY, adjusted: false, handled: false };
})()`
}
@@ -354,7 +384,8 @@ async function resolveMobileTouchClickPoint(
dbg: WebContents['debugger'],
x: number,
y: number,
radius?: number
radius: number | undefined,
allowDomActivation: boolean
): Promise<BrowserClickPoint> {
const fallback = { x, y, adjusted: false, handled: false }
if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) {
@@ -362,7 +393,7 @@ async function resolveMobileTouchClickPoint(
}
try {
const result = await dbg.sendCommand('Runtime.evaluate', {
expression: mobileTouchClickExpression(x, y, radius),
expression: mobileTouchClickExpression(x, y, radius, allowDomActivation),
returnByValue: true,
silent: true
})
@@ -839,7 +870,8 @@ export class AgentBrowserBridge {
button?: string,
worktreeId?: string,
browserPageId?: string,
radius?: number
radius?: number,
modifiers?: BrowserMouseModifier[]
): Promise<unknown> {
return this.enqueueTargetedCommand(
worktreeId,
@@ -854,12 +886,15 @@ export class AgentBrowserBridge {
}
const cdpButton = normalizeCdpMouseButton(button)
const buttons = cdpMouseButtonMask(cdpButton)
const cdpModifiers = cdpMouseModifierMask(modifiers)
const lease = acquireElectronDebugger(wc)
try {
wc.focus()
const point =
cdpButton === 'left'
? await resolveMobileTouchClickPoint(wc.debugger, x, y, radius)
? // Why: DOM activation cannot carry Cmd/Ctrl/Alt/Shift, so modifier
// clicks use only the adjusted point and let CDP dispatch the event.
await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0)
: { x, y, adjusted: false, handled: false }
// Why: mobile taps should land as one atomic input operation. Sending
// move/down/up through separate CLI calls visibly hovers targets and can
@@ -873,6 +908,7 @@ export class AgentBrowserBridge {
y: point.y,
button: cdpButton,
buttons,
modifiers: cdpModifiers,
clickCount: 1
})
await wc.debugger.sendCommand('Input.dispatchMouseEvent', {
@@ -881,6 +917,7 @@ export class AgentBrowserBridge {
y: point.y,
button: cdpButton,
buttons: 0,
modifiers: cdpModifiers,
clickCount: 1
})
}
+45 -2
View File
@@ -909,6 +909,49 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
it('returns cold restore OSC link ranges from checkpoint history', async () => {
const sessionId = 'cold-restore-osc-links'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
const oscLinks = [{ row: 0, startCol: 0, endCol: 5, uri: 'https://example.com/issue/1234' }]
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: '/projects/myapp',
cols: 80,
rows: 24,
startedAt: '2026-04-15T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(
join(sessionDir, 'checkpoint.json'),
JSON.stringify({
snapshotAnsi: '#1234\r\n',
scrollbackAnsi: '',
oscLinks,
rehydrateSequences: '',
cwd: '/projects/myapp',
cols: 80,
rows: 24,
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: false
},
scrollbackLines: 0,
checkpointedAt: '2026-04-15T11:00:00Z'
})
)
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.coldRestore?.oscLinks).toEqual(oscLinks)
})
it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => {
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
@@ -1013,7 +1056,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const internals = historyAdapter as unknown as {
coldRestoreCache: Map<string, { scrollback: string; cwd: string }>
coldRestoreCache: Map<string, { scrollback: string; cwd: string; oscLinks?: unknown[] }>
}
await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
@@ -1043,7 +1086,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const internals = historyAdapter as unknown as {
coldRestoreCache: Map<string, { scrollback: string; cwd: string }>
coldRestoreCache: Map<string, { scrollback: string; cwd: string; oscLinks?: unknown[] }>
}
await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
+10 -5
View File
@@ -24,6 +24,13 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers
import { isShellProcess } from '../../shared/agent-detection'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
type ColdRestorePayload = {
scrollback: string
cwd: string
oscLinks?: TerminalOscLinkRange[]
}
export type DaemonPtyAdapterOptions = {
socketPath: string
@@ -73,7 +80,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: React StrictMode double-mounts: mount → cold restore → unmount →
// mount → ??? The sticky cache returns the same cold restore data on the
// second mount until the renderer explicitly acknowledges it.
private coldRestoreCache = new Map<string, { scrollback: string; cwd: string }>()
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
@@ -348,9 +355,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.killedSessionTombstones.delete(sessionId)
}
private buildColdRestorePayload(
restoreInfo: ColdRestoreInfo
): { scrollback: string; cwd: string } | null {
private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null {
// Why: if the checkpoint was captured while an alternate-screen app
// (vim, less, htop) was active, snapshotAnsi is the alt buffer content.
// Replaying that into a fresh shell would show stale TUI content. Use
@@ -363,7 +368,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!scrollback) {
return null
}
return { scrollback, cwd: restoreInfo.cwd }
return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks }
}
async sendSignal(id: string, signal: string): Promise<void> {
+46
View File
@@ -91,6 +91,52 @@ describe('HeadlessEmulator', () => {
replay.dispose()
}
})
it('captures OSC 8 link ranges in snapshot metadata', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b]8;;https://news.ycombinator.com\x07Hacker News\x1b]8;;\x07')
expect(emulator.getSnapshot().oscLinks).toEqual([
{
row: 0,
startCol: 0,
endCol: 11,
uri: 'https://news.ycombinator.com'
}
])
})
it('captures scrollback OSC 8 ranges in unrestricted snapshots', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 2, scrollback: 10 })
await emulator.write('\x1b]8;;https://example.com/old\x07old\x1b]8;;\x07\r\nplain\r\nvisible')
expect(emulator.getSnapshot().oscLinks).toContainEqual({
row: 0,
startCol: 0,
endCol: 3,
uri: 'https://example.com/old'
})
expect(
emulator
.getSnapshot({ scrollbackRows: 0 })
.oscLinks?.some((link) => link.uri === 'https://example.com/old')
).toBe(false)
})
it('projects restored OSC 8 ranges into serialized snapshot windows', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('issue #1234 done')
emulator.setRestoredOscLinks([
{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }
])
expect(emulator.getSnapshot().oscLinks).toContainEqual({
row: 0,
startCol: 6,
endCol: 11,
uri: 'https://example.com/issue/1234'
})
})
})
describe('OSC-7 CWD tracking', () => {
+14
View File
@@ -4,11 +4,13 @@ import { SerializeAddon } from '@xterm/addon-serialize'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import {
installTerminalViewAttributeResponder,
type TerminalViewAttributeResponder
} from './terminal-view-attribute-responder'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
export type HeadlessEmulatorOptions = {
cols: number
@@ -56,6 +58,7 @@ export class HeadlessEmulator {
private serializer: SerializeAddon
private oscText = new TerminalOscCwdTitleScanner()
private mouseModes = new TerminalMouseModeMirror()
private restoredOscLinks: TerminalOscLinkRange[] = []
private disposed = false
private onQueryReply: ((reply: string) => void) | null
private conptyDa1OverrideInstalled = false
@@ -256,6 +259,7 @@ export class HeadlessEmulator {
if (this.disposed) {
return
}
this.restoredOscLinks = []
this.terminal.resize(cols, rows)
}
@@ -268,6 +272,11 @@ export class HeadlessEmulator {
return {
snapshotAnsi,
scrollbackAnsi: '',
oscLinks: collectHeadlessOscLinkRanges(
this.terminal,
opts.scrollbackRows,
this.restoredOscLinks
),
rehydrateSequences: this.buildRehydrateSequences(modes),
cwd: this.oscText.cwd,
modes,
@@ -303,7 +312,12 @@ export class HeadlessEmulator {
this.oscText.lastTitle = title
}
setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void {
this.restoredOscLinks = links?.slice() ?? []
}
clearScrollback(): void {
this.restoredOscLinks = []
this.terminal.clear()
}
@@ -0,0 +1,90 @@
import type { Terminal } from '@xterm/headless'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
type TerminalWithOscLinks = Terminal & {
_core?: {
_oscLinkService?: {
getLinkData: (linkId: number) => { uri?: string } | undefined
}
}
}
type CellWithOscLink = {
extended?: { urlId?: number }
hasExtendedAttrs?: () => boolean
}
export function collectHeadlessOscLinkRanges(
terminal: Terminal,
scrollbackRows: number | undefined,
restoredLinks: TerminalOscLinkRange[] = []
): TerminalOscLinkRange[] {
// Why: headless xterm exposes OSC 8 metadata only via this private service.
// Keep this boundary explicit so xterm upgrades are audited here.
const service = (terminal as TerminalWithOscLinks)._core?._oscLinkService
if (!service) {
return []
}
const buffer = terminal.buffer.active
const startRow =
scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows)
const ranges: TerminalOscLinkRange[] = []
for (let row = startRow; row < buffer.length; row += 1) {
const line = buffer.getLine(row)
if (!line) {
continue
}
const lineLength = Math.min(terminal.cols, line.length)
let currentUrlId = 0
let currentStart = -1
for (let col = 0; col <= lineLength; col += 1) {
const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0
if (urlId === currentUrlId) {
continue
}
if (currentUrlId && currentStart >= 0) {
const uri = service.getLinkData(currentUrlId)?.uri
if (uri) {
ranges.push({ row: row - startRow, startCol: currentStart, endCol: col, uri })
}
}
currentUrlId = urlId
currentStart = urlId ? col : -1
}
}
for (const link of restoredLinks) {
if (link.row < startRow || link.row >= buffer.length) {
continue
}
const startCol = Math.max(0, Math.min(terminal.cols, link.startCol))
const endCol = Math.max(0, Math.min(terminal.cols, link.endCol))
if (startCol >= endCol) {
continue
}
ranges.push({
row: link.row - startRow,
startCol,
endCol,
uri: link.uri
})
}
return dedupeOscLinkRanges(ranges)
}
function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRange[] {
const seen = new Set<string>()
return ranges.filter((range) => {
const key = `${range.row}:${range.startCol}:${range.endCol}:${range.uri}`
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number {
const cell = line.getCell(col) as CellWithOscLink | undefined
// Why: OSC link IDs live in extended cell attrs; missing attrs means no link.
return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0
}
+10
View File
@@ -125,6 +125,16 @@ describe('HistoryManager', () => {
expect(data.rehydrateSequences).toBe('\x1b[?2004h\x1b[?1h')
})
it('preserves OSC link ranges in checkpoint', async () => {
await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 })
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
await mgr.checkpoint('sess-1', makeSnapshot({ oscLinks }))
const data = JSON.parse(readFileSync(sessionPath(dir, 'sess-1', 'checkpoint.json'), 'utf-8'))
expect(data.oscLinks).toEqual(oscLinks)
})
it('ignores checkpoint for unknown sessions', async () => {
await mgr.checkpoint('nonexistent', makeSnapshot())
})
+1
View File
@@ -219,6 +219,7 @@ export class HistoryManager {
const checkpointFile: TerminalCheckpointFile = {
snapshotAnsi: snapshot.snapshotAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
oscLinks: snapshot.oscLinks,
rehydrateSequences: snapshot.rehydrateSequences,
cwd: effectiveCwd,
cols: snapshot.cols,
+8
View File
@@ -118,6 +118,14 @@ describe('HistoryReader', () => {
expect(info!.rehydrateSequences).toBe('\x1b[?2004h')
})
it('restores OSC link ranges from checkpoint', () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ oscLinks }))
const info = reader.detectColdRestore('sess-1')
expect(info!.oscLinks).toEqual(oscLinks)
})
it('returns null for clean shutdown (endedAt is set)', () => {
writeSessionWithCheckpoint(
dir,
+5
View File
@@ -2,6 +2,7 @@ import { join } from 'path'
import { readFileSync, existsSync, readdirSync } from 'fs'
import type { SessionMeta } from './history-manager'
import type { TerminalCheckpointFile, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import { getHistorySessionDirName } from './history-paths'
import { decodeTerminalHistoryLog } from './terminal-history-log'
import { HeadlessEmulator } from './headless-emulator'
@@ -9,6 +10,7 @@ import { HeadlessEmulator } from './headless-emulator'
export type ColdRestoreInfo = {
snapshotAnsi: string
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
cwd: string
cols: number
@@ -136,6 +138,7 @@ export class HistoryReader {
if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) {
return null
}
emulator.setRestoredOscLinks(checkpoint.oscLinks)
}
for (const batch of log.batches) {
for (const record of batch.records) {
@@ -169,6 +172,7 @@ export class HistoryReader {
snapshot: {
snapshotAnsi: string
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
cols: number
rows: number
@@ -188,6 +192,7 @@ export class HistoryReader {
return {
snapshotAnsi: snapshot.snapshotAnsi,
scrollbackAnsi,
oscLinks: snapshot.oscLinks,
rehydrateSequences: snapshot.rehydrateSequences,
cwd: cwd ?? meta.cwd,
cols: snapshot.cols,
@@ -72,6 +72,23 @@ describe('incremental terminal history restore', () => {
expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint')
})
it('preserves checkpoint OSC link ranges while replaying the log tail', async () => {
await manager.checkpoint(
SESSION_ID,
snapshotOf(['\x1b]8;;https://example.com/issue/1234\x07#1234\x1b]8;;\x07\r\n'])
)
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'tail\r\n' }])
const restore = reader.detectColdRestore(SESSION_ID)
expect(restore).not.toBeNull()
expect(restore!.oscLinks).toContainEqual({
row: 0,
startCol: 0,
endCol: 5,
uri: 'https://example.com/issue/1234'
})
})
it('ignores a stale log whose generation predates the checkpoint', async () => {
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'stale tail\r\n' }])
// Simulate a crash between checkpoint rename and log reset: write the
+6 -2
View File
@@ -1,3 +1,5 @@
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
// ─── Protocol Version ────────────────────────────────────────────────
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
@@ -5,9 +7,9 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 16
export const PROTOCOL_VERSION = 17
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
] as const
// ─── Session State Machine ──────────────────────────────────────────
@@ -21,6 +23,7 @@ export type TerminalSnapshot = {
/** Scrollback portion only (rows above the visible viewport). Write this
* to preserve history without interfering with TUI repaints. */
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
cwd: string | null
modes: TerminalModes
@@ -56,6 +59,7 @@ export type TerminalModes = {
export type TerminalCheckpointFile = {
snapshotAnsi: string
scrollbackAnsi: string
oscLinks?: TerminalOscLinkRange[]
rehydrateSequences: string
cwd: string | null
cols: number
+108
View File
@@ -64,6 +64,32 @@ describe('buildSearchBaseRefsArgv', () => {
expect(argv).toContain('--count=40')
expect(argv).toContain('refs/remotes/*upstream*/*main*')
expect(argv).toContain('refs/heads/*upstream*/*main*')
expect(argv).toContain('refs/remotes/*/upstream/main*')
expect(argv).toContain('refs/heads/upstream/main*')
})
it('anchors local-branch-name searches below configured remotes', () => {
const argv = buildSearchBaseRefsArgv('plan/docs', 10, { remoteNames: ['origin', 'foo/bar'] })
expect(argv).toContain('refs/remotes/origin/plan/docs*')
expect(argv).toContain('refs/remotes/foo/bar/plan/docs*')
expect(argv).not.toContain('refs/remotes/**/*plan/docs*')
})
it('can build display-format and branch-root patterns separately', () => {
const segmentedArgv = buildSearchBaseRefsArgv('upstream/feat', 10, {
remoteNames: ['origin', 'upstream'],
patternGroup: 'segmented'
})
const argv = buildSearchBaseRefsArgv('upstream/feat', 10, {
remoteNames: ['origin', 'upstream'],
patternGroup: 'branchRoot'
})
expect(segmentedArgv).toContain('refs/remotes/*upstream*/*feat*')
expect(segmentedArgv).not.toContain('refs/remotes/origin/upstream/feat*')
expect(argv).toContain('refs/remotes/origin/upstream/feat*')
expect(argv).not.toContain('refs/remotes/*upstream*/*feat*')
})
it('adds fallback headroom when remote HEAD cannot be excluded by git', () => {
@@ -351,6 +377,88 @@ describe('searchBaseRefs (widened glob)', () => {
expect(await searchBaseRefs(tmpDir, '/upstream')).toContain('upstream/main')
expect(await searchBaseRefs(tmpDir, 'upstream//main')).toContain('upstream/main')
})
it('finds a remote branch when the query is the local branch name with slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/plan/unified-brainstorm-plan-docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('origin/plan/unified-brainstorm-plan-docs')
})
it('finds a remote branch by local branch name when the remote name has slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'foo/bar/plan/unified-brainstorm-plan-docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('foo/bar/plan/unified-brainstorm-plan-docs')
})
it('does not match slash queries inside unrelated nested branch paths', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
createRemoteRef(tmpDir, 'origin/upstream/feature-x', sha)
createRemoteRef(tmpDir, 'origin/foo/upstream/feature-x', sha)
createRemoteRef(tmpDir, 'upstream/feature-y', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/feat')
expect(results).toContain('upstream/feature-y')
expect(results).toContain('origin/upstream/feature-x')
expect(results).not.toContain('origin/foo/upstream/feature-x')
})
it('keeps display-format matches when many branch-root matches share the query', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
for (let i = 0; i < 12; i += 1) {
createRemoteRef(tmpDir, `origin/upstream/feature-${i}`, sha)
}
createRemoteRef(tmpDir, 'upstream/feature-target', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/feature', 2)
expect(results).toContain('upstream/feature-target')
})
it('still finds a local-branch-name match when the first segment is also a remote name', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git'])
createRemoteRef(tmpDir, 'origin/plan/docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/docs')
expect(results).toContain('origin/plan/docs')
})
it('keeps branch-root matches when many display-format matches share the query', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git'])
for (let i = 0; i < 12; i += 1) {
createRemoteRef(tmpDir, `plan/docs-${i}`, sha)
}
createRemoteRef(tmpDir, 'origin/plan/docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/docs', 2)
expect(results).toContain('origin/plan/docs')
})
it('finds a local slashed branch when the query repeats the full branch name', async () => {
git(tmpDir, ['branch', 'plan/unified-brainstorm-plan-docs'])
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('plan/unified-brainstorm-plan-docs')
})
})
describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
+110 -17
View File
@@ -504,6 +504,12 @@ async function getDefaultBaseRefAsync(
const REF_SEARCH_CANDIDATE_MULTIPLIER = 4
const REF_SEARCH_LEGACY_HEADROOM = 100
type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot'
function getRefSearchTokens(normalizedQuery: string): string[] {
return normalizedQuery.split('/').filter((t) => t.length > 0)
}
function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
@@ -515,7 +521,11 @@ function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean):
export function buildSearchBaseRefsArgv(
normalizedQuery: string,
limit: number,
options: { excludeRemoteHead?: boolean } = {}
options: {
excludeRemoteHead?: boolean
remoteNames?: readonly string[]
patternGroup?: RefSearchPatternGroup
} = {}
): string[] {
const excludeRemoteHead = options.excludeRemoteHead ?? true
const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead)
@@ -541,7 +551,7 @@ export function buildSearchBaseRefsArgv(
// patterns. A single remaining token means the user hasn't committed
// to a remote-plus-branch query yet — route through the widened
// single-segment globs below instead of pinning to one segment.
const tokens = normalizedQuery.split('/').filter((t) => t.length > 0)
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length <= 1) {
const q = tokens[0] ?? ''
// Why `**`, not `*`: git for-each-ref globs are fnmatch-style where a
@@ -563,7 +573,82 @@ export function buildSearchBaseRefsArgv(
// as `<remote>/<branch>`, so users naturally retype that format; this
// branch is what makes re-typing a visible result actually find it.
const segmented = tokens.map((token) => `*${token}*`).join('/')
return [...base, `refs/remotes/${segmented}`, `refs/heads/${segmented}`]
const substringQuery = tokens.join('/')
const remoteBranchRootPatterns =
options.remoteNames && options.remoteNames.length > 0
? options.remoteNames.flatMap((remote) => [
`refs/remotes/${remote}/${substringQuery}*`,
`refs/remotes/${remote}/${substringQuery}*/**`
])
: [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`]
const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`]
const branchRootPatterns = [
// Why: branch names often contain slashes (`plan/docs`). Segment-wise
// display-format globs only align with `<remote>/<branch>`; these root
// patterns also match the local branch name beneath any configured remote.
`refs/heads/${substringQuery}*`,
`refs/heads/${substringQuery}*/**`,
...remoteBranchRootPatterns
]
const patterns =
options.patternGroup === 'segmented'
? segmentedPatterns
: options.patternGroup === 'branchRoot'
? branchRootPatterns
: [...segmentedPatterns, ...branchRootPatterns]
return [...base, ...patterns]
}
async function runSearchBaseRefsGit(
path: string,
normalizedQuery: string,
limit: number,
options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup }
): Promise<{ stdout: string }> {
try {
return await gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
)
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
)
}
}
export function mergeBaseRefSearchResultGroups(
groups: readonly BaseRefSearchResult[][],
limit: number
): BaseRefSearchResult[] {
const seen = new Set<string>()
const merged: BaseRefSearchResult[] = []
const maxLength = Math.max(0, ...groups.map((group) => group.length))
for (let index = 0; index < maxLength && merged.length < limit; index += 1) {
for (const group of groups) {
const entry = group[index]
if (!entry || seen.has(entry.refName)) {
continue
}
seen.add(entry.refName)
merged.push(entry)
if (merged.length >= limit) {
break
}
}
}
return merged
}
export function isForEachRefExcludeUnsupportedError(error: unknown): boolean {
@@ -655,23 +740,31 @@ export async function searchBaseRefDetails(
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH sibling cannot drift.
const remotesPromise = listRemoteNames(path)
let result: { stdout: string }
try {
result = await gitExecFileAsync(buildSearchBaseRefsArgv(normalizedQuery, limit), {
cwd: path
})
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
result = await gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
{ cwd: path }
const remotes = await listRemoteNames(path)
const tokens = getRefSearchTokens(normalizedQuery)
if (tokens.length > 1) {
// Why: ambiguous slash queries need both display-format matches
// (`upstream/feat`) and local branch-name matches (`plan/docs`).
// Parse and merge buckets before the final limit so neither side starves.
const results = await Promise.all([
runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes,
patternGroup: 'segmented'
}),
runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes,
patternGroup: 'branchRoot'
})
])
return mergeBaseRefSearchResultGroups(
results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)),
limit
)
}
const remotes = await remotesPromise
const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, {
remoteNames: remotes
})
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
} catch (err) {
// Why: surface the failure for diagnostics; callers treat `[]` as "no
+184
View File
@@ -288,6 +288,190 @@ describe('runner execFile timeout handling', () => {
expect(capturedEnv?.GIT_SSH_COMMAND).toContain('BatchMode=yes')
})
it('probes core.sshCommand for opted-in network git calls', async () => {
const child = createMockChildProcess(1234)
const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = []
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
calls.push({ args, env: opts.env })
cb(null, args[0] === 'config' ? 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key\n' : '', '')
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(calls[0]?.args).toEqual(['config', '--get', 'core.sshCommand'])
expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0')
expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined()
expect(calls[1]?.args).toEqual(['fetch', 'origin'])
expect(calls[1]?.env.GIT_SSH_COMMAND).toBe(
'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key -o BatchMode=yes'
)
})
it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, 'ssh -o BatchMode=no -i ~/.ssh/personal\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/personal -o BatchMode=yes')
})
it('merges quoted ssh.exe command shapes for opted-in network calls', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, '"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe(
"'C:/Program Files/Git/usr/bin/ssh.exe' -F ~/.ssh/config -o BatchMode=yes"
)
})
it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, `${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\n`, '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe(
String.raw`'C:\Git\usr\bin\ssh.exe' -i 'C:\Users\me\.ssh\work_key' -o BatchMode=yes`
)
})
it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, '/usr/local/bin/work-ssh-wrapper --account work\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
expect(capturedEnv?.GIT_ASKPASS).toBe('')
expect(capturedEnv?.SSH_ASKPASS).toBe('')
expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined()
})
it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, 'ssh -i "$HOME/.ssh/work_key"\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined()
})
it('falls back to generic batch-mode SSH when opted-in config is unset', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
})
it('preserves explicit GIT_SSH_COMMAND and skips the opted-in config probe', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, _args, opts, cb) => {
capturedEnv = opts.env
cb(null, '', '')
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' },
useConfiguredSshCommandForNetwork: true
})
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('custom-ssh -o IdentityAgent=none')
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
})
it('routes git through the selected WSL distro login shell when requested', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
+195 -16
View File
@@ -253,6 +253,7 @@ type GitExecOptions = {
env?: NodeJS.ProcessEnv
signal?: AbortSignal
wslDistro?: string
useConfiguredSshCommandForNetwork?: boolean
}
type CommandExecOptions = {
@@ -517,6 +518,15 @@ export function gitOptionalLocksDisabledEnv(
}
}
function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return {
...env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? ''
}
}
/**
* Force git to be non-interactive so it fails fast instead of blocking forever
* on a prompt. Without this, a git read-path call (status, worktree list, …)
@@ -533,18 +543,175 @@ export function gitOptionalLocksDisabledEnv(
* caller hasn't set its own GIT_SSH_COMMAND.
*/
export function nonInteractiveGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = {
...env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? ''
}
const next = promptGuardGitEnv(env)
if (!next.GIT_SSH_COMMAND) {
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
}
return next
}
type GitSshPolicyMode =
| 'default'
| 'explicit-env'
| 'fallback'
| 'configured-openssh'
| 'configured-wrapper-passthrough'
const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500
function commandBasename(command: string): string {
const pieces = command.split(/[\\/]+/)
return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase()
}
function isMergeableOpenSshCommand(command: string): boolean {
const basename = commandBasename(command)
return basename === 'ssh' || basename === 'ssh.exe'
}
function shellTokenize(command: string): string[] | null {
const tokens: string[] = []
let current = ''
let quote: "'" | '"' | null = null
let escaped = false
for (let i = 0; i < command.length; i++) {
const char = command[i]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\') {
const next = command[i + 1]
if (next && /[\s'"\\]/.test(next)) {
escaped = true
} else {
current += char
}
continue
}
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
if (';&|<>()`'.includes(char)) {
return null
}
current += char
}
if (escaped || quote) {
return null
}
if (current) {
tokens.push(current)
}
return tokens
}
function shellQuoteToken(token: string): string {
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token)
}
function containsShellExpansionSyntax(command: string): boolean {
return command.includes('$')
}
function withoutBatchModeOptions(tokens: string[]): string[] {
const next: string[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const lower = token.toLowerCase()
if (lower === '-o') {
const option = tokens[i + 1]?.toLowerCase()
if (option?.startsWith('batchmode')) {
i += 1
continue
}
}
if (lower.startsWith('-obatchmode')) {
continue
}
next.push(token)
}
return next
}
function buildOpenSshBatchModeCommand(configuredCommand: string): string | null {
if (containsShellExpansionSyntax(configuredCommand)) {
return null
}
const tokens = shellTokenize(configuredCommand)
if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) {
return null
}
return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ')
}
async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
env: NodeJS.ProcessEnv
mode: GitSshPolicyMode
}> {
const promptEnv = promptGuardGitEnv(options.env)
if (promptEnv.GIT_SSH_COMMAND) {
return { env: promptEnv, mode: 'explicit-env' }
}
const resolved = resolveCommand(
'git',
['config', '--get', 'core.sshCommand'],
options.cwd,
options.wslDistro,
{ useWslLoginShell: Boolean(options.wslDistro) }
)
let configuredCommand = ''
try {
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'utf-8',
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS,
env: promptEnv,
signal: options.signal
})
configuredCommand = String(stdout).trim()
} catch {
configuredCommand = ''
}
if (!configuredCommand) {
return { env: { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }, mode: 'fallback' }
}
const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand)
if (!batchModeCommand) {
// Why: custom wrappers are executable user policy; rewriting their argv is
// riskier than relying on prompt guards plus the caller's target timeout.
return { env: promptEnv, mode: 'configured-wrapper-passthrough' }
}
return {
env: { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand },
mode: 'configured-openssh'
}
}
/**
* Async git command execution. Drop-in replacement for
* `execFileAsync('git', args, { cwd, encoding, ... })`.
@@ -563,16 +730,28 @@ export async function gitExecFileAsync(
const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, {
useWslLoginShell: Boolean(options.wslDistro)
})
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
// Why: never let a git read-path call block on an interactive prompt
// (issue #5308) — fail fast instead of hanging the runtime.
env: nonInteractiveGitEnv(options.env),
signal: options.signal
})
const policy = options.useConfiguredSshCommandForNetwork
? await buildNetworkSshPolicyEnv(options)
: { env: nonInteractiveGitEnv(options.env), mode: 'default' as const }
let result: { stdout: string | Buffer; stderr: string | Buffer }
try {
result = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
// Why: never let a git read-path call block on an interactive prompt
// (issue #5308) — fail fast instead of hanging the runtime.
env: policy.env,
signal: options.signal
})
} catch (error) {
if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') {
Object.assign(error, { gitSshPolicyMode: policy.mode })
}
throw error
}
const { stdout, stderr } = result
return { stdout: stdout as string, stderr: stderr as string }
}
)
+6 -18
View File
@@ -112,6 +112,7 @@ import { initializeBrowserSessionsForApp } from './browser/browser-session-start
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
import { AgentAwakeService } from './agent-awake-service'
import {
getCrashBreadcrumbSnapshot,
@@ -165,15 +166,6 @@ let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
function buildHeadlessAutomationWorkspaceName(runTitle: string, scheduledFor: number): string {
const slug = runTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
return `auto-${slug || 'run'}-${stamp}`
}
let starNag: StarNagService | null = null
let agentAwakeService: AgentAwakeService | null = null
let crashReports: CrashReportStore | null = null
@@ -1386,15 +1378,11 @@ app.whenReady().then(async () => {
if (automation.workspaceMode === 'new_per_run') {
const created = await runtimeService.createManagedWorktree({
repoSelector: target.repo.id,
name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor),
baseBranch: automation.baseBranch ?? undefined,
setupDecision: 'inherit',
activate: false,
createdWithAgent: automation.agentId,
startupAgent: automation.agentId,
startupPrompt: automation.prompt,
telemetrySource: 'unknown'
...buildHeadlessAutomationWorktreeCreateArgs({
automation,
run,
repo: target.repo
})
})
terminalHandle = created.startupTerminal?.handle ?? ''
terminalSessionId = created.startupTerminal?.tabId ?? null
+7 -2
View File
@@ -6450,7 +6450,12 @@ describe('registerPtyHandlers', () => {
})
it('seeds headless terminal state with cold-restore cwd metadata', async () => {
const coldRestore = { scrollback: 'restored history\r\n', cwd: '/projects/restored' }
const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }]
const coldRestore = {
scrollback: 'restored history\r\n',
cwd: '/projects/restored',
oscLinks
}
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-cold-restore', coldRestore })),
write: vi.fn(),
@@ -6481,7 +6486,7 @@ describe('registerPtyHandlers', () => {
'pty-cold-restore',
'restored history\r\n',
undefined,
{ cwd: '/projects/restored' }
{ cwd: '/projects/restored', oscLinks }
)
})
+2 -1
View File
@@ -2823,7 +2823,8 @@ export function registerPtyHandlers(
result.coldRestore.scrollback.length > 0
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
})
}
}
+12 -7
View File
@@ -2561,17 +2561,22 @@ describe('repos:searchBaseRefs SSH relay', () => {
await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' })
expect(mockGitProvider.exec).toHaveBeenCalledTimes(2)
const [argv] = mockGitProvider.exec.mock.calls.find(
expect(mockGitProvider.exec).toHaveBeenCalledTimes(3)
const forEachRefCalls = mockGitProvider.exec.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)!
expect(argv).toContain('refs/remotes/*upstream*/*main*')
expect(argv).toContain('refs/heads/*upstream*/*main*')
)
expect(forEachRefCalls).toHaveLength(2)
const segmentedArgv = forEachRefCalls[0][0] as string[]
const branchRootArgv = forEachRefCalls[1][0] as string[]
expect(segmentedArgv).toContain('refs/remotes/*upstream*/*main*')
expect(segmentedArgv).toContain('refs/heads/*upstream*/*main*')
expect(branchRootArgv).toContain('refs/remotes/*/upstream/main*')
expect(branchRootArgv).toContain('refs/heads/upstream/main*')
// Regression guard: the literal slash must never appear inside a
// single segmented glob (would be `refs/remotes/*upstream/main*`),
// which fnmatch cannot match because `*` doesn't cross `/`.
expect(argv).not.toContain('refs/remotes/*upstream/main*/*')
expect(argv).not.toContain('refs/remotes/*/*upstream/main*')
expect(segmentedArgv).not.toContain('refs/remotes/*upstream/main*/*')
expect(segmentedArgv).not.toContain('refs/remotes/*/*upstream/main*')
expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo')
})
+42 -19
View File
@@ -69,6 +69,7 @@ import {
resolveDefaultBaseRefViaExec,
buildSearchBaseRefsArgv,
isForEachRefExcludeUnsupportedError,
mergeBaseRefSearchResultGroups,
searchBaseRefDetails
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
@@ -2423,29 +2424,51 @@ async function searchBaseRefDetailsForRepo(
try {
// Why: argv (including the two-remote-glob rationale) lives in
// buildSearchBaseRefsArgv so the SSH and local paths cannot drift.
const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
let result: { stdout: string }
try {
result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path)
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
result = await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
repo.path
)
}
const remotesResult = await remotesPromise
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.
// See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
// HEAD-filter rationale.
const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
const remotes = remotesResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
try {
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
}
}
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.
// See parseAndFilterSearchRefs in ../git/repo.ts for the dedup +
// HEAD-filter rationale.
const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0)
if (searchTokens.length > 1) {
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
return mergeBaseRefSearchResultGroups(
results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)),
limit
)
}
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes)
} catch (err) {
console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', {
path: repo.path,
+28 -2
View File
@@ -354,7 +354,20 @@ describe('mergeWorktree', () => {
sortOrder: 5,
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: []
diffComments: [],
automationProvenance: {
kind: 'created-by-automation' as const,
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'ssh' as const,
executionTargetId: 'openclaw-2',
projectId: 'github:stablyai/orca',
repoId: 'repo1',
hostId: 'ssh:openclaw-2' as const
}
}
const result = mergeWorktree('repo1', baseGit, meta)
expect(result).toEqual({
@@ -387,7 +400,20 @@ describe('mergeWorktree', () => {
sortOrder: 5,
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: []
diffComments: [],
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'ssh',
executionTargetId: 'openclaw-2',
projectId: 'github:stablyai/orca',
repoId: 'repo1',
hostId: 'ssh:openclaw-2'
}
})
})
+3 -76
View File
@@ -1,23 +1,15 @@
import { basename, resolve, relative, isAbsolute, posix, sep, win32 } from 'path'
import type {
GitWorktreeInfo,
GlobalSettings,
OrcaWorkspaceLayout,
Repo,
Worktree,
WorktreeMeta
} from '../../shared/types'
import { resolve, relative, isAbsolute, posix, sep, win32 } from 'path'
import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types'
import { resolveRuntimePath } from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { splitWorktreeId } from '../../shared/worktree-id'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { getWslHome, parseWslPath } from '../wsl'
import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata'
type WorktreePathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name'
export { mergeWorktree } from './worktree-metadata-merge'
/**
* Sanitize a worktree name for use in branch names and directory paths.
@@ -265,71 +257,6 @@ export function shouldSetDisplayName(
return !(branchName === requestedName && sanitizedName === requestedName)
}
/**
* Merge raw git worktree info with persisted user metadata into a full Worktree.
*/
export function mergeWorktree(
repoId: string,
git: GitWorktreeInfo,
meta: WorktreeMeta | undefined,
defaultDisplayName?: string
): Worktree {
const branchShort = git.branch.replace(/^refs\/heads\//, '')
return {
id: `${repoId}::${git.path}`,
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId,
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}),
...(meta?.projectHostSetupId !== undefined
? { projectHostSetupId: meta.projectHostSetupId }
: {}),
path: git.path,
head: git.head,
branch: git.branch,
isBare: git.isBare,
...(git.isSparse === true ? { isSparse: true } : {}),
isMainWorktree: git.isMainWorktree,
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
comment: meta?.comment || '',
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null,
...getLinkedWorkItemMetadata(meta),
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
isPinned: meta?.isPinned ?? false,
sortOrder: meta?.sortOrder ?? 0,
...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
lastActivityAt: meta?.lastActivityAt ?? 0,
...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta?.pendingFirstAgentMessageRename !== undefined
? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename }
: {}),
...(meta?.firstAgentMessageRenameError !== undefined
? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError }
: {}),
...(git.isSparse === true
? {
sparseDirectories: meta?.sparseDirectories,
sparseBaseRef: meta?.sparseBaseRef,
sparsePresetId: meta?.sparsePresetId
}
: {}),
...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}),
...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}),
workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
// Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in
// shared/types) and forwarded verbatim so the renderer store mirrors
// on-disk state. `undefined` here means the worktree has no comments yet.
diffComments: meta?.diffComments,
mobileDiffReview: meta?.mobileDiffReview
}
}
/**
* Parse a composite worktreeId ("repoId::worktreePath") into its parts.
*/
+71
View File
@@ -0,0 +1,71 @@
import { basename } from 'path'
import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata'
/**
* Merge raw git worktree info with persisted user metadata into a full Worktree.
*/
export function mergeWorktree(
repoId: string,
git: GitWorktreeInfo,
meta: WorktreeMeta | undefined,
defaultDisplayName?: string
): Worktree {
const branchShort = git.branch.replace(/^refs\/heads\//, '')
return {
id: `${repoId}::${git.path}`,
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
repoId,
...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}),
...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}),
...(meta?.projectHostSetupId !== undefined
? { projectHostSetupId: meta.projectHostSetupId }
: {}),
path: git.path,
head: git.head,
branch: git.branch,
isBare: git.isBare,
...(git.isSparse === true ? { isSparse: true } : {}),
isMainWorktree: git.isMainWorktree,
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
comment: meta?.comment || '',
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null,
...getLinkedWorkItemMetadata(meta),
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
isPinned: meta?.isPinned ?? false,
sortOrder: meta?.sortOrder ?? 0,
...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
lastActivityAt: meta?.lastActivityAt ?? 0,
...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta?.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
...(meta?.pendingFirstAgentMessageRename !== undefined
? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename }
: {}),
...(meta?.firstAgentMessageRenameError !== undefined
? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError }
: {}),
...(git.isSparse === true
? {
sparseDirectories: meta?.sparseDirectories,
sparseBaseRef: meta?.sparseBaseRef,
sparsePresetId: meta?.sparsePresetId
}
: {}),
...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}),
...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}),
workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
// Why: diff comments are persisted on WorktreeMeta and forwarded verbatim
// so the renderer store mirrors on-disk state.
diffComments: meta?.diffComments,
mobileDiffReview: meta?.mobileDiffReview
}
}
+9 -2
View File
@@ -13,6 +13,7 @@ import { existsSync } from 'fs'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type {
AutomationWorkspaceProvenance,
CreateWorktreeArgs,
CreateWorktreeResult,
GitPushTarget,
@@ -56,6 +57,10 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { getSshGitUsername } from '../git/git-username'
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
automationProvenance?: AutomationWorkspaceProvenance
}
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
@@ -1341,7 +1346,7 @@ export function emitCreateWorktreeProgress(
}
export async function createRemoteWorktree(
args: CreateWorktreeArgs,
args: CreateWorktreeArgsWithSystemProvenance,
repo: Repo,
store: Store,
mainWindow: BrowserWindow
@@ -1642,6 +1647,7 @@ export async function createRemoteWorktree(
orcaCreatedAt: now,
orcaCreationSource: 'ssh',
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings),
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
baseRef: metadataBaseRef,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
@@ -1749,7 +1755,7 @@ export async function createRemoteWorktree(
}
export async function createLocalWorktree(
args: CreateWorktreeArgs,
args: CreateWorktreeArgsWithSystemProvenance,
repo: Repo,
store: Store,
mainWindow: BrowserWindow,
@@ -2238,6 +2244,7 @@ export async function createLocalWorktree(
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings),
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
baseRef: metadataBaseRef,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
+35
View File
@@ -669,6 +669,41 @@ describe('registerWorktreeHandlers', () => {
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
})
it('does not trust renderer-authored automation provenance during local create', async () => {
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Forged',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Forged run',
createdAt: 123,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1'
}
})
const persistedMeta = store.setWorktreeMeta.mock.calls.find(
([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard'
)?.[1]
expect(persistedMeta).toBeDefined()
expect(persistedMeta).not.toHaveProperty('automationProvenance')
})
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
// rather than failing and forcing the user back to the name picker.
+31 -4
View File
@@ -14,6 +14,7 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import type {
AutomationWorkspaceProvenance,
CreateWorktreeArgs,
CreateWorktreeResult,
DetectedWorktree,
@@ -89,6 +90,15 @@ import { removeWorktreeLinkedPaths } from './worktree-symlinks'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events'
import {
finishAutomationWorkspaceProvenanceRequest,
releaseAutomationWorkspaceProvenanceRequest,
resolveAutomationWorkspaceProvenance
} from '../automations/workspace-provenance'
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
automationProvenance?: AutomationWorkspaceProvenance
}
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import {
@@ -627,6 +637,9 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta
lastActivityAt: meta.lastActivityAt ?? 0,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments,
mobileDiffReview: meta.mobileDiffReview
@@ -698,7 +711,7 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
}
function createFolderWorkspace(
args: CreateWorktreeArgs,
args: CreateWorktreeArgsWithSystemProvenance,
repo: Repo,
store: Store
): CreateWorktreeResult {
@@ -715,6 +728,7 @@ function createFolderWorkspace(
createdAt: now,
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
@@ -1022,6 +1036,17 @@ export function registerWorktreeHandlers(
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: args.repoId,
repo,
request: args.automationProvenanceRequest
})
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
...args,
automationProvenance
}
let result: CreateWorktreeResult
try {
// Why: only wrap the helpers themselves. The pre-validation throws
@@ -1030,11 +1055,12 @@ export function registerWorktreeHandlers(
// git/filesystem failures the funnel cares about — bucketing them
// into `unknown` would pollute the failure taxonomy.
result = isFolderRepo(repo)
? createFolderWorkspace(args, repo, store)
? createFolderWorkspace(createArgs, repo, store)
: repo.connectionId
? await createRemoteWorktree(args, repo, store, mainWindow)
: await createLocalWorktree(args, repo, store, mainWindow, runtime)
? await createRemoteWorktree(createArgs, repo, store, mainWindow)
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
track('workspace_create_failed', {
source,
error_class: classifyWorkspaceCreateError(error),
@@ -1042,6 +1068,7 @@ export function registerWorktreeHandlers(
})
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
// Why: emit `workspace_created` only after the underlying create has
// resolved (the helpers throw on failure, so reaching this line means
+31 -11
View File
@@ -5084,6 +5084,7 @@ describe('Store', () => {
'issue',
'linear-issue',
'pr',
'automation',
'comment',
'ports',
'inline-agents'
@@ -5209,7 +5210,7 @@ describe('Store', () => {
expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents')
})
it('uses the default preset when card properties are missing', async () => {
it('uses the compact preset when card properties are missing in compact mode', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
@@ -5222,18 +5223,37 @@ describe('Store', () => {
const store = await createStore()
expect(store.getSettings().compactWorktreeCards).toBe(true)
expect(store.getUI().worktreeCardProperties).toEqual([
'status',
'unread',
'issue',
'linear-issue',
'pr',
'comment',
'ports',
'inline-agents'
])
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread'])
expect(store.getUI().worktreeCardProperties).not.toContain('automation')
})
it.each([
['raw', ['status', 'automation']],
['normalized', ['status', 'unread', 'automation']]
] as const)(
'migrates the old %s defaulted compact preset without automation',
async (_, props) => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { compactWorktreeCards: true },
ui: {
worktreeCardProperties: [...props],
_worktreeCardModeDefaulted: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().compactWorktreeCards).toBe(true)
expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread'])
expect(store.getUI().worktreeCardProperties).not.toContain('automation')
expect(store.getUI()._worktreeCardModeDefaulted).toBe(true)
}
)
// ── GitHub Cache ───────────────────────────────────────────────────
it('get/set GitHub cache round-trips', async () => {
+19 -4
View File
@@ -89,6 +89,8 @@ import {
getDefaultUIState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession,
getWorktreeCardModeProperties,
isLegacyDefaultedCompactWorktreeCardProperties,
normalizeAgentActivityDisplayMode,
normalizeWorktreeCardProperties,
ONBOARDING_FLOW_VERSION,
@@ -2651,6 +2653,10 @@ export class Store {
this.loadNeedsSave = true
}
const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups)
const loadedCompactWorktreeCards =
parsed.settings?.compactWorktreeCards ??
parsed.settings?.experimentalCompactWorktreeCards ??
defaults.settings.compactWorktreeCards
result = {
...defaults,
...parsed,
@@ -2691,10 +2697,7 @@ export class Store {
experimentalActivityDefaultedOffForAllUsers: true,
// Why: compact worktree cards graduated from Experimental; preserve
// the old opt-in for profiles written during the rollout.
compactWorktreeCards:
parsed.settings?.compactWorktreeCards ??
parsed.settings?.experimentalCompactWorktreeCards ??
defaults.settings.compactWorktreeCards,
compactWorktreeCards: loadedCompactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
@@ -2797,10 +2800,17 @@ export class Store {
!deliberateUncheck &&
Array.isArray(rawCardProps) &&
!rawCardProps.includes('inline-agents')
const needsLegacyDefaultedCompactMigration =
loadedCompactWorktreeCards &&
parsed.ui?._worktreeCardModeDefaulted === true &&
isLegacyDefaultedCompactWorktreeCardProperties(rawCardProps)
const migratedCardProps = (() => {
if (!Array.isArray(rawCardProps)) {
return undefined
}
if (needsLegacyDefaultedCompactMigration) {
return getWorktreeCardModeProperties('Compact')
}
const candidate = needsInlineAgentsMigration
? [...rawCardProps, 'inline-agents' as const]
: rawCardProps
@@ -2845,6 +2855,11 @@ export class Store {
}
return {
...defaults.ui,
// Why: missing card properties should follow the persisted card
// layout mode; explicit property choices are preserved below.
worktreeCardProperties: getWorktreeCardModeProperties(
loadedCompactWorktreeCards ? 'Compact' : 'Default'
),
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
// Why: migrate once from the retired Appearance setting only
// when no explicit persisted chrome preference exists yet.
+2
View File
@@ -19,6 +19,7 @@ import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-histo
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
// ─── PTY Provider ───────────────────────────────────────────────────
@@ -97,6 +98,7 @@ export type PtySpawnResult = {
coldRestore?: {
scrollback: string
cwd: string
oscLinks?: TerminalOscLinkRange[]
}
}
@@ -55,6 +55,28 @@ describe('markRemoteAgentWorkspaceTrusted', () => {
)
})
it('writes Codex trust when the remote home is a Windows absolute path', async () => {
const fsProvider = makeFsProvider({
realpath: vi.fn(async () => 'C:/Users/alice/platform')
})
mocks.getActiveMultiplexer.mockReturnValue({
request: vi.fn(async () => ({ resolvedPath: 'C:\\Users\\alice\\' }))
})
mocks.getSshFilesystemProvider.mockReturnValue(fsProvider)
await markRemoteAgentWorkspaceTrusted({
preset: 'codex',
connectionId: 'ssh-windows',
workspacePath: 'C:\\Users\\alice\\platform'
})
expect(fsProvider.createDir).toHaveBeenCalledWith('C:/Users/alice/.codex')
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'C:/Users/alice/.codex/config.toml',
expect.stringContaining('[projects."C:/Users/alice/platform"]')
)
})
it('writes Cursor trust marker on the remote host', async () => {
const fsProvider = makeFsProvider()
mocks.getSshFilesystemProvider.mockReturnValue(fsProvider)
@@ -72,6 +94,30 @@ describe('markRemoteAgentWorkspaceTrusted', () => {
)
})
it('sanitizes Windows path characters in remote Cursor trust marker paths', async () => {
const fsProvider = makeFsProvider({
realpath: vi.fn(async () => 'C:/Users/alice/platform')
})
mocks.getActiveMultiplexer.mockReturnValue({
request: vi.fn(async () => ({ resolvedPath: 'C:/Users/alice/' }))
})
mocks.getSshFilesystemProvider.mockReturnValue(fsProvider)
await markRemoteAgentWorkspaceTrusted({
preset: 'cursor',
connectionId: 'ssh-windows',
workspacePath: 'C:\\Users\\alice\\platform'
})
expect(fsProvider.createDir).toHaveBeenCalledWith(
'C:/Users/alice/.cursor/projects/C-Users-alice-platform'
)
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'C:/Users/alice/.cursor/projects/C-Users-alice-platform/.workspace-trusted',
expect.stringContaining('"workspacePath": "C:/Users/alice/platform"')
)
})
it('appends Copilot trusted folder remotely without clobbering config keys', async () => {
const writeFile = vi.fn(async (_filePath: string, _content: string) => undefined)
const fsProvider = makeFsProvider({
+12 -3
View File
@@ -3,6 +3,10 @@ import { upsertProjectTrustLevelInContent } from './codex/config-toml-trust'
import { getActiveMultiplexer } from './ipc/ssh'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from './providers/types'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathSeparators
} from '../shared/cross-platform-path'
export async function markRemoteAgentWorkspaceTrusted(args: {
preset: AgentTrustPreset
@@ -33,8 +37,13 @@ async function resolveRemoteHome(connectionId: string): Promise<string | null> {
const result = (await mux.request('session.resolveHome', { path: '~' })) as {
resolvedPath?: unknown
}
const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : ''
return home && home.startsWith('/') && !hasRemotePathControlCharacter(home)
const home =
typeof result.resolvedPath === 'string'
? normalizeRuntimePathSeparators(result.resolvedPath.trim())
: ''
return home &&
(home.startsWith('/') || isWindowsAbsolutePathLike(home)) &&
!hasRemotePathControlCharacter(home)
? home.replace(/\/$/, '')
: null
}
@@ -87,7 +96,7 @@ async function markRemoteCursorWorkspaceTrusted(
remoteHome: string,
workspacePath: string
): Promise<void> {
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-')
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-')
if (!slug) {
return
}
+12 -4
View File
@@ -28,6 +28,14 @@ function fetchCallCount(): number {
).length
}
function exactBaseRefreshOptions(cwd: string): {
cwd: string
timeout: number
useConfiguredSshCommandForNetwork: boolean
} {
return { cwd, timeout: 60_000, useConfiguredSshCommandForNetwork: true }
}
function mockFetchResults(results: (Promise<unknown> | unknown)[]): void {
let fetchIndex = 0
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
@@ -175,7 +183,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/f' }
exactBaseRefreshOptions('/repo/f')
)
})
@@ -289,7 +297,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
expect(fetchCalls).toEqual([
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/h' }
exactBaseRefreshOptions('/repo/h')
],
[['fetch', 'origin'], { cwd: '/repo/h' }]
])
@@ -336,7 +344,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
[['fetch', 'origin'], { cwd: '/repo/i' }],
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/i' }
exactBaseRefreshOptions('/repo/i')
]
])
})
@@ -382,7 +390,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
[['fetch', 'origin'], { cwd: '/repo/i-fail' }],
[
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: '/repo/i-fail' }
exactBaseRefreshOptions('/repo/i-fail')
]
])
})
+9 -2
View File
@@ -1033,7 +1033,13 @@ export class RuntimeBrowserCommands {
}
async browserMouseClick(
params: { x: number; y: number; button?: string; radius?: number } & BrowserCommandTargetParams
params: {
x: number
y: number
button?: string
radius?: number
modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[]
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseClick(
@@ -1042,7 +1048,8 @@ export class RuntimeBrowserCommands {
params.button,
target.worktreeId,
target.browserPageId,
clampOptionalNumber(params.radius, 0, 64)
clampOptionalNumber(params.radius, 0, 64),
params.modifiers
)
}
+29 -11
View File
@@ -1114,22 +1114,27 @@ computeWorktreePathMock.mockImplementation(
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
describe('OrcaRuntimeService', () => {
it('projects experimentalNewWorktreeCardStyle to paired client settings', () => {
it('projects worktree card display settings to paired clients', () => {
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
experimentalNewWorktreeCardStyle: true
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
})
} as never)
expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true })
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
})
})
it('accepts experimentalNewWorktreeCardStyle updates from paired clients', () => {
it('accepts worktree card display setting updates from paired clients', () => {
let settings = {
...store.getSettings(),
experimentalNewWorktreeCardStyle: false
experimentalNewWorktreeCardStyle: false,
compactWorktreeCards: false
}
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
@@ -1141,14 +1146,23 @@ describe('OrcaRuntimeService', () => {
updateSettings
} as never)
expect(runtime.updateClientSettings({ experimentalNewWorktreeCardStyle: true })).toMatchObject({
experimentalNewWorktreeCardStyle: true
expect(
runtime.updateClientSettings({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
})
).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
})
expect(updateSettings).toHaveBeenCalledWith(
{ experimentalNewWorktreeCardStyle: true },
{ experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true },
{ notifyListeners: true }
)
expect(runtime.getClientSettings()).toMatchObject({ experimentalNewWorktreeCardStyle: true })
expect(runtime.getClientSettings()).toMatchObject({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true
})
})
it('rejects relative paths for runtime nested repo scan/import', async () => {
@@ -1916,7 +1930,11 @@ describe('OrcaRuntimeService', () => {
await vi.waitFor(() => {
expect(gitSpy).toHaveBeenCalledWith(
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
{ cwd: TEST_REPO_PATH }
{
cwd: TEST_REPO_PATH,
useConfiguredSshCommandForNetwork: true,
timeout: 60_000
}
)
})
expect(addWorktree).not.toHaveBeenCalled()
@@ -15024,7 +15042,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
message:
'Worktree created, but Orca could not validate the current directory as a parent workspace.'
'Worktree created, but Orca could not validate the current directory as a parent context.'
})
])
})
+88 -34
View File
@@ -10,6 +10,7 @@ import {
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import type { AgentStatus } from '../../shared/agent-detection'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import {
createTerminalTitleTracker,
stripBrailleSpinnerGlyphs,
@@ -54,6 +55,7 @@ import type {
AutomationWorkspaceMode
} from '../../shared/automations-types'
import type {
AutomationWorkspaceProvenance,
BaseRefSearchResult,
CreateWorktreeResult,
DetectedWorktree,
@@ -529,6 +531,7 @@ import {
resolveDefaultBaseRefViaExec,
buildSearchBaseRefsArgv,
isForEachRefExcludeUnsupportedError,
mergeBaseRefSearchResultGroups,
getRemoteDrift,
getRecentDriftSubjects
} from '../git/repo'
@@ -733,6 +736,7 @@ type RuntimeStore = {
defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection']
githubProjects?: GlobalSettings['githubProjects']
experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle']
compactWorktreeCards?: GlobalSettings['compactWorktreeCards']
gitlabProjects?: GlobalSettings['gitlabProjects']
experimentalWorktreeSymlinks?: boolean
mobileAutoRestoreFitMs?: number | null
@@ -946,6 +950,7 @@ type HeadlessSeedMetadata = {
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
kittyKeyboardFlags?: number
oscLinks?: TerminalOscLinkRange[]
}
type RuntimePtyController = {
@@ -1235,6 +1240,9 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt
lastActivityAt: meta.lastActivityAt ?? 0,
...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}),
...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}),
...(meta.automationProvenance !== undefined
? { automationProvenance: meta.automationProvenance }
: {}),
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
diffComments: meta.diffComments,
mobileDiffReview: meta.mobileDiffReview
@@ -2162,6 +2170,7 @@ export class OrcaRuntimeService {
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
> {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
@@ -2180,7 +2189,8 @@ export class OrcaRuntimeService {
defaultRepoSelection: settings.defaultRepoSelection ?? null,
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
compactWorktreeCards: settings.compactWorktreeCards === true
}
}
@@ -2199,6 +2209,7 @@ export class OrcaRuntimeService {
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
>
): Pick<
GlobalSettings,
@@ -2215,6 +2226,7 @@ export class OrcaRuntimeService {
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
@@ -4588,6 +4600,7 @@ export class OrcaRuntimeService {
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
} | null> {
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
}
@@ -4603,6 +4616,7 @@ export class OrcaRuntimeService {
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
} | null> {
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
}
@@ -4676,6 +4690,9 @@ export class OrcaRuntimeService {
if (metadata.cwd !== undefined) {
state.emulator.setCwd(metadata.cwd)
}
if (metadata.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(metadata.oscLinks)
}
})
.catch(() => {
// Seeding is best-effort; live data will continue to populate the
@@ -4913,6 +4930,7 @@ export class OrcaRuntimeService {
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
} | null> {
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
@@ -4925,6 +4943,7 @@ export class OrcaRuntimeService {
rows: number
cwd?: string | null
lastTitle?: string
oscLinks?: TerminalOscLinkRange[]
} | null = null
try {
// Why: read-fallback wants visible alt-screen content (e.g. an active
@@ -5009,6 +5028,7 @@ export class OrcaRuntimeService {
lastTitle?: string
seq?: number
source?: 'headless'
oscLinks?: TerminalOscLinkRange[]
} | null> {
const state = this.headlessTerminals.get(ptyId)
if (!state) {
@@ -5034,7 +5054,8 @@ export class OrcaRuntimeService {
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: state.outputSequence,
source: 'headless' as const
source: 'headless' as const,
oscLinks: snapshot.oscLinks
})
: null
}
@@ -8821,25 +8842,47 @@ export class OrcaRuntimeService {
}
const normalizedQuery = normalizeRefSearchQuery(query)
try {
const remotesPromise = provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
let result: { stdout: string }
try {
result = await provider.exec(buildSearchBaseRefsArgv(normalizedQuery, limit), repo.path)
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
result = await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false }),
repo.path
)
}
const remotesResult = await remotesPromise
const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
const remotes = remotesResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return parseAndFilterSearchRefDetails(result.stdout, limit, remotes)
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
try {
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
}
}
const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0)
if (searchTokens.length > 1) {
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
return mergeBaseRefSearchResultGroups(
results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)),
limit
)
}
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes)
} catch (err) {
console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', {
path: repo.path,
@@ -10851,6 +10894,7 @@ export class OrcaRuntimeService {
startupAgent?: TuiAgent
startupPrompt?: string
pendingFirstAgentMessageRename?: boolean
automationProvenance?: AutomationWorkspaceProvenance
startup?: WorktreeStartupLaunch
startupDraft?: string
startupDraftPaste?: WorktreeStartupDraftPaste
@@ -10910,6 +10954,7 @@ export class OrcaRuntimeService {
path: settings.workspaceDir,
nestWorkspaces: settings.nestWorkspaces
},
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined
@@ -11429,6 +11474,7 @@ export class OrcaRuntimeService {
...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}),
...(args.comment !== undefined ? { comment: args.comment } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
@@ -11708,6 +11754,7 @@ export class OrcaRuntimeService {
setupDecision?: 'run' | 'skip' | 'inherit'
createdWithAgent?: TuiAgent
pendingFirstAgentMessageRename?: boolean
automationProvenance?: AutomationWorkspaceProvenance
startup?: WorktreeStartupLaunch
startupFollowup?: WorktreeStartupFollowup
startupDraftPaste?: WorktreeStartupDraftPaste
@@ -11758,7 +11805,8 @@ export class OrcaRuntimeService {
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true
? { pendingFirstAgentMessageRename: true }
: {})
: {}),
...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {})
},
repo,
this.store as unknown as Store,
@@ -12067,7 +12115,14 @@ export class OrcaRuntimeService {
}
return gitExecFileAsync(
['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`],
{ cwd: repoPath, ...gitOptions }
{
cwd: repoPath,
...gitOptions,
// Why: exact remote-base refresh is the network gate for worktree
// creation, so honor repo SSH routing and bound custom wrappers.
useConfiguredSshCommandForNetwork: true,
timeout: 60_000
}
)
.then((): RemoteFetchResult => {
this.rememberFreshFetchCompletedAt(key)
@@ -12397,13 +12452,13 @@ export class OrcaRuntimeService {
if (!worktree.instanceId || !parent.instanceId) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Workspace instance identity was unavailable.'
'Worktree instance identity was unavailable.'
)
}
if (!this.store.setWorktreeLineage) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_MISSING',
'Workspace lineage storage was unavailable.'
'Worktree lineage storage was unavailable.'
)
}
const createdAt = Date.now()
@@ -14582,7 +14637,7 @@ export class OrcaRuntimeService {
const childWorktreeId = child.id
const parentWorktreeId = parent.id
if (childWorktreeId === parentWorktreeId) {
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.')
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.')
}
const instanceByWorktreeId = new Map(
this.resolvedWorktreeCache?.worktrees.map((worktree) => [
@@ -14599,7 +14654,7 @@ export class OrcaRuntimeService {
if (visited.has(cursor)) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CYCLE',
'Parent workspace would create a lineage cycle.'
'Parent selector would create a lineage cycle.'
)
}
visited.add(cursor)
@@ -14629,13 +14684,13 @@ export class OrcaRuntimeService {
if (input.noParent === true && (input.parentWorkspace || input.parentWorktree)) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_CONFLICT',
'Choose either a parent workspace flag or --no-parent, not both.'
'Choose either one parent selector or --no-parent.'
)
}
if (input.parentWorkspace && input.parentWorktree) {
throw new RuntimeLineageError(
'LINEAGE_PARENT_CONTEXT_CONFLICT',
'Choose either --parent-workspace or --parent-worktree, not both.'
'Choose either one parent selector or --no-parent.'
)
}
@@ -14654,10 +14709,10 @@ export class OrcaRuntimeService {
} catch {
throw new RuntimeLineageError(
'LINEAGE_PARENT_NOT_FOUND',
'Parent workspace was not found.',
'Parent selector was not found.',
{
nextSteps: [
'Pass a valid --parent-workspace selector such as folder:<id> or worktree:<id>.',
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Retry with --no-parent to create without lineage.'
]
}
@@ -14682,10 +14737,10 @@ export class OrcaRuntimeService {
} catch {
throw new RuntimeLineageError(
'LINEAGE_PARENT_NOT_FOUND',
'Parent workspace was not found.',
'Parent selector was not found.',
{
nextSteps: [
'Run `orca worktree list` and pass a valid --parent-worktree selector.',
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
'Retry with --no-parent to create without lineage.'
]
}
@@ -14707,8 +14762,7 @@ export class OrcaRuntimeService {
} catch {
warnings.push({
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
message:
'Worktree created, but Orca could not validate the environment parent workspace.',
message: 'Worktree created, but Orca could not validate the environment parent context.',
details: { envParentWorkspace: input.envParentWorkspace }
})
}
@@ -14777,7 +14831,7 @@ export class OrcaRuntimeService {
warnings.push({
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
message:
'Worktree created, but Orca could not validate the caller terminal as a parent workspace.',
'Worktree created, but Orca could not validate the caller terminal as a parent context.',
details: { callerTerminalHandle: input.callerTerminalHandle }
})
}
@@ -14793,7 +14847,7 @@ export class OrcaRuntimeService {
warnings.push({
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
message:
'Worktree created, but Orca could not validate the current directory as a parent workspace.',
'Worktree created, but Orca could not validate the current directory as a parent context.',
details: { cwdParentWorktree: input.cwdParentWorktree }
})
}
@@ -14817,7 +14871,7 @@ export class OrcaRuntimeService {
warnings: [
{
code: 'LINEAGE_PARENT_CONTEXT_CONFLICT',
message: 'Worktree created, but Orca could not prove which parent workspace caused it.',
message: 'Worktree created, but Orca could not prove which parent context caused it.',
details: {
terminalParentWorkspaceKey: candidates.find((c) => c.source === 'terminal-context')
?.parent.workspaceKey,
@@ -133,6 +133,7 @@ describe('remote runtime request connection integration', () => {
clientEventListeners.add(listener)
return () => clientEventListeners.delete(listener)
},
showRepo: () => repo,
listDetectedManagedWorktrees: () => ({
repoId: repo.id,
authoritative: true,
@@ -347,6 +348,7 @@ describe('remote runtime request connection integration', () => {
},
watchFileExplorer: async () => () => {},
listRepos: () => [repo],
showRepo: () => repo,
listDetectedManagedWorktrees: () => ({
repoId: repo.id,
authoritative: true,
+2 -2
View File
@@ -108,7 +108,7 @@ describe('mapRuntimeError', () => {
const response = mapRuntimeError(
'req_1',
{ runtimeId: 'runtime-1' },
new LineageError('Parent workspace was not found.')
new LineageError('Parent selector was not found.')
)
expect(response).toEqual({
@@ -116,7 +116,7 @@ describe('mapRuntimeError', () => {
ok: false,
error: {
code: 'LINEAGE_PARENT_NOT_FOUND',
message: 'Parent workspace was not found.',
message: 'Parent selector was not found.',
data: {
nextSteps: ['Run `orca worktree list`.', 'Retry with --no-parent.']
}
+11 -1
View File
@@ -1,3 +1,4 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { BrowserTarget, OptionalFiniteNumber } from '../schemas'
import {
@@ -21,7 +22,16 @@ import {
Viewport
} from './browser-schemas'
const MouseClick = MouseXY.merge(MouseButton).extend({ radius: OptionalFiniteNumber })
const MouseModifiers = z
.unknown()
.transform((v) => (Array.isArray(v) ? v : undefined))
.pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()]))
.optional()
const MouseClick = MouseXY.merge(MouseButton).extend({
radius: OptionalFiniteNumber,
modifiers: MouseModifiers
})
export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
defineMethod({
+10 -3
View File
@@ -21,6 +21,7 @@ describe('client UI RPC methods', () => {
visibleTaskProviders: ['github', 'gitlab'],
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
compactWorktreeCards: true,
githubProjects: {
pinned: [],
recent: [],
@@ -51,6 +52,7 @@ describe('client UI RPC methods', () => {
defaultRepoSelection: ['repo-1', 'repo-2'],
defaultLinearTeamSelection: ['team-1', 'team-2'],
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
githubProjects: {
pinned: [],
recent: [],
@@ -74,6 +76,7 @@ describe('client UI RPC methods', () => {
visibleTaskProviders: ['github', 'linear'],
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -87,6 +90,7 @@ describe('client UI RPC methods', () => {
visibleTaskProviders: ['github', 'linear'],
defaultTaskViewPreset: 'my-prs',
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
@@ -134,6 +138,7 @@ describe('client UI RPC methods', () => {
rightSidebarTab: 'checks',
rightSidebarExplorerView: 'search',
showActiveOnly: true,
hideAutomationGeneratedWorkspaces: true,
filterRepoIds: ['repo-1']
}
const runtime = {
@@ -149,6 +154,7 @@ describe('client UI RPC methods', () => {
rightSidebarExplorerView: 'search',
showActiveOnly: true,
hideSleepingWorkspaces: true,
hideAutomationGeneratedWorkspaces: true,
filterRepoIds: ['repo-1']
})
)
@@ -159,6 +165,7 @@ describe('client UI RPC methods', () => {
rightSidebarExplorerView: 'search',
showActiveOnly: true,
hideSleepingWorkspaces: true,
hideAutomationGeneratedWorkspaces: true,
filterRepoIds: ['repo-1']
})
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
@@ -167,7 +174,7 @@ describe('client UI RPC methods', () => {
it('accepts persisted literal UI arrays and nested UI state', async () => {
const updated: PersistedUIState = {
...getDefaultUIState(),
worktreeCardProperties: ['status', 'branch', 'inline-agents'],
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex'],
taskResumeState: {
@@ -203,7 +210,7 @@ describe('client UI RPC methods', () => {
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const payload = {
worktreeCardProperties: ['status', 'branch', 'inline-agents'],
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
_worktreeCardModeDefaulted: true,
statusBarItems: ['codex'],
taskResumeState: {
@@ -236,7 +243,7 @@ describe('client UI RPC methods', () => {
expect(runtime.updateUIState).toHaveBeenCalledWith({
...payload,
worktreeCardProperties: ['status', 'unread', 'branch', 'inline-agents']
worktreeCardProperties: ['status', 'unread', 'branch', 'automation', 'inline-agents']
})
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
})
@@ -31,6 +31,7 @@ const LegacyWorktreeCardProperty = z.enum([
'issue',
'linear-issue',
'pr',
'automation',
'comment',
'ports',
'inline-agents'
@@ -140,6 +141,7 @@ const SettingsUpdate = z
agentStatusHooksEnabled: z.boolean().optional(),
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
compactWorktreeCards: z.boolean().optional(),
githubProjects: GitHubProjectSettings.optional()
})
.strict()
@@ -169,6 +171,7 @@ const UiUpdate = z
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
hideDefaultBranchWorkspace: z.boolean().optional(),
hideAutomationGeneratedWorkspaces: z.boolean().optional(),
filterRepoIds: StringArray.optional(),
collapsedGroups: StringArray.optional(),
uiZoomLevel: z.number().finite().optional(),
+9
View File
@@ -13,6 +13,7 @@ import {
type TerminalStreamFrame
} from '../../../../shared/terminal-stream-protocol'
import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry'
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
// Why: when a mobile client subscribes the server resizes the PTY to phone
// dims and serializes the buffer. Sending only the visible screen meant
@@ -48,6 +49,7 @@ type SnapshotFrameOptions = {
truncated?: boolean
truncatedByByteBudget?: boolean
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
}
type SerializedSnapshot = {
@@ -56,6 +58,7 @@ type SerializedSnapshot = {
rows: number
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
scrollbackRows: number
truncatedByByteBudget: boolean
} | null
@@ -389,6 +392,7 @@ function sendSnapshotFrames(
reason: options.reason,
seq: options.seq,
source: options.source,
oscLinks: options.oscLinks,
truncated: options.truncated === true,
truncatedByByteBudget: options.truncatedByByteBudget === true
})
@@ -467,6 +471,7 @@ async function sendMobileResizeRestream(
reason: event.reason,
seq: event.seq ?? serialized.seq,
source: serialized.source,
oscLinks: serialized.oscLinks,
truncated: false,
truncatedByByteBudget: serialized.truncatedByByteBudget,
data: serialized.data
@@ -1348,6 +1353,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
displayMode,
seq: serialized?.seq,
source: serialized?.source,
oscLinks: serialized?.oscLinks,
truncated: false,
truncatedByByteBudget: serialized?.truncatedByByteBudget,
data: serialized?.data ?? ''
@@ -1544,6 +1550,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
oscLinks: serialized?.oscLinks,
data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '')
})
// Why: baseline for resize re-stream gating; the client already
@@ -1710,6 +1717,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
lines: read.tail,
truncated: isTerminalReadPayloadIncomplete(read),
serialized: serialized?.data,
oscLinks: serialized?.oscLinks,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
@@ -1931,6 +1939,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
seq,
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
truncatedByByteBudget: serialized?.truncatedByByteBudget,
oscLinks: serialized?.oscLinks,
data: serialized?.data ?? ''
})
console.log('[mobile-terminal-stream] snapshot', {
@@ -20,6 +20,13 @@ const OptionalTuiAgent = z
.transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
.optional()
const AutomationWorkspaceProvenanceRequest = z.object({
automationId: z.string(),
automationRunId: z.string(),
dispatchToken: z.string(),
createRequestId: z.string()
})
export const WorktreeListParams = z.object({
repo: OptionalString,
limit: OptionalFiniteNumber
@@ -129,19 +136,20 @@ export const WorktreeCreate = z
createdWithAgent: z
.unknown()
.transform((value) => (isTuiAgent(value) ? value : undefined))
.optional()
.optional(),
automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional()
})
.superRefine((params, ctx) => {
if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either a parent workspace flag or --no-parent, not both.'
message: 'Choose either one parent selector or --no-parent.'
})
}
if (params.parentWorkspace && params.parentWorktree) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-workspace or --parent-worktree, not both.'
message: 'Choose either one parent selector or --no-parent.'
})
}
if (params.startupPrompt !== undefined && params.startupAgent === undefined) {
+335 -3
View File
@@ -3,6 +3,17 @@ import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { WORKTREE_METHODS } from './worktree'
import { createAutomationDispatchToken } from '../../../automations/dispatch-tokens'
const repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 1,
kind: 'git' as const,
executionHostId: 'ssh:ssh-target-1' as const
}
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
@@ -12,6 +23,7 @@ describe('worktree RPC methods', () => {
it('routes create options to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
@@ -22,6 +34,7 @@ describe('worktree RPC methods', () => {
name: 'feature',
branchNameOverride: 'feature/something',
baseBranch: 'origin/main',
compareBaseRef: undefined,
setupDecision: 'skip',
displayName: 'Feature title',
telemetrySource: 'sidebar',
@@ -49,6 +62,9 @@ describe('worktree RPC methods', () => {
linkedLinearIssueOrganizationUrlKey: undefined,
linkedGitLabIssue: 789,
linkedGitLabMR: 321,
linkedBitbucketPR: undefined,
linkedAzureDevOpsPR: undefined,
linkedGiteaPR: undefined,
comment: undefined,
displayName: 'Feature title',
telemetrySource: 'sidebar',
@@ -60,6 +76,7 @@ describe('worktree RPC methods', () => {
activate: false,
setupDecision: 'skip',
createdWithAgent: undefined,
automationProvenance: undefined,
startup: undefined,
startupDraft: undefined,
lineage: {
@@ -71,9 +88,324 @@ describe('worktree RPC methods', () => {
})
})
it('mints automation provenance from a valid dispatch request on worktree creation', async () => {
const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1')
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
showAutomation: vi.fn(() => ({
id: 'automation-1',
name: 'Nightly review',
projectId: 'legacy-repo-1',
runContext: {
projectId: 'project-1',
repoId: 'repo-1',
hostId: 'ssh:ssh-target-1'
},
workspaceMode: 'new_per_run',
executionTargetType: 'ssh',
executionTargetId: 'ssh-target-1'
})),
listAutomationRuns: vi.fn(() => [
{
id: 'run-1',
automationId: 'automation-1',
title: 'Nightly review run',
status: 'dispatching',
workspaceId: null
}
]),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'automation-workspace',
automationProvenanceRequest: {
automationId: 'automation-1',
automationRunId: 'run-1',
dispatchToken,
createRequestId: 'create-request-1'
}
})
)
expect(response).toMatchObject({ ok: true })
const replay = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'automation-workspace-replay',
automationProvenanceRequest: {
automationId: 'automation-1',
automationRunId: 'run-1',
dispatchToken,
createRequestId: 'create-request-1'
}
})
)
expect(replay).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
repoSelector: 'repo-1',
name: 'automation-workspace',
automationProvenance: expect.objectContaining({
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
executionTargetType: 'ssh',
executionTargetId: 'ssh-target-1',
projectId: 'project-1',
repoId: 'repo-1',
hostId: 'ssh:ssh-target-1'
})
})
)
})
it('stamps automation provenance with the persisted runtime host from run context', async () => {
const dispatchToken = createAutomationDispatchToken('automation-runtime', 'run-runtime')
const runtimeLocalRepo = {
id: 'repo-runtime',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 1,
kind: 'git' as const
}
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(runtimeLocalRepo),
showAutomation: vi.fn(() => ({
id: 'automation-runtime',
name: 'Runtime review',
projectId: 'legacy-repo-runtime',
runContext: {
projectId: 'project-runtime',
repoId: 'repo-runtime',
hostId: 'runtime:owner-runtime'
},
workspaceMode: 'new_per_run',
executionTargetType: 'runtime',
executionTargetId: 'owner-runtime'
})),
listAutomationRuns: vi.fn(() => [
{
id: 'run-runtime',
automationId: 'automation-runtime',
title: 'Runtime review run',
status: 'dispatching',
workspaceId: null
}
]),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-runtime' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-runtime',
name: 'runtime-automation-workspace',
automationProvenanceRequest: {
automationId: 'automation-runtime',
automationRunId: 'run-runtime',
dispatchToken,
createRequestId: 'create-request-runtime'
}
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
automationProvenance: expect.objectContaining({
automationId: 'automation-runtime',
automationRunId: 'run-runtime',
repoId: 'repo-runtime',
hostId: 'runtime:owner-runtime'
})
})
)
})
it('validates and stamps automation provenance from the dispatching run snapshot', async () => {
const dispatchToken = createAutomationDispatchToken('automation-edited', 'run-edited')
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
showAutomation: vi.fn(() => ({
id: 'automation-edited',
name: 'Edited review',
projectId: 'legacy-repo-edited',
runContext: {
projectId: 'project-after-edit',
repoId: 'repo-after-edit',
hostId: 'runtime:after-edit'
},
workspaceMode: 'new_per_run',
executionTargetType: 'runtime',
executionTargetId: 'after-edit'
})),
listAutomationRuns: vi.fn(() => [
{
id: 'run-edited',
automationId: 'automation-edited',
title: 'Pre-edit run',
runContext: {
projectId: 'project-before-edit',
repoId: 'repo-1',
hostId: 'runtime:before-edit'
},
status: 'dispatching',
workspaceId: null
}
]),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-edited' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'edited-automation-workspace',
automationProvenanceRequest: {
automationId: 'automation-edited',
automationRunId: 'run-edited',
dispatchToken,
createRequestId: 'create-request-edited'
}
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
automationProvenance: expect.objectContaining({
automationId: 'automation-edited',
automationRunId: 'run-edited',
projectId: 'project-before-edit',
repoId: 'repo-1',
hostId: 'runtime:before-edit'
})
})
)
})
it('allows the same automation provenance request to retry after a failed create attempt', async () => {
const dispatchToken = createAutomationDispatchToken('automation-retry', 'run-retry')
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
showAutomation: vi.fn(() => ({
id: 'automation-retry',
name: 'Nightly retry',
projectId: 'repo-1',
workspaceMode: 'new_per_run',
executionTargetType: 'ssh',
executionTargetId: 'ssh-target-1'
})),
listAutomationRuns: vi.fn(() => [
{
id: 'run-retry',
automationId: 'automation-retry',
title: 'Nightly retry run',
status: 'dispatching',
workspaceId: null
}
]),
createManagedWorktree: vi
.fn()
.mockRejectedValueOnce(new Error('Branch "automation-workspace" already exists.'))
.mockResolvedValueOnce({ worktree: { id: 'wt-retry' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const automationProvenanceRequest = {
automationId: 'automation-retry',
automationRunId: 'run-retry',
dispatchToken,
createRequestId: 'create-request-retry'
}
const firstResponse = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'automation-workspace',
automationProvenanceRequest
})
)
const retryResponse = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'automation-workspace-2',
automationProvenanceRequest
})
)
expect(firstResponse).toMatchObject({ ok: false })
expect(retryResponse).toMatchObject({ ok: true })
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(2)
expect(runtime.createManagedWorktree).toHaveBeenLastCalledWith(
expect.objectContaining({
name: 'automation-workspace-2',
automationProvenance: expect.objectContaining({
automationId: 'automation-retry',
automationRunId: 'run-retry'
})
})
)
})
it('rejects forged automation provenance requests on worktree creation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
showAutomation: vi.fn(() => ({
id: 'automation-1',
name: 'Nightly review',
projectId: 'repo-1',
workspaceMode: 'new_per_run',
executionTargetType: 'local',
executionTargetId: 'local'
})),
listAutomationRuns: vi.fn(() => [
{
id: 'run-1',
automationId: 'automation-1',
title: 'Nightly review run',
status: 'dispatching',
workspaceId: null
}
]),
createManagedWorktree: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'manual-workspace',
automationProvenanceRequest: {
automationId: 'automation-1',
automationRunId: 'run-1',
dispatchToken: 'forged-token',
createRequestId: 'create-request-forged'
}
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
})
it('forwards startup command and env to runtime worktree creation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
@@ -106,6 +438,7 @@ describe('worktree RPC methods', () => {
it('forwards task startup drafts to runtime worktree creation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
@@ -156,6 +489,7 @@ describe('worktree RPC methods', () => {
it('maps unknown telemetry sources to the runtime default instead of rejecting create', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue(repo),
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
@@ -195,9 +529,7 @@ describe('worktree RPC methods', () => {
)
expect(response).toMatchObject({ ok: false })
expect(JSON.stringify(response)).toContain(
'Choose either a parent workspace flag or --no-parent'
)
expect(JSON.stringify(response)).toContain('Choose either one parent selector or --no-parent')
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
})
+71 -48
View File
@@ -1,3 +1,8 @@
import {
finishAutomationWorkspaceProvenanceRequest,
releaseAutomationWorkspaceProvenanceRequest,
resolveAutomationWorkspaceProvenance
} from '../../../automations/workspace-provenance'
import { defineMethod, type RpcMethod } from '../core'
import {
WorktreeCreate,
@@ -58,56 +63,74 @@ export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
name: 'worktree.create',
params: WorktreeCreate,
handler: async (params, { runtime }) =>
runtime.createManagedWorktree({
handler: async (params, { runtime }) => {
const repo = await runtime.showRepo(params.repo)
const automationProvenance = resolveAutomationWorkspaceProvenance({
authority: runtime,
repoSelector: params.repo,
name: params.name ?? '',
baseBranch: params.baseBranch,
compareBaseRef: params.compareBaseRef,
branchNameOverride: params.branchNameOverride,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
comment: params.comment,
displayName: params.displayName,
telemetrySource: params.telemetrySource,
workspaceStatus: params.workspaceStatus,
manualOrder: params.manualOrder,
sparseCheckout: params.sparseCheckout,
pushTarget: params.pushTarget,
runHooks: params.runHooks === true,
activate: params.activate === true,
setupDecision: params.setupDecision,
createdWithAgent: params.createdWithAgent ?? params.startupAgent,
startup: params.startupCommand
? {
command: params.startupCommand,
...(params.startupEnv ? { env: params.startupEnv } : {}),
...(params.startupCommandDelivery
? { startupCommandDelivery: params.startupCommandDelivery }
: {})
}
: undefined,
...(params.startupAgent ? { startupAgent: params.startupAgent } : {}),
...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}),
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
noParent: params.noParent === true,
callerTerminalHandle: params.callerTerminalHandle,
orchestrationContext: params.orchestrationContext
}
repo,
request: params.automationProvenanceRequest
})
// Why: provenance tokens are reserved before creation so retries can recover,
// but failed create attempts must release the reservation for a safe retry.
try {
const result = await runtime.createManagedWorktree({
repoSelector: params.repo,
name: params.name ?? '',
baseBranch: params.baseBranch,
compareBaseRef: params.compareBaseRef,
branchNameOverride: params.branchNameOverride,
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
linkedBitbucketPR: params.linkedBitbucketPR,
linkedAzureDevOpsPR: params.linkedAzureDevOpsPR,
linkedGiteaPR: params.linkedGiteaPR,
comment: params.comment,
displayName: params.displayName,
telemetrySource: params.telemetrySource,
workspaceStatus: params.workspaceStatus,
manualOrder: params.manualOrder,
sparseCheckout: params.sparseCheckout,
pushTarget: params.pushTarget,
runHooks: params.runHooks === true,
activate: params.activate === true,
setupDecision: params.setupDecision,
createdWithAgent: params.createdWithAgent ?? params.startupAgent,
automationProvenance,
startup: params.startupCommand
? {
command: params.startupCommand,
...(params.startupEnv ? { env: params.startupEnv } : {}),
...(params.startupCommandDelivery
? { startupCommandDelivery: params.startupCommandDelivery }
: {})
}
: undefined,
...(params.startupAgent ? { startupAgent: params.startupAgent } : {}),
...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}),
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
noParent: params.noParent === true,
callerTerminalHandle: params.callerTerminalHandle,
orchestrationContext: params.orchestrationContext
}
})
finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
return result
} catch (error) {
releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
throw error
}
}
}),
defineMethod({
name: 'worktree.prefetchCreateBase',
@@ -480,13 +480,23 @@ describe('terminal subscribe buffering', () => {
seq: number
}) => void)
| undefined
const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = []
const restreamResolves: ((value: {
data: string
cols: number
rows: number
oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[]
}) => void)[] = []
const serializeTerminalBuffer = vi
.fn()
.mockResolvedValueOnce({ data: 'initial', cols: 80, rows: 24 })
.mockImplementation(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
new Promise<{
data: string
cols: number
rows: number
oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[]
}>((resolve) => {
restreamResolves.push(resolve)
})
)
@@ -540,7 +550,8 @@ describe('terminal subscribe buffering', () => {
resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 })
await vi.waitFor(() => expect(restreamResolves).toHaveLength(2))
restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24 })
const newerOscLinks = [{ row: 0, startCol: 4, endCol: 9, uri: 'https://example.com' }]
restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24, oscLinks: newerOscLinks })
await vi.waitFor(() =>
expect(
binaryFrames.some((frame) => {
@@ -561,6 +572,13 @@ describe('terminal subscribe buffering', () => {
.filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
expect(snapshotData).toEqual(['newer'])
const snapshotStart = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
kind: 'resized',
oscLinks: newerOscLinks
})
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
+12 -1
View File
@@ -93,7 +93,18 @@ describe('stripOrcaProvenanceMetaUpdates', () => {
comment: 'keep me',
orcaCreatedAt: 123,
orcaCreationSource: 'desktop',
orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false }
orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false },
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1'
}
})
).toEqual({ comment: 'keep me' })
})
+2 -1
View File
@@ -22,7 +22,8 @@ const ORCA_CREATION_SOURCES = new Set<NonNullable<WorktreeMeta['orcaCreationSour
const ORCA_OWNED_PROVENANCE_META_KEYS = [
'orcaCreatedAt',
'orcaCreationSource',
'orcaCreationWorkspaceLayout'
'orcaCreationWorkspaceLayout',
'automationProvenance'
] as const
type UnregisteredOrcaCleanupMeta = Pick<
WorktreeMeta,
+3
View File
@@ -574,6 +574,7 @@ function App(): React.JSX.Element {
const projectOrderBy = useAppStore((s) => s.projectOrderBy)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
@@ -1256,6 +1257,7 @@ function App(): React.JSX.Element {
hideSleepingWorkspaces: !showSleepingWorkspaces,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
// Why: rides the same debounced save so dashboard auto-acks (which fire
@@ -1281,6 +1283,7 @@ function App(): React.JSX.Element {
projectOrderBy,
showSleepingWorkspaces,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
acknowledgedAgentsByPaneKey
@@ -17,7 +17,10 @@ import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/gith
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { sortWorktreesSmart } from '@/components/sidebar/smart-sort'
import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees'
import {
isAutomationGeneratedWorkspace,
isDefaultBranchWorkspace
} from '@/components/sidebar/visible-worktrees'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees'
import StatusIndicator from '@/components/sidebar/StatusIndicator'
@@ -321,6 +324,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
@@ -393,6 +397,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) {
return false
}
if (hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(worktree)) {
return false
}
if (
!showSleepingWorkspaces &&
isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree)
@@ -404,6 +411,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[
allWorktrees,
browserTabsByWorktree,
hideAutomationGeneratedWorkspaces,
hideDefaultBranchWorkspace,
ptyIdsByTabId,
showSleepingWorkspaces,
@@ -116,7 +116,10 @@ import {
import {
createAutomationForTarget,
deleteAutomationForTarget,
type AutomationHostTarget,
getAutomationListTarget,
getAutomationOwnerTarget,
getAutomationTargetFromHostId,
listAutomationRunsForTarget,
listAutomationsForTarget,
runAutomationNowForTarget,
@@ -153,6 +156,10 @@ type SelectedExternalRunPage = {
run: ExternalAutomationRun
}
function getAutomationHostTargetKey(target: AutomationHostTarget): string {
return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local'
}
function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
}
@@ -353,6 +360,8 @@ export default function AutomationsPage(): React.JSX.Element {
)
const selectedId = useAppStore((s) => s.selectedAutomationId)
const setSelectedId = useAppStore((s) => s.setSelectedAutomationId)
const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation)
const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation)
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents)
@@ -365,6 +374,7 @@ export default function AutomationsPage(): React.JSX.Element {
const [automations, setAutomations] = useState<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
const [automationHostTargetKey, setAutomationHostTargetKey] = useState<string | null>(null)
const [selectedAutomationRuns, setSelectedAutomationRuns] = useState<{
automationId: string | null
runs: AutomationRun[]
@@ -478,7 +488,9 @@ export default function AutomationsPage(): React.JSX.Element {
(automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null)
const selected =
selectedExternal === null
? (automations.find((automation) => automation.id === selectedId) ?? automations[0] ?? null)
? selectedId
? (automations.find((automation) => automation.id === selectedId) ?? null)
: (automations[0] ?? null)
: null
const runsWithWorkspaceNames = useMemo(
() =>
@@ -515,9 +527,10 @@ export default function AutomationsPage(): React.JSX.Element {
selected && selectedAutomationRuns.automationId === selected.id
? selectedAutomationRunsWithWorkspaceNames
: runsWithWorkspaceNames
const selectedRuns = selected
? selectedRunsSource.filter((run) => run.automationId === selected.id)
: []
const selectedRuns = useMemo(
() => (selected ? selectedRunsSource.filter((run) => run.automationId === selected.id) : []),
[selected, selectedRunsSource]
)
const selectedAutomationRunPage = selectedAutomationRunPageId
? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null)
: null
@@ -534,6 +547,72 @@ export default function AutomationsPage(): React.JSX.Element {
}
}
}, [worktreeMap])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
return
}
const pending = pendingAutomationRunNavigation
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pending.hostId)
)
if (automationHostTargetKey !== pendingTargetKey) {
return
}
const pendingAutomation = automations.find(
(automation) => automation.id === pending.automationId
)
if (!pendingAutomation) {
// Why: stale provenance should not silently select the first automation.
setSelectedId(pending.automationId)
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
toast.message(
translate(
'auto.components.automations.AutomationsPage.pendingAutomationMissing',
'Automation no longer available.'
)
)
return
}
if (selectedId !== pending.automationId) {
setSelectedId(pending.automationId)
return
}
if (!pending.runId) {
setActivePaneTab('overview')
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
return
}
if (selectedAutomationRuns.automationId !== pending.automationId) {
return
}
setActivePaneTab('runs')
const pendingRun = selectedRuns.find((run) => run.id === pending.runId)
if (pendingRun) {
setSelectedAutomationRunPageId(pending.runId)
setPendingAutomationRunNavigation(null)
return
}
setSelectedAutomationRunPageId(null)
setPendingAutomationRunNavigation(null)
toast.message(
translate(
'auto.components.automations.AutomationsPage.pendingAutomationRunMissing',
'Run history no longer available.'
)
)
}, [
automations,
automationHostTargetKey,
isLoading,
pendingAutomationRunNavigation,
selectedAutomationRuns.automationId,
selectedId,
selectedRuns,
setPendingAutomationRunNavigation,
setSelectedId
])
const activeTerminalTabIds = useMemo(() => {
const ids = new Set<string>()
for (const tabs of Object.values(unifiedTabsByWorktree)) {
@@ -790,7 +869,10 @@ export default function AutomationsPage(): React.JSX.Element {
const refresh = useCallback(async () => {
setIsLoading(true)
const automationHostTarget = getAutomationListTarget(settings)
const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation
const automationHostTarget = pendingNavigation
? getAutomationTargetFromHostId(pendingNavigation.hostId)
: getAutomationListTarget(settings)
try {
const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([
listAutomationsForTarget(automationHostTarget),
@@ -801,20 +883,26 @@ export default function AutomationsPage(): React.JSX.Element {
const hasCurrentSelection = nextAutomations.some(
(automation) => automation.id === currentSelectedId
)
const nextSelectedId = hasCurrentSelection
? currentSelectedId
: (nextAutomations[0]?.id ?? null)
let nextSelectedId: string | null
if (hasCurrentSelection) {
nextSelectedId = currentSelectedId
} else if (pendingNavigation) {
nextSelectedId = pendingNavigation.automationId
} else {
nextSelectedId = nextAutomations[0]?.id ?? null
}
const nextSelectedRuns = nextSelectedId
? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId)
: []
setAutomations(nextAutomations)
setRuns(nextRuns)
setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget))
setSelectedAutomationRuns({
automationId: nextSelectedId,
runs: nextSelectedRuns
})
setExternalManagers(nextExternalManagers)
if (!hasCurrentSelection) {
if (!hasCurrentSelection && !pendingNavigation) {
selectAutomationId(nextAutomations[0]?.id ?? null)
}
} finally {
@@ -822,6 +910,18 @@ export default function AutomationsPage(): React.JSX.Element {
}
}, [selectAutomationId, settings])
useEffect(() => {
if (!pendingAutomationRunNavigation || isLoading) {
return
}
const pendingTargetKey = getAutomationHostTargetKey(
getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId)
)
if (automationHostTargetKey !== pendingTargetKey) {
void refresh()
}
}, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh])
const hydratePersistedUIState = useCallback(async (): Promise<void> => {
useAppStore.getState().hydratePersistedUI(await window.api.ui.get())
}, [])
@@ -843,17 +943,22 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
let cancelled = false
void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then(
(nextRuns) => {
if (!cancelled) {
setSelectedAutomationRuns({ automationId, runs: nextRuns })
}
const target =
pendingAutomationRunNavigation?.automationId === automationId &&
pendingAutomationRunNavigation.hostId
? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId)
: selected
? getAutomationOwnerTarget(selected)
: getAutomationListTarget(settings)
void listAutomationRunsForTarget(target, automationId).then((nextRuns) => {
if (!cancelled) {
setSelectedAutomationRuns({ automationId, runs: nextRuns })
}
)
})
return () => {
cancelled = true
}
}, [selected?.id, runs, settings])
}, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings])
useEffect(() => {
const onAutomationsChanged = (): void => {
@@ -22,9 +22,13 @@ type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'w
workspace?: string
}
type AutomationHostTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
export type AutomationHostTarget =
| { kind: 'local' }
| { kind: 'environment'; environmentId: string }
function getRuntimeTargetFromHostId(hostId: string | null | undefined): AutomationHostTarget {
export function getAutomationTargetFromHostId(
hostId: string | null | undefined
): AutomationHostTarget {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'runtime'
? { kind: 'environment', environmentId: parsed.environmentId }
@@ -41,11 +45,11 @@ export function getAutomationListTarget(
export function getAutomationOwnerTarget(
automation: Pick<Automation, 'runContext'>
): AutomationHostTarget {
return getRuntimeTargetFromHostId(automation.runContext?.hostId)
return getAutomationTargetFromHostId(automation.runContext?.hostId)
}
export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget {
return getRuntimeTargetFromHostId(input.runContext?.hostId)
return getAutomationTargetFromHostId(input.runContext?.hostId)
}
function toRuntimeAutomationCreateInput(
@@ -46,7 +46,12 @@ vi.mock('@/store', () => ({
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
values ? fallback.replace('{{value0}}', String(values.value0)) : fallback
values
? Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{{${key}}}`, String(value)),
fallback
)
: fallback
}))
vi.mock('@/lib/http-link-routing', () => ({
@@ -150,7 +155,7 @@ function makeWorktree(): Worktree {
}
}
function makeReview(): HostedReviewInfo {
function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 12,
@@ -160,16 +165,18 @@ function makeReview(): HostedReviewInfo {
status: 'success',
updatedAt: '2026-01-01T00:00:00.000Z',
mergeable: 'MERGEABLE',
headSha: 'abc'
headSha: 'abc',
...overrides
}
}
function makeCheck(): PRCheckDetail {
function makeCheck(overrides: Partial<PRCheckDetail> = {}): PRCheckDetail {
return {
name: 'verify',
status: 'completed',
conclusion: 'success',
url: 'https://example.test/check/verify'
url: 'https://example.test/check/verify',
...overrides
}
}
@@ -256,6 +263,114 @@ describe('FolderWorkspacePrChecksPanel', () => {
expect(mockState.store.setRightSidebarTab).not.toHaveBeenCalled()
})
it('shows a compact clean-state header summary without noisy aggregate counts', () => {
renderPanel()
expect(container.textContent).toContain('Review checks')
expect(container.textContent).toContain('1 worktree · all checks passing')
expect(container.textContent).not.toContain('1 attached')
expect(container.textContent).not.toContain('with PR/MR')
expect(container.textContent).not.toContain('unknown')
})
it('summarizes failing and pending rows before the worktree count', () => {
const repo = mockState.store.repos[0]
const passingWorktree = mockState.store.worktreesByRepo[repo.id][0]
const failingWorktree = {
...makeWorktree(),
id: 'repo-1::/failing-child',
path: '/failing-child',
head: 'def',
branch: 'refs/heads/failing-child',
displayName: 'Failing child',
linkedPR: 13,
lastActivityAt: 2
}
const pendingWorktree = {
...makeWorktree(),
id: 'repo-1::/pending-child',
path: '/pending-child',
head: 'fed',
branch: 'refs/heads/pending-child',
displayName: 'Pending child',
linkedPR: 14,
lastActivityAt: 1
}
const reviewsByBranch = {
feature: makeReview(),
'failing-child': makeReview({
number: 13,
title: 'Failing review',
status: 'failure',
headSha: 'def'
}),
'pending-child': makeReview({
number: 14,
title: 'Pending review',
status: 'pending',
headSha: 'fed'
})
}
const worktrees = [passingWorktree, failingWorktree, pendingWorktree]
mockState.store.worktreesByRepo = { [repo.id]: worktrees }
mockState.store.workspaceLineageByChildKey = Object.fromEntries(
worktrees.map((worktree, index) => [
worktreeWorkspaceKey(worktree.id),
{
childWorkspaceKey: worktreeWorkspaceKey(worktree.id),
childInstanceId: null,
parentWorkspaceKey: folderWorkspaceKey('folder-1'),
parentInstanceId: null,
origin: 'cli',
capture: { source: 'env-workspace', confidence: 'inferred' },
createdAt: index + 1
}
])
)
mockState.store.hostedReviewCache = {
[getHostedReviewCacheKey(repo.path, 'feature', null, repo.id)]: {
data: reviewsByBranch.feature,
fetchedAt: 1
},
[getHostedReviewCacheKey(repo.path, 'failing-child', null, repo.id)]: {
data: reviewsByBranch['failing-child'],
fetchedAt: 1
},
[getHostedReviewCacheKey(repo.path, 'pending-child', null, repo.id)]: {
data: reviewsByBranch['pending-child'],
fetchedAt: 1
}
}
mockState.store.checksCache = {
[getGitHubRepoCacheKey(repo.path, repo.id, prChecksCacheSuffix(12, null, 'abc'), null)]: {
data: [makeCheck()],
fetchedAt: 1,
headSha: 'abc'
},
[getGitHubRepoCacheKey(repo.path, repo.id, prChecksCacheSuffix(13, null, 'def'), null)]: {
data: [makeCheck({ name: 'unit-tests', conclusion: 'failure' })],
fetchedAt: 1,
headSha: 'def'
},
[getGitHubRepoCacheKey(repo.path, repo.id, prChecksCacheSuffix(14, null, 'fed'), null)]: {
data: [makeCheck({ name: 'integration', status: 'in_progress', conclusion: null })],
fetchedAt: 1,
headSha: 'fed'
}
}
mockState.store.fetchHostedReviewForBranch = vi.fn(
async (_repoPath: string, branch: keyof typeof reviewsByBranch) =>
reviewsByBranch[branch] ?? null
)
renderPanel()
expect(container.textContent).toContain('Review checks')
expect(container.textContent).toContain('1 failing · 1 pending · 3 worktrees')
expect(container.textContent).not.toContain('with PR/MR')
expect(container.textContent).not.toContain('unknown')
})
it('opens external review links without activating the row', () => {
renderPanel()
@@ -79,6 +79,10 @@ export default function FolderWorkspacePrChecksPanel({
[childWorktrees, repos, settings, hostedReviewCache, prCache, checksCache, refreshOutcomes]
)
const folderWorkspaceId = folderWorkspace?.id ?? null
const headerSummary = useMemo(
() => formatReviewChecksHeaderSummary(projection.summary),
[projection.summary]
)
const refreshCandidates = useMemo(
() => getParentPrChecksRefreshCandidates({ worktrees: childWorktrees, repos }),
[childWorktrees, repos]
@@ -210,11 +214,14 @@ export default function FolderWorkspacePrChecksPanel({
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{folderWorkspace.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSummary(projection.summary)}
{translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.reviewChecks',
'Review checks'
)}
</div>
{headerSummary ? (
<div className="mt-1 truncate text-xs text-muted-foreground">{headerSummary}</div>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -276,26 +283,65 @@ export default function FolderWorkspacePrChecksPanel({
)
}
function formatSummary(summary: {
function formatReviewChecksHeaderSummary(summary: {
attached: number
knownReview: number
failing: number
pending: number
passing: number
noPr: number
unknown: number
}): string {
return translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.summary',
'{{value0}} attached · {{value1}} with PR/MR · {{value2}} attention · {{value3}} pending · {{value4}} passing · {{value5}} no PR · {{value6}} unknown',
{
value0: summary.attached,
value1: summary.knownReview,
value2: summary.failing,
value3: summary.pending,
value4: summary.passing,
value5: summary.noPr,
value6: summary.unknown
}
)
}): string | null {
if (summary.attached === 0) {
return null
}
const worktreeCount = formatWorktreeCount(summary.attached)
const attentionParts = [
summary.failing > 0 ? formatFailingCount(summary.failing) : null,
summary.pending > 0 ? formatPendingCount(summary.pending) : null
].filter((part): part is string => part !== null)
if (attentionParts.length > 0) {
return [...attentionParts, worktreeCount].join(' · ')
}
if (summary.passing === summary.attached) {
return [
worktreeCount,
translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.allChecksPassing',
'all checks passing'
)
].join(' · ')
}
return worktreeCount
}
function formatWorktreeCount(count: number): string {
return count === 1
? translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.oneWorktree',
'1 worktree'
)
: translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.worktreeCount',
'{{value0}} worktrees',
{ value0: count }
)
}
function formatFailingCount(count: number): string {
return count === 1
? translate('auto.components.rightSidebar.FolderWorkspacePrChecksPanel.oneFailing', '1 failing')
: translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.failingCount',
'{{value0}} failing',
{ value0: count }
)
}
function formatPendingCount(count: number): string {
return count === 1
? translate('auto.components.rightSidebar.FolderWorkspacePrChecksPanel.onePending', '1 pending')
: translate(
'auto.components.rightSidebar.FolderWorkspacePrChecksPanel.pendingCount',
'{{value0}} pending',
{ value0: count }
)
}
@@ -0,0 +1,226 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CheckStatus, Repo, Worktree } from '../../../../shared/types'
import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-rows'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
values ? fallback.replace('{{value0}}', String(values.value0)) : fallback
}))
vi.mock('@/lib/http-link-routing', () => ({
openHttpLink: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('./checks-panel-content', () => ({
CHECK_COLOR: {
success: 'success-color',
failure: 'failure-color',
pending: 'pending-color',
neutral: 'neutral-color'
},
CHECK_ICON: {
success: (props: { className?: string }) => <span data-icon="success" {...props} />,
failure: (props: { className?: string }) => <span data-icon="failure" {...props} />,
pending: (props: { className?: string }) => <span data-icon="pending" {...props} />,
neutral: (props: { className?: string }) => <span data-icon="neutral" {...props} />
},
ChecksList: () => <div data-testid="checks-list" />,
PullRequestIcon: (props: { className?: string }) => <span data-icon="review" {...props} />,
prStateColor: () => 'state-color'
}))
import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow'
let container: HTMLDivElement
let root: Root
function makeWorktree(): Worktree {
return {
id: 'repo-1::/child',
path: '/child',
head: 'abc',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
repoId: 'repo-1',
displayName: 'Enable child worktrees',
comment: '',
linkedIssue: null,
linkedPR: 12,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#fff',
addedAt: 1,
kind: 'git'
}
}
function makeRow(
overrides: Partial<ParentPrChecksRow> & {
status?: ParentPrChecksRowStatus
checkTone?: CheckStatus
} = {}
): ParentPrChecksRow {
return {
id: 'row-1',
refreshIdentity: 'row-1::feature',
worktree: makeWorktree(),
repo: makeRepo(),
branch: 'feature',
status: 'success',
group: 'passing',
checkTone: 'success',
title: 'Review title',
reviewLabel: '#12',
reviewUrl: 'https://example.test/pr/12',
reviewState: 'open',
provider: 'github',
summary: 'Checks passing',
detailNames: [],
checks: [],
isRefreshing: false,
hasLinkedReview: true,
...overrides
}
}
function renderRow(row: ParentPrChecksRow): void {
act(() => {
root.render(
<FolderWorkspacePrChecksRow
row={row}
expanded={false}
onToggle={vi.fn()}
onLoadCheckDetails={vi.fn(async () => null)}
/>
)
})
}
function iconNames(): string[] {
return [...container.querySelectorAll<HTMLElement>('[data-icon]')].map(
(icon) => icon.dataset.icon ?? ''
)
}
describe('FolderWorkspacePrChecksRow', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it.each([
{
status: 'success' as const,
checkTone: 'success' as const,
icon: 'success',
color: 'success-color',
summary: 'Checks passing'
},
{
status: 'failing' as const,
checkTone: 'failure' as const,
icon: 'failure',
color: 'failure-color',
summary: 'Checks failing'
}
])('shows $icon status as summary metadata after the review identity', (state) => {
renderRow(
makeRow({
status: state.status,
checkTone: state.checkTone,
summary: state.summary
})
)
expect(iconNames()).toEqual(['review', state.icon])
expect(container.textContent).toContain('Enable child worktrees')
expect(container.textContent).toContain(state.summary)
expect(container.querySelector(`[data-icon="${state.icon}"]`)?.className).toContain(state.color)
expect(container.querySelector(`[data-icon="${state.icon}"]`)?.className).not.toContain(
'animate-spin'
)
})
it('spins pending summary status like regular pending check rows', () => {
renderRow(
makeRow({
status: 'pending',
group: 'pending',
checkTone: 'pending',
summary: 'Checks pending'
})
)
expect(iconNames()).toEqual(['review', 'pending'])
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('pending-color')
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('animate-spin')
})
it('spins loading summary status because it uses the pending check icon', () => {
renderRow(
makeRow({
status: 'loading',
group: 'unavailable',
checkTone: 'pending',
reviewLabel: null,
reviewUrl: null,
reviewState: null,
provider: null,
summary: 'Checking review status...'
})
)
expect(iconNames()).toEqual(['review', 'pending'])
expect(container.querySelector('[data-icon="pending"]')?.className).toContain('animate-spin')
})
it('does not render a dashed neutral status glyph for rows without a known check state', () => {
renderRow(
makeRow({
status: 'noReview',
group: 'noPr',
checkTone: 'neutral',
reviewLabel: null,
reviewUrl: null,
reviewState: null,
provider: null,
summary: 'No PR linked'
})
)
expect(iconNames()).toEqual(['review'])
expect(container.querySelector('[data-icon="neutral"]')).toBeNull()
expect(container.textContent).toContain('No PR linked')
})
})
@@ -1,4 +1,4 @@
import { ChevronRight, ExternalLink } from 'lucide-react'
import { ChevronRight, ExternalLink, GitMerge } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { openHttpLink } from '@/lib/http-link-routing'
@@ -26,7 +26,12 @@ export function FolderWorkspacePrChecksRow({
onToggle,
onLoadCheckDetails
}: FolderWorkspacePrChecksRowProps): React.JSX.Element {
const Icon = CHECK_ICON[row.checkTone] ?? CHECK_ICON.neutral
const ReviewIcon = row.provider === 'gitlab' ? GitMerge : PullRequestIcon
const StatusIcon = CHECK_ICON[row.checkTone] ?? CHECK_ICON.neutral
// Why: match the regular PR checks header; the review identity leads,
// while aggregate check state stays with the summary metadata.
const showStatusIcon = row.checkTone !== 'neutral'
const animateStatusIcon = row.checkTone === 'pending'
const reviewProviderLabel = row.provider === 'gitlab' ? 'MR' : 'PR'
const toggleDetailsLabel = expanded
? translate(
@@ -72,11 +77,20 @@ export function FolderWorkspacePrChecksRow({
expanded && 'rotate-90'
)}
/>
<Icon className={cn('mt-0.5 size-3.5 shrink-0', CHECK_COLOR[row.checkTone])} />
<ReviewIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<PrChecksRowHeader row={row} />
<div className="mt-1 truncate text-[12px] text-foreground/90">{row.title}</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{showStatusIcon ? (
<StatusIcon
className={cn(
'size-3 shrink-0',
CHECK_COLOR[row.checkTone],
animateStatusIcon && 'animate-spin'
)}
/>
) : null}
<span className="truncate">{row.summary}</span>
{row.repo ? <span className="shrink-0">· {row.repo.displayName}</span> : null}
{row.branch ? <span className="truncate">· {row.branch}</span> : null}
@@ -130,8 +144,7 @@ function PrChecksRowHeader({ row }: { row: ParentPrChecksRow }): React.JSX.Eleme
{row.worktree.displayName}
</span>
{row.reviewLabel ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<PullRequestIcon className="size-3" />
<span className="inline-flex shrink-0 items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{row.reviewLabel}
</span>
) : null}
@@ -1313,11 +1313,16 @@ function SourceControlInner(): React.JSX.Element {
)
: null
const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null
const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey
? activePrFromQueue
? { provider: 'github', ...activePrFromQueue, status: activePrFromQueue.checksStatus }
: (hostedReviewEntry?.data ?? null)
: null
const hostedReviewEntryData = hostedReviewEntry?.data ?? null
const hostedReview: HostedReviewInfo | null = useMemo(() => {
if (!hostedReviewCacheKey) {
return null
}
if (activePrFromQueue) {
return { provider: 'github', ...activePrFromQueue, status: activePrFromQueue.checksStatus }
}
return hostedReviewEntryData
}, [activePrFromQueue, hostedReviewCacheKey, hostedReviewEntryData])
const effectiveBaseRef = resolveSourceControlBaseRef({
worktreeBaseRef: normalizedWorktreeBaseRef,
reviewBaseRefName: hostedReview?.baseRefName,
@@ -4350,12 +4355,9 @@ function SourceControlInner(): React.JSX.Element {
if (shouldResetToLoading) {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, effectiveBaseRef)
} else {
useAppStore.setState((s) => ({
gitBranchCompareRequestKeyByWorktree: {
...s.gitBranchCompareRequestKeyByWorktree,
[activeWorktreeId]: requestKey
}
}))
beginGitBranchCompareRequest(activeWorktreeId, requestKey, effectiveBaseRef, {
preserveExistingSummary: true
})
}
try {
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ProjectHeaderActions } from './ProjectHeaderActions'
let root: Root | null = null
describe('ProjectHeaderActions', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
root = null
}
document.body.replaceChildren()
})
it('overlays hover-only controls instead of reserving project title width', () => {
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<ProjectHeaderActions />)
})
const actions = container.querySelector('[data-repo-header-actions]')
expect(actions?.className).toContain('can-hover:absolute')
expect(actions?.className).toContain('can-hover:pointer-events-none')
expect(actions?.className).toContain('group-hover:pointer-events-auto')
expect(actions?.className).toContain('has-[:focus-visible]:pointer-events-auto')
expect(actions?.className).toContain('has-[button[data-state=open]]:pointer-events-auto')
})
})
@@ -0,0 +1,27 @@
import React from 'react'
import { cn } from '@/lib/utils'
// Why: desktop hover actions should not permanently reserve project-title width;
// touch devices keep them in normal flow because there is no hover reveal.
export const PROJECT_HEADER_ACTIONS_CLASS_NAME = cn(
'flex shrink-0 items-center gap-0.5',
'can-hover:absolute can-hover:right-1 can-hover:top-1/2 can-hover:z-10 can-hover:-translate-y-1/2',
'can-hover:rounded-md can-hover:bg-worktree-sidebar can-hover:pl-1',
'can-hover:pointer-events-none can-hover:opacity-0 can-hover:transition-opacity',
'group-hover:pointer-events-auto group-hover:opacity-100',
'has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100',
'has-[button[data-state=open]]:pointer-events-auto has-[button[data-state=open]]:opacity-100'
)
export function ProjectHeaderActions({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
return (
<div
data-repo-header-actions=""
className={cn(PROJECT_HEADER_ACTIONS_CLASS_NAME, className)}
{...props}
/>
)
}
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server } from 'lucide-react'
import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server, Workflow } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import {
@@ -39,6 +39,10 @@ const SidebarFilter = React.memo(function SidebarFilter({
const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const setHideAutomationGeneratedWorkspaces = useAppStore(
(s) => s.setHideAutomationGeneratedWorkspaces
)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const repos = useAppStore((s) => s.repos)
@@ -85,9 +89,16 @@ const SidebarFilter = React.memo(function SidebarFilter({
const selectedCount = selectedRepoIdSet.size
const hasRepoFilter = selectedCount > 0
const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES
const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter
const hasAnyFilter =
hasSleepingFilter ||
hideDefaultBranchWorkspace ||
hideAutomationGeneratedWorkspaces ||
hasRepoFilter
const activeFilterCount =
(hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
(hasSleepingFilter ? 1 : 0) +
(hideDefaultBranchWorkspace ? 1 : 0) +
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
selectedCount
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
const commandValue =
@@ -99,8 +110,14 @@ const SidebarFilter = React.memo(function SidebarFilter({
const clearAll = useCallback(() => {
setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES)
setHideDefaultBranchWorkspace(false)
setHideAutomationGeneratedWorkspaces(false)
setFilterRepoIds([])
}, [setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setFilterRepoIds])
}, [
setShowSleepingWorkspaces,
setHideDefaultBranchWorkspace,
setHideAutomationGeneratedWorkspaces,
setFilterRepoIds
])
// Why: derive ids from the live repos list at click time so a repo added
// while the popover is open is included immediately.
@@ -176,6 +193,15 @@ const SidebarFilter = React.memo(function SidebarFilter({
checked={hideDefaultBranchWorkspace}
onChange={setHideDefaultBranchWorkspace}
/>
<FilterToggleRow
icon={<Workflow className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarFilter.automationCreated',
'Hide automation-created'
)}
checked={hideAutomationGeneratedWorkspaces}
onChange={setHideAutomationGeneratedWorkspaces}
/>
{canFilterRepos && (
<>
@@ -1,5 +1,5 @@
import React from 'react'
import { GitBranch, Moon } from 'lucide-react'
import { GitBranch, Moon, Workflow } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -9,6 +9,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const setHideAutomationGeneratedWorkspaces = useAppStore(
(s) => s.setHideAutomationGeneratedWorkspaces
)
return (
<>
@@ -35,6 +39,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
checked={hideDefaultBranchWorkspace}
onChange={setHideDefaultBranchWorkspace}
/>
<FilterToggleRow
icon={<Workflow className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated',
'Hide automation-created'
)}
checked={hideAutomationGeneratedWorkspaces}
onChange={setHideAutomationGeneratedWorkspaces}
/>
</>
)
})
@@ -37,6 +37,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
}: SidebarWorkspaceOptionsMenuProps) {
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const repos = useAppStore((s) => s.repos)
const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope)
@@ -76,10 +77,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES
const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null
const hasAnyFilter =
hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter
hasSleepingFilter ||
hideDefaultBranchWorkspace ||
hideAutomationGeneratedWorkspaces ||
hasRepoFilter ||
hasHostVisibilityFilter
const activeFilterCount =
(hasSleepingFilter ? 1 : 0) +
(hideDefaultBranchWorkspace ? 1 : 0) +
(hideAutomationGeneratedWorkspaces ? 1 : 0) +
(hasHostVisibilityFilter ? 1 : 0) +
selectedCount
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/worktree-card-properties'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
const fetchHostedReviewForBranch = vi.fn()
@@ -335,6 +336,130 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('Reviewer handoff note')
})
it('shows automation-created workspaces as a metadata icon property', async () => {
worktreeCardProperties = ['status', 'automation']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
}, 20_000)
it('hides the automation metadata icon in compact card mode by preset default', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = [...COMPACT_WORKTREE_CARD_PROPERTIES]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
expect(markup).not.toContain('Nightly triage automation')
}, 20_000)
it('shows the automation metadata icon in compact card mode when manually enabled', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status', 'automation']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
}, 20_000)
it('hides automation-created card surfaces when the Automation property is disabled', async () => {
worktreeCardProperties = ['status']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
expect(markup).not.toContain('Nightly triage automation')
})
it('hides live port metadata when the Ports card property is disabled', async () => {
const worktree = makeWorktree()
workspacePortScan = {
@@ -216,6 +216,7 @@ describe('WorktreeCard quick actions', () => {
/>
)
expect(markup).not.toMatch(/Will be renamed from first agent message|rename pending/)
expect(markup).not.toContain(
'aria-label="This worktree will be renamed from the first agent message"'
)
@@ -205,6 +205,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
}: WorktreeCardProps) {
const openModal = useAppStore((s) => s.openModal)
const openTaskPage = useAppStore((s) => s.openTaskPage)
const openAutomationsPage = useAppStore((s) => s.openAutomationsPage)
const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
@@ -248,6 +250,53 @@ const WorktreeCard = React.memo(function WorktreeCard({
[worktree, openModal]
)
const handleOpenAutomation = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const automationId = worktree.automationProvenance?.automationId
if (!automationId) {
return
}
const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId
setPendingAutomationRunNavigation({
automationId,
runId: null,
...(hostId ? { hostId } : {})
})
openAutomationsPage()
},
[
openAutomationsPage,
setPendingAutomationRunNavigation,
worktree.automationProvenance?.automationId,
worktree.automationProvenance?.hostId,
worktree.hostId
]
)
const handleOpenAutomationRun = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const provenance = worktree.automationProvenance
if (!provenance) {
return
}
const hostId = provenance.hostId ?? worktree.hostId
setPendingAutomationRunNavigation({
automationId: provenance.automationId,
runId: provenance.automationRunId,
...(hostId ? { hostId } : {})
})
openAutomationsPage()
},
[
openAutomationsPage,
setPendingAutomationRunNavigation,
worktree.automationProvenance,
worktree.hostId
]
)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id])
const remoteBranchConflict = useAppStore((s) => s.remoteBranchConflictByWorktreeId[worktree.id])
@@ -442,6 +491,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const showIssue = cardProps.includes('issue')
const showLinearIssue = cardProps.includes('linear-issue')
const showPR = cardProps.includes('pr')
const showAutomation = cardProps.includes('automation')
const showComment = cardProps.includes('comment')
const showPorts = cardProps.includes('ports')
const shouldRefreshHostedReview = newCardStyle ? showStatus : showPR
@@ -781,7 +831,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
event.stopPropagation()
setShowRenameErrorDialog(true)
}, [])
const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread'
const lineageChildAriaLabel =
lineageChildCount === 1
@@ -869,6 +918,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const metaIssue = showIssue ? hoverIssue : null
const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null
const metaReview = showPR ? hoverReview : null
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
const metaComment = showComment ? hoverComment : null
const handleOpenGitHubIssueInOrca = useCallback(
(e: React.MouseEvent) => {
@@ -958,7 +1008,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
issue: metaIssue,
linearIssue: metaLinearIssue,
review: newCardStyle ? null : metaReview,
comment: metaComment
comment: metaComment,
automationProvenance: metaAutomationProvenance
})
const hasPorts = showPorts && workspacePorts.length > 0
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
@@ -986,7 +1037,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const showCombinedStatusSlot = showStatus
const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder
const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts)
const showTitleRowIndicators = newCardStyle && (hasDetails || hasPorts)
const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts)
// Why: detailed cards need a stable metadata lane only when it has content.
// Grouped project views can hide the repo badge; don't reserve a blank
// metadata lane unless branch or detached-head identity has content.
@@ -1014,7 +1065,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
issue: hoverIssue,
linearIssue: hoverLinearIssue,
review: hoverReview,
comment: hoverComment
comment: hoverComment,
automationProvenance: metaAutomationProvenance
}) ||
workspacePorts.length > 0 ||
showBranchIdentityHover)
@@ -1031,6 +1083,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
automationProvenance={metaAutomationProvenance}
automationHostId={worktree.hostId}
branchName={showBranchIdentityHover ? branch : undefined}
workspaceTitle={worktree.displayName}
identityOrder="branch-first"
@@ -1050,6 +1104,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
? handleOpenReviewInOrca
: undefined
}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
// Why: compact mode hides the metadata badge row, so title hover
// carries the same explicit-link affordance without adding chrome.
onUnlinkReview={
@@ -1078,6 +1134,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
linearIssue={metaLinearIssue}
review={newCardStyle ? null : metaReview}
comment={metaComment}
automationProvenance={metaAutomationProvenance}
className="ml-0 pr-0"
/>
)}
@@ -1090,6 +1147,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
linearIssue={metaLinearIssue}
review={metaReview}
comment={metaComment}
automationProvenance={metaAutomationProvenance}
automationHostId={worktree.hostId}
detailsAfter={hasPorts ? <WorktreeCardPortsDetails ports={workspacePorts} /> : null}
hoverControl={detailsHoverControl}
onEditIssue={affiliateListMode ? undefined : handleEditIssue}
@@ -1101,6 +1160,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
onOpenReviewInOrca={
metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
// Why: branch lookup can show a review without persisted metadata. Only
// expose unlink when this workspace has an explicit linked PR/MR.
onUnlinkReview={
@@ -1147,6 +1208,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
onToggleUnread={handleToggleUnreadQuick}
prDisplay={hoverReview}
newCardStyle={newCardStyle}
hasBranchIdentity={!isFolder && branch.length > 0}
/>
</div>
) : null}
@@ -1275,7 +1337,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
</TooltipContent>
</Tooltip>
) : null}
{!compactCards && worktree.isMainWorktree && !isFolder && (
<Tooltip>
<TooltipTrigger asChild>
@@ -1542,6 +1603,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
linearIssue={hoverLinearIssue}
review={hoverReview}
comment={hoverComment}
automationProvenance={metaAutomationProvenance}
automationHostId={worktree.hostId}
branchName={showBranchIdentityHover ? branch : undefined}
workspaceTitle={showBranchIdentityHover ? visibleCardTitle : undefined}
detailsAfter={
@@ -1560,6 +1623,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
onOpenReviewInOrca={
hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined
}
onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation}
onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun}
// Why: branch lookup can show a review without persisted metadata. Only
// expose unlink when this workspace has an explicit linked PR/MR.
onUnlinkReview={
@@ -0,0 +1,151 @@
import React from 'react'
import { CalendarClock, PlayCircle } from 'lucide-react'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { AutomationWorkspaceProvenance } from '../../../../shared/types'
import {
WorktreeCardDetailSection,
WorktreeCardDetailSectionContent
} from './WorktreeCardDetailSection'
import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls'
import { translate } from '@/i18n/i18n'
import {
getAutomationTargetFromHostId,
listAutomationRunsForTarget,
listAutomationsForTarget
} from '@/components/automations/automation-host-client'
type WorktreeCardAutomationDetailSectionProps = {
provenance: AutomationWorkspaceProvenance
worktreeHostId?: ExecutionHostId
onOpenAutomation?: (event: React.MouseEvent) => void
onOpenAutomationRun?: (event: React.MouseEvent) => void
}
type AutomationProvenanceAvailability =
| { status: 'checking' }
| { status: 'available'; runAvailable: boolean }
| { status: 'automation-missing' }
| { status: 'unavailable' }
export function WorktreeCardAutomationDetailSection({
provenance,
worktreeHostId,
onOpenAutomation,
onOpenAutomationRun
}: WorktreeCardAutomationDetailSectionProps): React.JSX.Element {
const [availability, setAvailability] = React.useState<AutomationProvenanceAvailability>({
status: 'checking'
})
React.useEffect(() => {
let cancelled = false
async function resolveAvailability(): Promise<void> {
setAvailability({ status: 'checking' })
try {
const target = getAutomationTargetFromHostId(provenance.hostId ?? worktreeHostId)
const automations = await listAutomationsForTarget(target)
const automation = automations.find((entry) => entry.id === provenance.automationId)
if (!automation) {
if (!cancelled) {
setAvailability({ status: 'automation-missing' })
}
return
}
const runs = await listAutomationRunsForTarget(target, provenance.automationId)
if (!cancelled) {
setAvailability({
status: 'available',
runAvailable: runs.some((run) => run.id === provenance.automationRunId)
})
}
} catch {
if (!cancelled) {
setAvailability({ status: 'unavailable' })
}
}
}
void resolveAvailability()
return () => {
cancelled = true
}
}, [provenance.automationId, provenance.automationRunId, provenance.hostId, worktreeHostId])
const canOpenAutomation = availability.status === 'available'
const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable
return (
<WorktreeCardDetailSection>
<DetailHeader
icon={<CalendarClock className="size-3 text-muted-foreground" />}
label={translate('auto.components.sidebar.WorktreeCardMeta.automationHeader', 'Automation')}
actions={
<>
{onOpenAutomation && canOpenAutomation && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.openAutomation',
'Open automation'
)}
onClick={onOpenAutomation}
>
<CalendarClock className="size-3" />
</MetadataActionIcon>
)}
{onOpenAutomationRun && canOpenAutomationRun && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.openAutomationRun',
'Open run'
)}
onClick={onOpenAutomationRun}
>
<PlayCircle className="size-3" />
</MetadataActionIcon>
)}
</>
}
/>
<WorktreeCardDetailSectionContent className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{provenance.automationNameSnapshot}
</div>
<div className="text-[11.5px] leading-snug text-muted-foreground break-words">
{provenance.automationRunTitleSnapshot}
</div>
{availability.status === 'checking' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.checkingAutomationAvailability',
'Checking automation availability...'
)}
</div>
) : null}
{availability.status === 'automation-missing' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationMissing',
'Automation no longer available.'
)}
</div>
) : null}
{availability.status === 'available' && !availability.runAvailable ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationRunMissing',
'Run history no longer available.'
)}
</div>
) : null}
{availability.status === 'unavailable' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationAvailabilityUnavailable',
'Automation availability could not be checked.'
)}
</div>
) : null}
</WorktreeCardDetailSectionContent>
</WorktreeCardDetailSection>
)
}
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorktreeCardDisplayMenuSection } from './WorktreeCardDisplayMenuSection'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const setWorktreeCardMode = vi.fn()
const setWorktreeCardProperties = vi.fn()
const setAgentActivityDisplayMode = vi.fn()
let settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
let worktreeCardProperties = [
'status',
'unread',
'issue',
'linear-issue',
'pr',
'automation',
'comment',
'ports',
'inline-agents'
]
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
agentActivityDisplayMode: 'compact',
setAgentActivityDisplayMode,
setWorktreeCardMode,
setWorktreeCardProperties,
settings,
worktreeCardProperties
})
}))
vi.mock('@/components/ui/dropdown-menu', async () => {
const ReactModule = await import('react')
type RadioItemProps = {
children: ReactNode
onSelect?: (event: { preventDefault: () => void }) => void
onValueChange?: (value: string) => void
value: string
}
return {
DropdownMenuCheckboxItem: ({
children,
checked,
onCheckedChange
}: {
children: ReactNode
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button
type="button"
data-checked={checked ? 'true' : 'false'}
onClick={() => onCheckedChange?.(!checked)}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuRadioGroup: ({
children,
onValueChange,
value
}: {
children: ReactNode
onValueChange?: (value: string) => void
value?: string
}) => (
<div data-radio-group-value={value}>
{ReactModule.Children.map(children, (child) =>
ReactModule.isValidElement<RadioItemProps>(child)
? ReactModule.cloneElement(child, { onValueChange })
: child
)}
</div>
),
DropdownMenuRadioItem: ({ children, onSelect, onValueChange, value }: RadioItemProps) => (
<button
type="button"
data-radio-item-value={value}
onClick={() => {
onSelect?.({ preventDefault: vi.fn() })
onValueChange?.(value)
}}
>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>
}
})
let root: Root | null = null
let container: HTMLDivElement | null = null
function renderMenu(): void {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<WorktreeCardDisplayMenuSection preserveWorkspaceBoardOpen={false} />)
})
}
beforeEach(() => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = [
'status',
'unread',
'issue',
'linear-issue',
'pr',
'automation',
'comment',
'ports',
'inline-agents'
]
setAgentActivityDisplayMode.mockReset()
setWorktreeCardMode.mockReset()
setWorktreeCardProperties.mockReset()
})
afterEach(() => {
act(() => {
root?.unmount()
})
root = null
container?.remove()
container = null
document.body.innerHTML = ''
})
describe('WorktreeCardDisplayMenuSection', () => {
it('applies the compact card mode preset from the visible card layout menu', () => {
renderMenu()
const compactLayoutButton = document.querySelector<HTMLButtonElement>(
'[data-radio-group-value="detailed"] [data-radio-item-value="compact"]'
)
expect(compactLayoutButton).not.toBeNull()
act(() => {
compactLayoutButton?.click()
})
expect(setWorktreeCardMode).toHaveBeenCalledWith('Compact')
})
})
@@ -14,9 +14,9 @@ import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../..
import {
AGENT_ACTIVITY_DISPLAY_OPTIONS,
CARD_LAYOUT_OPTIONS,
PROPERTY_OPTIONS,
getWorktreeCardPropertyOptions
} from './sidebar-workspace-option-items'
import { PROPERTY_OPTIONS } from './worktree-card-display-property-options'
import { translate } from '@/i18n/i18n'
type WorktreeCardDisplayMenuSectionProps = {
@@ -29,7 +29,7 @@ export function WorktreeCardDisplayMenuSection({
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
const setWorktreeCardProperties = useAppStore((s) => s.setWorktreeCardProperties)
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const setWorktreeCardMode = useAppStore((s) => s.setWorktreeCardMode)
const agentActivityDisplayMode = useAppStore((s) => s.agentActivityDisplayMode)
const setAgentActivityDisplayMode = useAppStore((s) => s.setAgentActivityDisplayMode)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
@@ -108,9 +108,9 @@ export function WorktreeCardDisplayMenuSection({
<DropdownMenuRadioGroup
value={cardLayout}
onValueChange={(value) => {
void updateSettings({
compactWorktreeCards: value === 'compact'
})
// Why: layout changes are presets, not just density toggles; keep
// the visible menu path aligned with card property defaults.
setWorktreeCardMode(value === 'compact' ? 'Compact' : 'Default')
}}
>
{CARD_LAYOUT_OPTIONS.map((opt) => (
@@ -1,7 +1,7 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react'
import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
@@ -24,6 +24,7 @@ import type {
} from './worktree-card-meta-types'
import { translate } from '@/i18n/i18n'
import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection'
import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection'
export type {
WorktreeCardIssueDisplay,
@@ -41,19 +42,20 @@ export function hasWorktreeCardDetails({
issue,
linearIssue,
review,
comment
comment,
automationProvenance
}: WorktreeCardMetaBadgesProps): boolean {
return Boolean(issue || linearIssue || review || hasComment(comment))
return Boolean(issue || linearIssue || review || hasComment(comment) || automationProvenance)
}
export const WorktreeCardMetaBadges = React.forwardRef<
HTMLDivElement,
WorktreeCardMetaBadgesRootProps
>(function WorktreeCardMetaBadges(
{ issue, linearIssue, review, comment, className, ...props },
{ issue, linearIssue, review, comment, automationProvenance, className, ...props },
ref
): React.JSX.Element | null {
if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) {
if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance })) {
return null
}
@@ -79,6 +81,16 @@ export const WorktreeCardMetaBadges = React.forwardRef<
<StickyNote className="text-muted-foreground" />
</MetaIconBadge>
)}
{automationProvenance && (
<MetaIconBadge
label={translate(
'auto.components.sidebar.WorktreeCardMeta.automationCreated',
'Created by automation'
)}
>
<CalendarClock className="text-muted-foreground" />
</MetaIconBadge>
)}
{issue && (
<MetaIconBadge
label={translate(
@@ -121,10 +133,12 @@ export function WorktreeCardDetailsHover({
linearIssue,
review,
comment,
automationProvenance,
children,
branchName,
workspaceTitle,
identityOrder = 'workspace-first',
automationHostId,
detailsAfter,
openDelay = 250,
closeDelay = 120,
@@ -134,6 +148,8 @@ export function WorktreeCardDetailsHover({
onOpenLinearIssueInOrca,
onOpenReviewInOrca,
onUnlinkReview,
onOpenAutomation,
onOpenAutomationRun,
hoverControl
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
const internalHoverControl = useWorktreeCardDetailsHoverControl()
@@ -156,7 +172,7 @@ export function WorktreeCardDetailsHover({
if (
!showIdentityHeader &&
!hasWorktreeCardDetails({ issue, linearIssue, review, comment }) &&
!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance }) &&
!detailsAfter
) {
return children
@@ -343,6 +359,17 @@ export function WorktreeCardDetailsHover({
closeHover={closeHover}
/>
{automationProvenance && (
<WorktreeCardAutomationDetailSection
provenance={automationProvenance}
worktreeHostId={automationHostId}
onOpenAutomation={onOpenAutomation ? dismissAndRun(onOpenAutomation) : undefined}
onOpenAutomationRun={
onOpenAutomationRun ? dismissAndRun(onOpenAutomationRun) : undefined
}
/>
)}
{hasComment(comment) && (
<WorktreeCardDetailSection>
<DetailHeader
@@ -148,6 +148,46 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('bg-neutral-500/40')
})
it('uses a branch icon instead of the quiet active dot when new card style has no review', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Branch')
expect(markup).toContain('lucide-git-branch')
expect(markup).toContain('text-muted-foreground/70')
expect(markup).not.toContain('bg-emerald-500')
})
it('keeps the quiet dot when the row has no branch identity', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity={false}
/>
)
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('lucide-git-branch')
})
it('keeps working activity ahead of PR status in new card style', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
@@ -237,23 +277,26 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('bg-emerald-500')
})
it('keeps the new style unread status dot in the stable lane footprint', () => {
it('overlays unread on the no-review branch icon in new card style', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread={false}
unreadTooltip="Mark as unread"
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Active · Mark as unread')
expect(markup).toContain('Branch · Mark as read')
expect(markup).toContain(
'group/unread relative flex cursor-pointer items-center justify-center rounded transition-all size-5'
)
expect(markup).toContain('lucide-git-branch')
expect(markup).toContain('absolute -right-1 -top-1 size-[13px] text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
})
})
@@ -1,5 +1,5 @@
import React from 'react'
import { Bell } from 'lucide-react'
import { Bell, GitBranch } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
@@ -19,10 +19,15 @@ type WorktreeCardStatusSlotProps = {
onPointerDown: React.PointerEventHandler<HTMLButtonElement>
prDisplay?: WorktreeCardPrDisplay | null
newCardStyle?: boolean
hasBranchIdentity?: boolean
className?: string
}
const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set<WorktreeStatus>(['active', 'done', 'inactive'])
// Why: a missing review display can also mean provider state is unavailable,
// so the passive label names the branch cue without claiming no review exists.
const BRANCH_STATUS_LABEL = 'Branch'
const branchStatusIconClassName = 'size-4 text-muted-foreground/70'
function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
const label = getReviewLabel(review)
@@ -57,6 +62,7 @@ export function WorktreeCardStatusSlot({
onPointerDown,
prDisplay = null,
newCardStyle = false,
hasBranchIdentity = true,
className
}: WorktreeCardStatusSlotProps): React.JSX.Element | null {
const status = useWorktreeActivityStatus(worktreeId)
@@ -66,9 +72,20 @@ export function WorktreeCardStatusSlot({
showStatus &&
prDisplay !== null &&
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const canShowBranchStatus =
newCardStyle &&
showStatus &&
hasBranchIdentity &&
prDisplay === null &&
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const passiveStatusLabel =
canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) : statusLabel
canShowReviewStatus && prDisplay
? getReviewStatusTooltip(prDisplay)
: canShowBranchStatus
? BRANCH_STATUS_LABEL
: statusLabel
const reviewStatusIconClassName = 'size-4'
const branchStatusIcon = <GitBranch className={branchStatusIconClassName} aria-hidden="true" />
const passiveStatus =
canShowReviewStatus && prDisplay ? (
<Tooltip>
@@ -82,6 +99,18 @@ export function WorktreeCardStatusSlot({
<span>{passiveStatusLabel}</span>
</TooltipContent>
</Tooltip>
) : canShowBranchStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusLabel}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusLabel}</span>
</TooltipContent>
</Tooltip>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
@@ -106,7 +135,7 @@ export function WorktreeCardStatusSlot({
const actionLabel = isUnread ? 'Mark as read' : 'Mark as unread'
const tooltip =
showStatus && (!isUnread || (newCardStyle && canShowReviewStatus && prDisplay))
showStatus && (!isUnread || (newCardStyle && (canShowBranchStatus || canShowReviewStatus)))
? `${passiveStatusLabel} · ${unreadTooltip}`
: unreadTooltip
@@ -135,6 +164,13 @@ export function WorktreeCardStatusSlot({
</span>
<FilledBellIcon className="absolute -right-1 -top-1 size-[13px] text-amber-500 drop-shadow-sm" />
</>
) : isUnread && showStatus && canShowBranchStatus ? (
<>
<span className="inline-flex size-5 items-center justify-center p-0.5">
{branchStatusIcon}
</span>
<FilledBellIcon className="absolute -right-1 -top-1 size-[13px] text-amber-500 drop-shadow-sm" />
</>
) : isUnread ? (
<FilledBellIcon className="size-[13px] text-amber-500 drop-shadow-sm" />
) : showStatus && canShowReviewStatus && prDisplay ? (
@@ -144,6 +180,13 @@ export function WorktreeCardStatusSlot({
</span>
<Bell className="absolute size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover/unread:opacity-100 group-focus-within/unread:opacity-100" />
</>
) : showStatus && canShowBranchStatus ? (
<>
<span className="inline-flex size-5 items-center justify-center p-0.5 transition-opacity group-hover/unread:opacity-0 group-focus-within/unread:opacity-0">
{branchStatusIcon}
</span>
<Bell className="absolute size-3 text-muted-foreground/40 opacity-0 transition-opacity group-hover/unread:opacity-100 group-focus-within/unread:opacity-100" />
</>
) : showStatus ? (
<>
<StatusIndicator

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