mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.']
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user