Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions Resources/bin/claude
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,23 @@ done
# the actual claude process PID, which hooks use for stale-session detection.
export CMUX_CLAUDE_PID=$$

# Build hooks settings JSON.
# Claude Code merges --settings additively with the user's own settings.json.
# - SessionStart/Stop/Notification: existing lifecycle hooks
# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire)
# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt
# - PreToolUse: clears "Needs input" when Claude resumes after permission grant (async to avoid latency)
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}'
# Resolve the directory containing this wrapper (for sibling scripts).
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"

# Base hooks (no path interpolation — always safe to inject).
BASE_HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}'

# Build full hooks JSON that adds AI-naming scripts alongside base hooks.
# $SELF_DIR is interpolated into JSON command strings, so validate it contains
# no characters that break JSON (" \) or shell tokenization (' space $ ` ! ; | & < > ( ) * ? [ ]).
# If unsafe, fall back to BASE_HOOKS_JSON — existing lifecycle hooks still work,
# only the AI-naming add-on is skipped.
if printf '%s' "$SELF_DIR" | grep -qE "[\"\\\\' \$\`!;|&<>()*?\[\]]"; then
echo "cmux: warning: wrapper path has JSON/shell-unsafe characters — AI tab naming disabled: $SELF_DIR" >&2
HOOKS_JSON="$BASE_HOOKS_JSON"
else
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10},{"type":"command","command":"'"$SELF_DIR"'/cmux-session-namer.sh","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10},{"type":"command","command":"'"$SELF_DIR"'/cmux-tab-namer.sh","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10},{"type":"command","command":"'"$SELF_DIR"'/cmux-rename-namer.sh","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}'
fi

if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
Expand Down
85 changes: 85 additions & 0 deletions Resources/bin/cmux-rename-namer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# cmux-rename-namer.sh — Apply pending AI labels + sync /rename custom title to cmux tab
#
# Invoked by the cmux claude wrapper as a UserPromptSubmit hook.
#
# Phase 1: Apply pending AI label from previous Stop (moved here from the Stop hook
# so the tab name updates the moment the user sends their next message,
# rather than waiting for Claude's full response to complete).
#
# Phase 2: Sync /rename custom title to tab and workspace (owner only).
# Writes a marker file so the custom title persists even in long transcripts
# where tail-500 would miss an early /rename entry.
#
# Environment: CMUX_WORKSPACE_ID, CMUX_SURFACE_ID (set by cmux shell integration)
# Stdin: JSON with session_id, transcript_path, etc.

set -e

INPUT=$(cat)

[ "$CMUX_TAB_NAMER_DISABLED" = "1" ] && exit 0
[ -z "$CMUX_WORKSPACE_ID" ] && exit 0
[ -z "$CMUX_SURFACE_ID" ] && exit 0
command -v cmux &>/dev/null || exit 0

# ============================================================
# PHASE 1: Apply pending AI label from previous Stop (fast path)
# Tab name updates on next user prompt, not on next Claude response.
# ============================================================
TAB_PENDING="/tmp/cmux-tab-pending-${CMUX_SURFACE_ID}"
TAB_CACHE="/tmp/cmux-tab-cache-${CMUX_SURFACE_ID}"
if [ -f "$TAB_PENDING" ]; then
LABEL=$(head -1 "$TAB_PENDING" 2>/dev/null)
P_LINE_COUNT=$(sed -n '2p' "$TAB_PENDING" 2>/dev/null)
P_HAS_CUSTOM=$(sed -n '3p' "$TAB_PENDING" 2>/dev/null)
rm -f "$TAB_PENDING" 2>/dev/null

if [ -n "$LABEL" ]; then
# Update tab cache
printf '%s\n%s\n' "${P_LINE_COUNT:-0}" "$LABEL" > "$TAB_CACHE" 2>/dev/null

# Rename this tab
cmux rename-tab --workspace "$CMUX_WORKSPACE_ID" --surface "$CMUX_SURFACE_ID" "$LABEL" 2>/dev/null || true

# Rename workspace only if: (a) no custom-title, AND (b) this tab is the workspace owner
WS_OWNER_FILE="/tmp/cmux-ws-owner-${CMUX_WORKSPACE_ID}"
WS_OWNER=$(cat "$WS_OWNER_FILE" 2>/dev/null || true)
if [ "${P_HAS_CUSTOM:-0}" -eq 0 ] 2>/dev/null && [ "$WS_OWNER" = "$CMUX_SURFACE_ID" ]; then
cmux rename-workspace --workspace "$CMUX_WORKSPACE_ID" "$LABEL" 2>/dev/null || true
fi
fi
fi

# ============================================================
# PHASE 2: Sync /rename custom title
# ============================================================

# Extract transcript path
TRANSCRIPT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || true)
[ -z "$TRANSCRIPT" ] && exit 0
[ -f "$TRANSCRIPT" ] || exit 0

# Fast: grep last custom-title from transcript tail
TITLE=$(tail -500 "$TRANSCRIPT" | grep '"type":"custom-title"' | tail -1 | python3 -c "
import sys,json
try:
print(json.loads(sys.stdin.readline()).get('customTitle',''))
except: pass
" 2>/dev/null)

[ -z "$TITLE" ] && exit 0

# Write marker file — ensures custom-title suppresses AI summary even in very long
# transcripts where the /rename entry falls outside the tail-500 window.
# umask 077 → 0600: marker contains conversation-derived text.
(umask 077; printf '%s\n' "$TITLE" > "/tmp/cmux-custom-title-${CMUX_SURFACE_ID}") 2>/dev/null

# Always rename this tab
cmux rename-tab --workspace "$CMUX_WORKSPACE_ID" --surface "$CMUX_SURFACE_ID" "$TITLE" 2>/dev/null || true

# Only rename workspace if this tab is the owner (first session)
WS_OWNER=$(cat "/tmp/cmux-ws-owner-${CMUX_WORKSPACE_ID}" 2>/dev/null || true)
if [ "$WS_OWNER" = "$CMUX_SURFACE_ID" ]; then
cmux rename-workspace --workspace "$CMUX_WORKSPACE_ID" "$TITLE" 2>/dev/null || true
fi
91 changes: 91 additions & 0 deletions Resources/bin/cmux-session-namer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# cmux-session-namer.sh — Initialize tab/workspace naming on new Claude Code session
#
# Invoked by the cmux claude wrapper as a SessionStart hook (both new and resume).
#
# New session: clear cache for fresh AI summary, set project basename
# Resume session: scan entire transcript for custom-title and sync it immediately,
# write marker file so AI summary is suppressed (fixes the bug where
# /rename early in a long transcript was invisible to tail-500 checks)
#
# Environment: CMUX_WORKSPACE_ID, CMUX_SURFACE_ID (set by cmux shell integration)
# Stdin: JSON with session_id, cwd, transcript_path, etc.

set -e

INPUT=$(cat)

[ "$CMUX_TAB_NAMER_DISABLED" = "1" ] && exit 0
[ -z "$CMUX_WORKSPACE_ID" ] && exit 0
[ -z "$CMUX_SURFACE_ID" ] && exit 0
command -v cmux &>/dev/null || exit 0

# Register as workspace owner if no owner exists yet (first session wins).
# The workspace owner's AI summary / custom-title controls the workspace name.
WS_OWNER_FILE="/tmp/cmux-ws-owner-${CMUX_WORKSPACE_ID}"

# Stale-owner recovery: if an owner file exists but that surface is no longer
# active in this workspace, clear the file so a live session can claim ownership.
# This prevents the workspace name from freezing after the original owner exits.
if [ -f "$WS_OWNER_FILE" ]; then
STALE_OWNER=$(cat "$WS_OWNER_FILE" 2>/dev/null || true)
if [ -n "$STALE_OWNER" ] && [ "$STALE_OWNER" != "$CMUX_SURFACE_ID" ]; then
# Check if the owner surface is still active in this workspace.
# Capture output first so a transient cmux error (empty output) is
# not mistaken for "owner not present" — only delete if the command
# succeeded AND the owner ID is absent from the listing.
LIVE_SURFACES=$(cmux list-surfaces --workspace "$CMUX_WORKSPACE_ID" --id-format uuids 2>/dev/null || true)
if [ -n "$LIVE_SURFACES" ] && ! printf '%s' "$LIVE_SURFACES" | grep -qF "$STALE_OWNER"; then
rm -f "$WS_OWNER_FILE" 2>/dev/null
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
fi
Comment on lines +25 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Workspace owner file is never cleaned up

/tmp/cmux-ws-owner-${CMUX_WORKSPACE_ID} (and the per-surface tab-cache, tab-pending, tab-prompt files) are written but never removed. The docs state the owner file "resets when all sessions in the workspace end", but there is no hook — SessionEnd only calls cmux claude-hook session-end, not any of the new scripts. Over time these files accumulate in /tmp.

A SessionEnd hook in a new cmux-session-ender.sh (or inline in the existing hook dispatch) could remove the owner file when the last session for a workspace exits and clean up the per-surface files. At minimum, the documentation should be corrected to reflect the current (no-cleanup) behaviour.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in be922f6. We intentionally don't clean up the owner file on exit (no reliable exit hook) — instead, stale-owner recovery runs on every SessionStart: cmux list-surfaces --workspace is called to verify the recorded owner is still active, and the file is deleted if not. This covers the case where the original owner tab closes without triggering cleanup.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Atomic owner claim via noclobber (O_EXCL): only the first concurrent
# SessionStart wins; others silently skip.
( set -o noclobber
printf '%s\n' "$CMUX_SURFACE_ID" > "$WS_OWNER_FILE"
) 2>/dev/null || true

# Check entire transcript for an existing custom-title (handles resumed sessions
# where /rename was issued earlier in a long transcript, beyond tail-500 reach)
CUSTOM_MARKER="/tmp/cmux-custom-title-${CMUX_SURFACE_ID}"
TRANSCRIPT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || true)

if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then
TITLE=$(grep '"type":"custom-title"' "$TRANSCRIPT" 2>/dev/null | tail -1 | python3 -c "
import sys,json
try:
print(json.loads(sys.stdin.readline()).get('customTitle',''))
except: pass
" 2>/dev/null)

if [ -n "$TITLE" ]; then
# Resumed session with custom-title: sync immediately and suppress AI summary.
# Write with umask 077 (0600) — marker contains conversation-derived text.
(umask 077; printf '%s\n' "$TITLE" > "$CUSTOM_MARKER")
cmux rename-tab --workspace "$CMUX_WORKSPACE_ID" --surface "$CMUX_SURFACE_ID" "$TITLE" 2>/dev/null || true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
WS_OWNER=$(cat "$WS_OWNER_FILE" 2>/dev/null || true)
if [ "$WS_OWNER" = "$CMUX_SURFACE_ID" ]; then
cmux rename-workspace --workspace "$CMUX_WORKSPACE_ID" "$TITLE" 2>/dev/null || true
fi
exit 0
fi
fi

# No custom-title: clear marker, cache, and pending label so the first Stop triggers
# a fresh AI summary and stale pending labels from a prior session are not applied.
rm -f "$CUSTOM_MARKER" 2>/dev/null
rm -f "/tmp/cmux-tab-cache-${CMUX_SURFACE_ID}" 2>/dev/null
rm -f "/tmp/cmux-tab-pending-${CMUX_SURFACE_ID}" 2>/dev/null

# Set project basename as initial workspace name — only if this tab owns the workspace.
# Non-owner sessions must not overwrite an already-established workspace name.
WS_OWNER=$(cat "$WS_OWNER_FILE" 2>/dev/null || true)
if [ "$WS_OWNER" = "$CMUX_SURFACE_ID" ]; then
CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || true)
if [ -n "$CWD" ]; then
BASENAME=$(basename "$CWD")
cmux rename-workspace --workspace "$CMUX_WORKSPACE_ID" "$BASENAME" 2>/dev/null || true
fi
fi
131 changes: 131 additions & 0 deletions Resources/bin/cmux-tab-namer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# cmux-tab-namer.sh — AI-powered tab & workspace auto-naming for Claude Code
#
# Invoked by the cmux claude wrapper as a Stop hook.
# Generates an AI summary in background → writes to pending file.
# The pending label is applied in cmux-rename-namer.sh (UserPromptSubmit)
# for faster tab name updates (user sees new name on next prompt, not next response).
#
# custom-title detection uses a marker file so long transcripts (e.g. resumed
# sessions with /rename early in history) are handled correctly.
#
# Environment: CMUX_WORKSPACE_ID, CMUX_SURFACE_ID (set by cmux shell integration)
# Stdin: JSON with session_id, transcript_path, cwd, etc.

set -e

INPUT=$(cat)

[ "$CMUX_TAB_NAMER_DISABLED" = "1" ] && exit 0
[ -z "$CMUX_WORKSPACE_ID" ] && exit 0
[ -z "$CMUX_SURFACE_ID" ] && exit 0
command -v cmux &>/dev/null || exit 0

TRANSCRIPT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || true)
[ -z "$TRANSCRIPT" ] && exit 0
[ -f "$TRANSCRIPT" ] || exit 0

# Per-tab (surface) files — each tab tracks its own label independently
TAB_PENDING="/tmp/cmux-tab-pending-${CMUX_SURFACE_ID}"
TAB_CACHE="/tmp/cmux-tab-cache-${CMUX_SURFACE_ID}"

# ============================================================
# Generate AI summary if needed, spawn background
# ============================================================
LINE_COUNT=$(wc -l < "$TRANSCRIPT" 2>/dev/null | tr -d ' ')
# Skip tiny transcripts (subagent sessions)
[ "$LINE_COUNT" -lt 50 ] 2>/dev/null && exit 0

# Skip if custom-title exists — check marker file first (handles long/resumed transcripts),
# then fall back to tail-500 scan for /rename done in the current session.
CUSTOM_MARKER="/tmp/cmux-custom-title-${CMUX_SURFACE_ID}"
[ -f "$CUSTOM_MARKER" ] && exit 0
HAS_CUSTOM_TITLE=$(tail -500 "$TRANSCRIPT" | grep -c '"type":"custom-title"' 2>/dev/null; true)
[ "${HAS_CUSTOM_TITLE:-0}" -gt 0 ] 2>/dev/null && exit 0

if [ -f "$TAB_CACHE" ]; then
PREV_COUNT=$(head -1 "$TAB_CACHE" 2>/dev/null || echo 0)
DIFF=$((LINE_COUNT - PREV_COUNT))
if [ "$DIFF" -lt 6 ]; then
exit 0
fi
fi

# Extract recent conversation context
CONTEXT=$(_TRANSCRIPT="$TRANSCRIPT" python3 -c "
import json, os, subprocess, sys

TRANSCRIPT = os.environ['_TRANSCRIPT']

tail_lines = subprocess.run(
['tail', '-300', TRANSCRIPT], capture_output=True, text=True
).stdout.splitlines()

head_lines = subprocess.run(
['head', '-100', TRANSCRIPT], capture_output=True, text=True
).stdout.splitlines()

def extract_text(entry):
msg = entry.get('message', {})
if isinstance(msg, dict):
c = msg.get('content', '')
if isinstance(c, str): return c
if isinstance(c, list):
return ' '.join(b.get('text','') for b in c if isinstance(b, dict) and b.get('type') == 'text')
return ''

first_user = []
for raw in head_lines:
try:
e = json.loads(raw)
if e.get('type') == 'user':
t = extract_text(e)
if t.strip():
first_user.append(f'user: {t[:200]}')
if len(first_user) >= 2: break
except: pass

last_msgs = []
for raw in reversed(tail_lines):
try:
e = json.loads(raw)
if e.get('type') in ('user', 'assistant'):
t = extract_text(e)
if t.strip():
last_msgs.append(f'{e[\"type\"]}: {t[:200]}')
if len(last_msgs) >= 4: break
except: pass
last_msgs.reverse()

all_msgs = first_user + last_msgs
if all_msgs:
print('\n'.join(all_msgs))
" 2>/dev/null)

[ -z "$CONTEXT" ] && exit 0

# Build prompt file (per-surface to avoid collisions)
PROMPT_FILE="/tmp/cmux-tab-prompt-${CMUX_SURFACE_ID}"
cat > "$PROMPT_FILE" << PYEOF
Summarize this conversation in 2-5 words, using the SAME language as the conversation. Output ONLY the summary, nothing else.

$CONTEXT
PYEOF

# Spawn background: generate AI label → write to pending file
# (applied on next UserPromptSubmit by cmux-rename-namer.sh for faster tab updates)
# Unset cmux env vars so the `claude -p` subprocess does not inherit them and
# re-trigger the naming hooks recursively (would corrupt cache/pending state).
(
set +e
unset CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID
# Timeout after 15s using perl alarm (POSIX-compatible, no external deps)
LABEL=$(perl -e 'alarm 15; exec @ARGV' claude -p --model haiku < "$PROMPT_FILE" 2>/dev/null | head -1 | cut -c1-30)
rm -f "$PROMPT_FILE" 2>/dev/null
if [ -n "$LABEL" ]; then
printf '%s\n%s\n%s\n' "$LABEL" "$LINE_COUNT" "${HAS_CUSTOM_TITLE:-0}" > "$TAB_PENDING"
fi
) &>/dev/null &
disown

exit 0
41 changes: 41 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var didSetupMultiWindowNotificationsUITest = false
private var didSetupDisplayResolutionUITestDiagnostics = false
private var displayResolutionUITestObservers: [NSObjectProtocol] = []
private var didRequestFallbackUITestWindow = false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
private struct UITestRenderDiagnosticsSnapshot {
let panelId: UUID
let drawCount: Int
Expand Down Expand Up @@ -2421,6 +2422,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}

#if DEBUG
// Retry launch stabilization until a delayed WindowGroup materialization produces
// a visible key window that XCUITest can bring to the foreground on the shared VM.
private func stabilizeUITestLaunchWindowAndForeground(attempt: Int = 0) {
let env = ProcessInfo.processInfo.environment
guard isRunningUnderXCTest(env) else { return }

if NSApp.windows.isEmpty, !didRequestFallbackUITestWindow {
didRequestFallbackUITestWindow = true
openNewMainWindow(nil)
}

moveUITestWindowToTargetDisplayIfNeeded()
activateUITestAppIfNeeded()

let hasWindow = !NSApp.windows.isEmpty
let hasVisibleWindow = NSApp.windows.contains { $0.isVisible }
let hasKeyWindow = NSApp.keyWindow != nil
let stage = attempt == 0 ? "afterForceWindow" : "afterForceWindow.retry\(attempt)"
writeUITestDiagnosticsIfNeeded(stage: stage)

guard attempt < 20 else { return }
if !hasWindow || !hasVisibleWindow || !hasKeyWindow || !NSRunningApplication.current.isActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.stabilizeUITestLaunchWindowAndForeground(attempt: attempt + 1)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func activateUITestAppIfNeeded() {
if let window = NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
if #available(macOS 14.0, *) {
NSRunningApplication.current.activate(options: [.activateAllWindows])
} else {
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
}

Comment on lines +2414 to +2460
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This UI-test stabilization routine is never called (the only references are the function itself and its recursive retry). As-is, didRequestFallbackUITestWindow and the helper functions add dead code/unused state. Either invoke this from the existing UI-test launch path (e.g. after the initial afterForceWindow block) or remove it to keep the debug-only launch logic maintainable.

Suggested change
// Retry launch stabilization until a delayed WindowGroup materialization produces
// a visible key window that XCUITest can bring to the foreground on the shared VM.
private func stabilizeUITestLaunchWindowAndForeground(attempt: Int = 0) {
let env = ProcessInfo.processInfo.environment
guard isRunningUnderXCTest(env) else { return }
if NSApp.windows.isEmpty, !didRequestFallbackUITestWindow {
didRequestFallbackUITestWindow = true
openNewMainWindow(nil)
}
moveUITestWindowToTargetDisplayIfNeeded()
activateUITestAppIfNeeded()
let hasWindow = !NSApp.windows.isEmpty
let hasVisibleWindow = NSApp.windows.contains { $0.isVisible }
let hasKeyWindow = NSApp.keyWindow != nil
let stage = attempt == 0 ? "afterForceWindow" : "afterForceWindow.retry\(attempt)"
writeUITestDiagnosticsIfNeeded(stage: stage)
guard attempt < 20 else { return }
if !hasWindow || !hasVisibleWindow || !hasKeyWindow || !NSRunningApplication.current.isActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.stabilizeUITestLaunchWindowAndForeground(attempt: attempt + 1)
}
}
}
private func activateUITestAppIfNeeded() {
if let window = NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
if #available(macOS 14.0, *) {
NSRunningApplication.current.activate(options: [.activateAllWindows])
} else {
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
}

Copilot uses AI. Check for mistakes.
private func writeUITestDiagnosticsIfNeeded(stage: String) {
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return }
Expand Down
Loading
Loading