using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows;
using System.Net.Http;
using System.Text.Json;
using System.Diagnostics;
using System.Threading;
using System.Windows.Input;
using System.Windows.Media;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows.Interop;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Threading;
using System.Windows.Navigation;
using System.Collections.Generic;
using System.Windows.Media.Imaging;
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using System.Windows.Media.Animation;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using System.Windows.Controls.Primitives;
using Microsoft.Win32;
using SquadDash.Screenshots;
using SquadDash.Screenshots.Fixtures;
using Shapes = System.Windows.Shapes;

namespace SquadDash;

public partial class MainWindow : Window, ILiveElementLocator
{
    private const string PostInstallPrompt =
        "Take a look at my code base and suggest a starting Squad team.";
    internal static readonly string[] UniverseSelectorOptions = [
        SquadInstallerService.SquadDashUniverseName,
        "Star Wars", "The Matrix", "Alien", "Firefly",
        "Ocean's Eleven", "The Simpsons", "Marvel Cinematic Universe",
        "Breaking Bad", "Futurama"
    ];
    private const string LeadAgentDefaultAccentHex = "#FF3E63B8";
    private const string ObservedAgentDefaultAccentHex = "#FF4472C4";
    private const string DynamicAgentDefaultAccentHex = "#FFD0D5DB";
    private const double TranscriptFontSizeMin = 11;
    private const double TranscriptFontSizeMax = 28;
    private const double TranscriptFontSizeStep = 1;
    private const double PromptFontSizeMin = 12;
    private const double PromptFontSizeMax = 30;
    private const double PromptFontSizeStep = 1;
    private const int PromptInputTraceIntervalMs = 1000;
    private const double DocSourceFontSizeMin = 8;
    private const double DocSourceFontSizeMax = 28;
    private const double DocSourceFontSizeStep = 1;

    private static readonly double[] FontScaleFactors = [0.75, 0.875, 1.0, 1.125, 1.25, 1.4, 1.6];
    private static readonly IReadOnlyDictionary<string, double> FontSizeBaseValues =
        new Dictionary<string, double> {
            ["FontSizeTiny"]     = 7,
            ["FontSizeXSmall"]   = 10,
            ["FontSizeSmall"]    = 11,
            ["FontSizeBody"]     = 12,
            ["FontSizeNormal"]   = 13,
            ["FontSizeMedium"]   = 14,
            ["FontSizeLarge"]    = 15,
            ["FontSizeLargePlus"]= 16,
            ["FontSizeSubtitle"] = 18,
            ["FontSizeTitle"]    = 20,
            ["FontSizeHeading"]  = 22,
            ["FontSizeHero"]     = 52,
            ["FontSizeDisplay"]  = 180,
        };
    private const DispatcherPriority PostVisualUpdatePriority = DispatcherPriority.Loaded;
    private const int UiDispatchQueueLagTraceMs = 250;
    private const int UiDispatchWorkTraceMs = 50;
    private const int UiResponsivenessLagTraceMs = 1000;
    private const int DispatcherLongOperationTraceMs = 500;
    private const int PromptRenderLatencyTraceMs = 250;
    private const int PromptTextChangedSevereTraceMs = 250;
    private const int PttVolumeTraceIntervalMs = 1000;
    private static readonly TimeSpan MultiLineHintCooldown = TimeSpan.FromMinutes(1);
    private static readonly TimeSpan AgentActiveDisplayLinger = TimeSpan.FromSeconds(20);
    private static readonly TimeSpan DynamicAgentHistoryRetention = TimeSpan.FromDays(2);
    private static readonly TimeSpan ResponseRenderCadence = TimeSpan.FromMilliseconds(60);
    private static readonly TimeSpan ResponseRenderInputQuietPeriod = TimeSpan.FromMilliseconds(750);
    private static readonly TimeSpan ResponseRenderInputPollCadence = TimeSpan.FromMilliseconds(150);
    private static readonly TimeSpan NonEssentialUiWorkInputQuietPeriod = TimeSpan.FromSeconds(2);
    private static readonly TimeSpan NonEssentialUiWorkInputPollCadence = TimeSpan.FromMilliseconds(250);
    private const int BridgeEventUiBatchLimit = 64;
    private const int BridgeEventUiBatchMaxMs = 8;
    private const int DelegationOutcomeRollupWindow = 8;
    private const int CoordinatorToolFailureTraceThreshold = 5;
    private const int CoordinatorToolFailureTraceEvery = 25;
    private const int DynamicAgentHistoryCardLimit = 6;
    private static readonly string[] ToolSpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
    private enum PromptTextChangedPhase
    {
        ReadText,
        EmptyUi,
        HistoryDraft,
        QueueDraftRefresh,
        InteractiveState,
        IntelliSense,
        Count,
    }

    private static readonly string[] PromptTextChangedPhaseNames =
    [
        "read",
        "empty",
        "history",
        "queue",
        "interactive",
        "intellisense",
    ];

    private static readonly AgentAccentPaletteOption[] AgentAccentPalette = [
        new("#FF4472C4"),
        new("#FF4F5FB8"),
        new("#FF7A4EB5"),
        new("#FFAD4F8C"),
        new("#FFB35852"),
        new("#FFA96E34"),
        new("#FF6E8140"),
        new("#FF3E7F97"),
        new("#FF111111"),
        new("#FF3A3A3A"),
        new("#FF6A6A6A"),
        new("#FF9A9A9A")
    ];
    private readonly SquadSdkProcess _bridge;
    private int _ignoreBridgeEventsThroughGeneration;
    private readonly ApplicationSettingsStore _settingsStore = new();
    private readonly SquadTeamRosterLoader _teamRosterLoader = new();
    private readonly SquadRoutingDocumentService _routingDocumentService = new();
    private readonly SquadInstallationStateService _installationStateService = new();
    private readonly SquadInstallerService _installerService = new();
    private readonly RunningInstanceRegistry _instanceRegistry = new();
    private readonly RestartCoordinatorStateStore _restartCoordinatorStateStore = new();
    private readonly WorkspaceOpenCoordinator _workspaceOpenCoordinator;
    private readonly InstanceActivationChannel _instanceActivationChannel;
    private PreferencesWindow? _preferencesWindow;
    private readonly PushNotificationService _pushNotificationService;
    internal SoundNotificationService SoundNotifications { get; private set; } = null!;
    private readonly ObservableCollection<AgentStatusCard> _agents = [];
    private readonly ObservableCollection<AgentStatusCard> _activeAgentCards = [];
    private readonly ObservableCollection<AgentStatusCard> _inactiveAgentCards = [];
    private readonly DispatcherTimer _historyHintTimer;
    private readonly DispatcherTimer _toolSpinnerTimer;
    private readonly DispatcherTimer _promptHealthTimer;
    private readonly DispatcherTimer _statusPresentationTimer;
    private readonly DispatcherTimer _responseRenderTimer;
    private readonly DispatcherTimer _uiResponsivenessTimer;
    private readonly Stopwatch _sdkDeltaTraceStopwatch = Stopwatch.StartNew();
    private DateTimeOffset _lastUiResponsivenessTick = DateTimeOffset.Now;
    private PromptExecutionController _pec = null!; // initialized in constructor after all services
    private LoopController _loopController = null!; // initialized in constructor after _pec
    private FileSystemWatcher? _inboxWatcher;
    private FileSystemWatcher? _teamFileWatcher;
    private FileSystemWatcher? _restartRequestWatcher;
    private readonly DispatcherTimer _teamRefreshDebounceTimer;
    private FileSystemWatcher? _docsWatcher;
    private FileSystemWatcher? _maintenanceMdWatcher;
    private CancellationTokenSource? _docsRefreshCts;
    private Point _docsDragStartPoint;
    private TreeViewItem? _docsDragItem;
    private bool _docsDragInProgress;
    private TreeViewItem? _dropInsideTarget;
    private TreeViewItem? _docsRenameItem;
    private TextBlock? _docsRenameOriginalTextBlock;
    private DateTime _docsRenameClickTime;
    private TreeViewItem? _docsRenameLastClickedItem;
    private bool _docsRenameIsFromAdd;
    private SessionWorkspace? _currentWorkspace;
    private readonly PastedImageStore _pastedImageStore = new();
    private SquadInstallationState? _currentInstallationState;
    private int _pendingThinkingDeltaTraceCount;
    private int _pendingThinkingDeltaTraceChars;
    private int _pendingResponseDeltaTraceCount;
    private int _pendingResponseDeltaTraceChars;
    private int _pendingSubagentViewToolTraceCount;
    private readonly Stopwatch _subagentViewToolTraceStopwatch = Stopwatch.StartNew();
    private SquadRoutingDocumentAssessment? _currentRoutingAssessment;
    private WorkspaceIssuePresentation? _startupIssue;
    private WorkspaceIssuePresentation? _runtimeIssue;
    private string? _dismissedWorkspaceIssueKey;
    private bool _installSquadButtonPressed;
    private string? _currentSolutionPath;
    private string? _currentSolutionName;
    private AgentStatusCard? _leadAgent;
    private bool _isApplyingIntelliSenseAccept;
    private IntelliSenseState? _intelliSenseState;
    private TextBox? _intelliSenseOwnerBox; // null = PromptTextBox; set when another box owns IntelliSense
    private bool _promptDraftStateRefreshPending;
    private readonly Stopwatch _promptInputTraceStopwatch = Stopwatch.StartNew();
    private int _promptTextChangedTraceCount;
    private long _promptTextChangedTraceElapsedMs;
    private long _promptTextChangedTraceMaxMs;
    private readonly long[] _promptTextChangedPhaseElapsedMs = new long[(int)PromptTextChangedPhase.Count];
    private readonly long[] _promptTextChangedPhaseMaxMs = new long[(int)PromptTextChangedPhase.Count];
    private int _promptKeyDownTraceCount;
    private long _promptKeyDownTraceElapsedMs;
    private long _promptKeyDownTraceMaxMs;
    private readonly Dictionary<DispatcherOperation, (long StartedAt, DispatcherPriority Priority)> _dispatcherOperationStarts = [];
    private bool _dispatcherDiagnosticsAttached;
    private string _lastLongDispatcherOperationSummary = "none";
    private long _promptRenderLatencyStartedAt;
    private int _promptRenderLatencyTextLen;
    private bool _promptRenderLatencyPending;
    private Dictionary<string, string> _agentHandleByDisplayName = new(StringComparer.OrdinalIgnoreCase);
    private string[] _agentDisplayNames = [];
    private string[] _tasksAgentSuggestions = [];
    private string[] _currentQuickReplyOptions = [];
    private DateTime _quickRepliesShownAt = DateTime.MinValue;
    private const int QuickReplyReadWindowMs = 400;
    private TranscriptResponseEntry? _lastQuickReplyEntry;
    private TranscriptResponseEntry? _routingIssueQuickReplyEntry;
    private string? _lastMissingUtilityAgentNoticeKey;
    private string? _pendingQuickReplyRoutingInstruction;
    private PendingQuickReplyLaunchState? _pendingQuickReplyLaunch;
    private readonly HashSet<string> _directQuickReplyAgentThreadIds = new(StringComparer.OrdinalIgnoreCase);
    private readonly Dictionary<string, string> _shellDescriptions = new(StringComparer.OrdinalIgnoreCase);
    private string? _pendingSupplementalPromptInstruction;
    private string? _announcedRoutingIssueFingerprint;
    private bool _pendingRoutingRepairRecheck;
    private bool _pendingPowerShellInstallRecheck;
    private TasksStatusWindow?      _tasksStatusWindow;
    private TraceWindow?            _traceWindow;
    private LoopMergedViewWindow?   _loopMergedViewWindow;
    private DispatcherTimer?        _loopPreviewFilterDebounce;
    private ScreenshotHealthWindow? _screenshotHealthWindow;
    // Offset (floating window Left/Top minus main window Right/Top) last set by the user
    // dragging the floating window. Null means "use default snap position".
    private Vector? _tasksWindowOffset;
    private Vector? _traceWindowOffset;
    private Vector? _screenshotHealthWindowOffset;
    private CommitApprovalPanel? _approvalPanel;
    private TasksPanelController? _tasksPanelController;
    private CommitApprovalStore? _approvalStore;
    private List<CommitApprovalItem> _approvalItems = [];
    private System.Windows.Controls.Primitives.Popup? _approvalNotFoundPopup;
    private NotesStore? _notesStore;
    private NotesPanelController? _notesPanel;
    private List<NoteItem> _noteItems = [];
    private MaintenancePanelController? _maintenancePanel;
    private IdleDetectionService?       _idleDetectionService;
    private MaintenanceRunner?          _maintenanceRunner;
    private MaintenanceStateStore?      _maintenanceStateStore;
    private MaintenanceReportWriter?    _maintenanceReportWriter;
    private MaintenanceReport?          _pendingMaintenanceBannerReport;
    private DispatcherTimer?            _maintenanceBannerTimer;
    // Set true while we are programmatically moving a floating window so its
    // LocationChanged does not overwrite the saved offset.
    private bool _movingFloatingWindow;
    private bool _isInstallingSquad;
    private bool _isClosing;
    private bool _mainWindowClosingInProgress; // set at the very start of Closing, before ShowDialog
    private bool _isPromptRunning;
    private bool _queueDrainActive;  // true while an auto-dispatched queue item is executing
    private bool _pendingPromptIsSystemInjected; // set for silent-completion follow-up prompts; consumed by CreateTranscriptTurnView
    private bool _bridgeRestartForSettingsPending;
    private readonly PromptQueue _promptQueue = new();
    private bool _queueManuallyPaused;
    private bool _queuePausePending;
    private bool _bridgeStallShowing;
    private int _promptQueueSeq;
    private readonly HostCommandRegistry _hostCommandRegistry = new();
    private HostCommandExecutor? _hostCommandExecutor;
    private string? _queuePreEditDraft;
    private int _queuePreEditDraftCaretIndex;
    private int _queuePreEditDraftSelectionStart;
    private int _queuePreEditDraftSelectionLength;
    private string? _activeTabId;   // null = Active Draft; otherwise a queued item Id
    private string? _priorityFeedbackId;        // Id of the recently-prioritized queue item
    private DispatcherTimer? _priorityFeedbackTimer;

    // ── Prompt shortcuts hint ────────────────────────────────────────────────
    private static readonly TimeSpan HintCooldown = TimeSpan.FromMinutes(10);
    private readonly Dictionary<PromptHintFeature, DateTime> _promptHintLastUsed = new();
    private readonly HashSet<PromptHintFeature> _promptHintPermanentlyDismissed = new();
    private DispatcherTimer? _hintRefreshTimer;
    private readonly Dictionary<string, List<FollowUpAttachment>> _followUpAttachments = new();
    // Captured by ApplyFollowUpHeader; consumed by CreateTranscriptTurnView for the paperclip UI.
    private IReadOnlyList<FollowUpAttachment>? _pendingTranscriptAttachments;

    // ── Queue tab drag-to-reorder ────────────────────────────────────────────
    private string? _dragTabId;               // Id of the tab currently being dragged
    private Point _dragStartPoint;          // Mouse-down position in QueueTabStrip coords
    private bool _isDragging;              // True once movement exceeds DragThreshold
    private string? _dragInsertBeforeTabId;   // Id of tab to drop before (visually); null = rightmost
    private Border? _dropIndicator;           // Narrow vertical bar shown between tabs during drag
    private const double DragThreshold = 4.0; // px of movement before drag mode activates
    private bool _restartPending;
    private bool _clipboardEditorOpen; // true while any ClipboardImageEditorWindow is open; defers restart
    private int  _clipboardEditorCount; // number of ClipboardImageEditorWindows currently open
    private bool _pendingShutdown;     // true when a close was requested while ClipboardImageEditorWindow was open
    private bool _programmaticExpanderChange;
    private DeferredShutdownMode _deferredShutdown;
    private bool _transcriptFullScreenEnabled;
    private bool _fullScreenPromptVisible;
    private bool _promptPanelOnTop;
    private WindowState _preFullScreenWindowState;
    private Rect _preFullScreenBounds;
    private bool _agentsPanelFocusModeEnabled;
    private WindowState _preFocusModeWindowState;
    private double _preFocusModeHeight;
    private double _preFocusModeTop;
    private bool _documentationModeEnabled;
    private string? _currentDocPath;          // tracks currently displayed doc for link resolution
    private string  _currentDocFrontMatter = string.Empty;  // Jekyll/JTD YAML block stripped from source editor; prepended on save
    private DateTime _docSaveSuppressionUntil;
    private string? _lastSavedDocPath;   // path written by SaveDocSourceToDisk; used to suppress watcher-triggered approval revocation
    private DocStatusStore? _docStatusStore;
    private bool _activeAgentLaneNudgeScheduled;
    private bool _inactiveAgentLaneNudgeScheduled;
    private int _toolSpinnerFrame;
    private double _transcriptFontSize = (double)Application.Current.Resources["FontSizeMedium"];
    private double _promptFontSize = (double)Application.Current.Resources["FontSizeMedium"];
    private double _docSourceFontSize = (double)Application.Current.Resources["FontSizeBody"];
    private int _fontScaleLevel = 2; // Normal (1.0×)
    private double _docPreviewScrollY;
    private readonly List<Image> _toolIconImages = [];
    private readonly HashSet<TranscriptResponseEntry> _pendingResponseEntryRenders = [];
    private readonly HashSet<TranscriptThoughtEntry> _pendingThoughtEntryRenders = [];
    private readonly object _bridgeEventQueueGate = new();
    private readonly Queue<SquadSdkEvent> _pendingBridgeEvents = new();
    private bool _bridgeEventDispatchPending;
    private DateTimeOffset _lastKeyboardInputAt = DateTimeOffset.MinValue;
    private readonly PostedUiActionTracker _postedUiActionTracker = new();
    private readonly UiActionReplayRegistry _uiActionReplayRegistry = new();
    private readonly FixtureLoaderRegistry _fixtureLoaderRegistry = new();
    private Screenshots.ScreenshotDefinitionRegistry? _cachedDefinitionRegistry;
    public  Screenshots.ScreenshotHealthChecker ScreenshotHealthChecker { get; private set; } = null!;
    private readonly Queue<DelegationOutcomeTelemetry> _recentDelegationOutcomes = new();
    private readonly Dictionary<string, CoordinatorToolFailureTraceState> _coordinatorToolFailureTraces =
        new(StringComparer.Ordinal);
    // _activeToolName moved to PromptExecutionController.ActiveToolName
    private AgentThreadRegistry _agentThreadRegistry = null!;
    private BackgroundTaskPresenter _backgroundTaskPresenter = null!;
    private TranscriptConversationManager _conversationManager = null!;
    private MarkdownDocumentRenderer _markdownRenderer = null!;
    private TranscriptScrollController _coordinatorScrollController = null!;
    private bool _modelObservedThisSession;
    private readonly Queue<(string Text, Brush? Brush)> _deferredSystemLines = new();
    private string? _currentSessionState;
    // _clearConfirmationPending, _universeSelectionPending moved to PromptExecutionController
    private readonly SquadCliAdapter _squadCliAdapter;
    private readonly IWorkspacePaths _workspacePaths;
    private readonly ScreenshotRefreshOptions _screenshotRefreshOptions;
    private string? _lastHandledRestartRequestId;
    private TranscriptThreadState? _coordinatorThread;
    private TranscriptThreadState? _selectedTranscriptThread;
    private UiExceptionPanelState? _activeUiException;
    private const int PrimaryAgentLiveHostLimit = 4;
    private readonly Dictionary<TranscriptThreadState, PrimaryTranscriptHostEntry> _primaryAgentTranscriptHosts =
        new(ReferenceEqualityComparer.Instance);
    private readonly List<TranscriptThreadState> _primaryAgentHostMru = [];
    private readonly Dictionary<TranscriptThreadState, RichTextBox> _bulkChangeTranscriptBoxes =
        new(ReferenceEqualityComparer.Instance);
    private readonly Dictionary<TranscriptThreadState, TranscriptSnapshot> _transcriptSnapshots =
        new(ReferenceEqualityComparer.Instance);
    private TranscriptThreadState? _snapshotThread;
    private long _lastTranscriptLayoutDuringInputTraceAt;
    private long _lastTranscriptSnapshotCaptureAt;
    private string _lastTranscriptSnapshotCaptureSummary = "none";
    private bool _primaryAgentWarmupPending;
    private long _deferredPrimaryTranscriptSelectionVersion;
    private TranscriptThreadState? _pendingPrimaryTranscriptVisualThread;

    // ── Active transcript helpers ────────────────────────────────────────────────

    /// <summary>
    /// The scroll controller for whichever transcript is currently displayed.
    /// Coordinator uses <c>OutputTextBox</c>; agents use their cached main host.
    /// </summary>
    private TranscriptScrollController ActiveScrollController =>
        (_selectedTranscriptThread?.Kind ?? TranscriptThreadKind.Coordinator) == TranscriptThreadKind.Coordinator
            ? _coordinatorScrollController
            : GetOrCreatePrimaryAgentTranscriptHost(_selectedTranscriptThread!).ScrollController;

    /// <summary>
    /// The RichTextBox that is currently visible in the main transcript panel.
    /// </summary>
    private RichTextBox ActiveTranscriptBox =>
        (_selectedTranscriptThread?.Kind ?? TranscriptThreadKind.Coordinator) == TranscriptThreadKind.Coordinator
            ? OutputTextBox
            : GetOrCreatePrimaryAgentTranscriptHost(_selectedTranscriptThread!).TranscriptBox;

    // ── Transcript search state ─────────────────────────────────────────────────
    private IReadOnlyList<TurnSearchMatch> _searchMatches = [];
    private int _searchMatchCursor = -1;
    private CancellationTokenSource? _searchCts;
    private DispatcherTimer? _searchDebounceTimer;
    private SearchHighlightAdorner? _searchAdorner;
    private ScrollbarMarkerAdorner? _scrollbarAdorner;
    // Pointer cache — built on first RefreshAdornerHighlights after a search, reused on Next/Prev.
    private List<(TextPointer Start, TextPointer End, string Text)>? _cachedSearchPointers;
    // Set true while navigating to a match in a different thread to suppress search-state clear.
    private bool _searchNavigating;
    private int[] _cachedMatchToCursor = [];  // match i → index in _cachedSearchPointers, -1 if BUC/skip
    private TextPointer?[] _cachedMatchScrollPointer = [];  // match i → pointer to scroll to
    private TextBlock?[] _cachedMatchBucCell = [];  // match i → BUC table cell, null if not a BUC match
    // TextBlocks inside table cells that currently carry a search-highlight background.
    private readonly HashSet<TextBlock> _bucHighlightedCells = [];
    private ScrollBar? _transcriptScrollBar;
    private string? _lastAgentImageFolder;
    private ScrollViewer? _transcriptScrollViewer;

    // ── Doc source find-in-source bar state ────────────────────────────────────
    private Border? _docSourceFindBar;
    private TextBox? _docSourceFindTextBox;
    private TextBlock? _docSourceFindMatchCount;
    private Canvas? _docSourceFindOverlay;
    private DispatcherTimer? _docSourceFindDebounceTimer;
    private List<int> _docSourceFindMatches = [];
    private int _docSourceFindCurrentIndex = -1;
    private Canvas? _docSourceOverlayCanvas;     // persistent overlay canvas for highlights
    private Shapes.Rectangle? _docSourceHoverHighlight;
    private DispatcherTimer? _docSourceHoverTimer;
    private int _loopCurrentIteration;
    private DateTimeOffset _loopNextIterationAt;
    private bool _loopIsWaiting;
    private bool _loopPanelVisible = true;
    private LoopOutputWindow? _loopOutputWindow;
    private bool _loopQueued;
    private LoopMode _activeLoopMode = LoopMode.NativeAgents; // set at loop start; Shift+click overrides to SquadCli
    private bool _loopInterruptedByQueue; // set when user enqueues a prompt while native loop is running
    private bool _loopPausedForQuickReply; // set at startup when loop resume is held for pending quick replies
    private int _activeTintStop;                       // 0 = natural; 1–7 = hue offsets at 45° steps
    private int _activeAccentHueOffset;               // extra hue rotation for ActiveAccent keys, in degrees
    private Dictionary<string, Color>? _tintBaseline; // baseline theme colors per TintKeys.All, refreshed on theme switch
    private ResourceDictionary? _themeDict;            // the currently-loaded theme ResourceDictionary (cached to avoid URI-search fragility)
    // Held while a loop iteration is waiting for user follow-up after quick replies.
    // Completed (true) when input arrives; completed (false) on abort.
    private TaskCompletionSource<bool>? _loopFollowUpTcs;
    private bool _startupShiftHeld;       // set in MainWindow_Loaded when Shift is down; suppresses auto-resume
    private string? _loopMdPathForConfig; // stored when loop config flyout is shown
    private LoopConfigFlyoutMode _loopConfigFlyoutMode = LoopConfigFlyoutMode.Configure;
    private string? _selectedLoopMdPath;  // null = use loop.md (default)
    private IReadOnlyList<LoopFileEntry> _loopFileEntries = Array.Empty<LoopFileEntry>();
    private bool _suppressLoopPickerChange;
    private bool _tasksPanelVisible = false;
    private bool _approvalPanelVisible = false;
    private bool _notesPanelVisible = false;
    private bool _maintenancePanelVisible = false;
    private string? _watchCycleId;
    private int _watchFleetSize;
    private int _watchWaveIndex;
    private int _watchWaveCount;
    private int _watchAgentCount;
    private string? _watchPhase;
    private bool _remoteAccessActive;
    private bool _rcRegeneratingToken;
    private bool _pendingRcRestartAfterReset;
    private int _rcActivePort;
    private string? _rcPanelUrl;
    private string? _rcTunnelUrl;
    private RcStatusPanel? _rcPanel;
    private MenuItem? _remoteAccessMenuItem;
    private readonly System.Collections.Concurrent.ConcurrentDictionary<string, RemoteSpeechSession> _remoteSpeechSessions = new();
    private ApplicationSettingsSnapshot _settingsSnapshot = ApplicationSettingsSnapshot.Empty;
    private string _activeThemeName = "Light";
    private string _themePreference = "Auto"; // "Auto", "Light", or "Dark"
    private readonly long _processStartedAtUtcTicks = ProcessIdentity.GetCurrentProcessStartedAtUtcTicks();
    private readonly string? _startupFolderArgument;
    private readonly bool _noWorkspaceOnStart;
    private WorkspaceOwnershipLease? _startupWorkspaceLease;
    private WorkspaceOwnershipLease? _workspaceOwnershipLease;
    private bool _startupInitialized;
    private (string FolderPath, WorkspaceWindowPlacement Placement)? _pendingWindowPlacement;
    private (bool TasksOpen, bool TraceOpen)? _pendingUtilityWindowState;
    private (bool Open, List<string>? ExpandedNodes, string? SelectedTopic, double? DocsPanelWidth, double? DocsTopicsWidth, double? DocsPanelWidthFraction, double? DocsTopicsWidthFraction, bool? DocsSourceOpen, double? DocsSourceWidth)? _pendingDocsPanelState;
    private WorkspaceDocsPanelState? _docsPanelState; // loaded at startup, updated on save
    private bool _docSourceLayoutTopBottom; // false = side-by-side (default), true = top-bottom
    // _currentPromptStartedAt, _lastPromptActivityAt, _promptNoActivityWarningShown,
    // _promptStallWarningShown moved to PromptExecutionController

    private TranscriptThreadState CoordinatorThread => _coordinatorThread ??= CreateCoordinatorTranscriptThread();
    private bool IsLoopRunning => _pec is { IsLoopRunning: true };
    private bool IsNativeLoopRunning => IsLoopRunning && _activeLoopMode == LoopMode.NativeAgents;
    private TranscriptTurnView? _currentTurn
    {
        get => CoordinatorThread.CurrentTurn;
        set => CoordinatorThread.CurrentTurn = value;
    }

    private sealed class SecondaryTranscriptEntry
    {
        public AgentStatusCard Agent { get; set; } = null!;
        public TranscriptThreadState Thread { get; init; } = null!;
        public RichTextBox TranscriptBox { get; init; } = null!;
        public TextBlock TitleBlock { get; init; } = null!;
        public Button NavUpButton { get; init; } = null!;
        public Button NavDownButton { get; init; } = null!;
        public Button CloseButton { get; init; } = null!;
        public Border PanelBorder { get; init; } = null!;
        public Grid ContentGrid { get; init; } = null!;
        public TranscriptScrollController ScrollController { get; init; } = null!;
        public DispatcherTimer? CountdownTimer { get; set; }
        public int CountdownSecondsRemaining { get; set; }
        public TextBlock? CountdownOverlay { get; set; }
        public bool IsAutoOpenedInMultiMode { get; set; }
        public bool CountdownCancelled { get; set; }
        public bool CountdownStarted { get; set; }
        public DispatcherTimer? PostponeTimer { get; set; }
    }

    private sealed class PrimaryTranscriptHostEntry
    {
        public TranscriptThreadState Thread { get; init; } = null!;
        public RichTextBox TranscriptBox { get; init; } = null!;
        public TranscriptScrollController ScrollController { get; init; } = null!;
    }

    private sealed record TranscriptSnapshot(
        ImageSource Source,
        double LogicalWidth,
        double LogicalHeight,
        int PixelWidth,
        int PixelHeight,
        string ThemeName);

    private sealed record TranscriptViewportAnchor(
        TranscriptThreadState Thread,
        TextPointer Pointer,
        double ViewportY,
        double PreviousVerticalOffset,
        double PreviousViewportHeight,
        double PreviousWidth);

    private readonly List<SecondaryTranscriptEntry> _secondaryTranscripts = new();
    private DispatcherTimer? _transcriptTitleRefreshTimer;
    private DispatcherTimer? _completedTimeFooterTimer;
    private TranscriptSelectionController _selectionController = null!; // initialized in constructor
    private HashSet<AgentStatusCard> _prevActiveAgentCards = new();
    private bool _mainTranscriptVisible = true;
    private bool _gridRebuildPending;
    private TranscriptViewportAnchor? _pendingGridRebuildViewportAnchor;

    // Push-to-talk state
    private enum PttState { Idle, TapDown, TapReleased, Active }
    private PttState _pttState = PttState.Idle;
    private bool _pttDraining; // true while speech service is draining after PTT release
    private bool _promptHasVoiceInput;
    private bool _suppressPromptNextTextInput;

    // ── Shift+F3 case-cycle state (PromptTextBox) ────────────────────────────────
    private string?       _promptCycleOriginal;   // full TextBox.Text before first cycle press
    private List<string>? _promptCycleVariants;   // [TitleCase, SentenceCase, UPPER, PascalCase]
    private int           _promptCycleIndex;      // index of the variant currently shown
    private int           _promptCycleSelStart;   // SelectionStart when cycle began
    private int           _promptCycleSelLen;     // original selection length (before any variant)
    private bool _pttHadPreexistingText;
    private bool _pttShiftTappedDuringRecording;
    private bool _voiceStartedWithSendEnabled;
    private bool _dictationStartedForQuickReply; // set at PTT start when quick replies visible and box empty
    private bool _pttLostFocusDuringRecording;   // set when another window stole focus mid-PTT
    private DispatcherTimer? _pttCtrlPollTimer;   // polls GetAsyncKeyState while window is inactive
    private DateTime _ctrlFirstDownTime;
    private DateTime _ctrlFirstReleaseTime;
    private ISpeechRecognitionService? _speechService;
    private PushToTalkWindow? _pttWindow;
    private TextBox? _pttTargetTextBox;       // resolved at activation; null = PromptTextBox
    private RichTextBox? _pttTargetRichTextBox;  // set when a RichTextBox (e.g. DocSourceTextBox) has focus at PTT activation
    private readonly object _pttVolumeUpdateGate = new();
    private bool _pttVolumeUpdateQueued;
    private double _pendingPttVolumeHeight;
    private int _pttVolumeEventTraceCount;
    private int _pttVolumeUiUpdateTraceCount;
    private readonly Stopwatch _pttVolumeTraceStopwatch = Stopwatch.StartNew();
    private int _sessionCaretIndex;       // caret captured before PTT panel becomes visible
    private int _sessionSelectionLength;  // selection length captured before PTT panel becomes visible
    private DispatcherTimer? _promptNavHintTimer;
    private InteractiveControlState? _lastInteractiveControlState;
    private bool _lastCanAbortBackgroundTask;
    private string? _workspaceGitHubUrl;
    private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(5) };
    private const int PttMaxTapHoldMs = 250;
    const int PttDoubleClickTime = 350;

    private sealed record UiExceptionPanelState(
        string Title,
        string Summary,
        string Details);

    internal MainWindow(string? startupFolder = null, WorkspaceOwnershipLease? startupWorkspaceLease = null, IWorkspacePaths? workspacePaths = null, ScreenshotRefreshOptions? screenshotRefreshOptions = null, bool noWorkspaceOnStart = false)
    {
        _workspacePaths = workspacePaths ?? WorkspacePathsProvider.Discover();
        _screenshotRefreshOptions = screenshotRefreshOptions ?? ScreenshotRefreshOptions.None;
        var ctorSw = Stopwatch.StartNew();
        SquadDashTrace.Write(TraceCategory.Startup, "Constructor: begin.");
        _bridge = new SquadSdkProcess(_workspacePaths);
        _bridge.ByokProviderSettings = BuildByokSettingsFromStore();
        _startupFolderArgument = startupFolder;
        _startupWorkspaceLease = startupWorkspaceLease;
        _noWorkspaceOnStart    = noWorkspaceOnStart;
        _squadCliAdapter = new SquadCliAdapter(_workspacePaths, (op, ex) => HandleUiCallbackException(op, ex));
        _workspaceOpenCoordinator = new WorkspaceOpenCoordinator(_instanceRegistry);
        _pushNotificationService = new PushNotificationService(_settingsStore);
        SoundNotifications = new SoundNotificationService(_settingsStore, () => BuildTtsProvider(_settingsSnapshot));
        InitializeComponent();
        SquadDashTrace.Write(TraceCategory.Startup, $"Constructor: InitializeComponent {ctorSw.ElapsedMilliseconds}ms.");
        SquadDashTrace.Write(
            TraceCategory.Startup,
            $"UI_THREAD_ID nativeThreadId={NativeMethods.GetCurrentNativeThreadId()} managedThreadId={Environment.CurrentManagedThreadId}");
        AttachTranscriptBoxRenderDiagnostics(OutputTextBox, "coordinator");
        _coordinatorScrollController = new TranscriptScrollController(OutputTextBox, Dispatcher);
        _coordinatorScrollController.SetScrollToBottomButton(ScrollToBottomButton);
        _agentThreadRegistry = new AgentThreadRegistry(
            beginTranscriptTurn: (thread, prompt) => BeginTranscriptTurn(thread, prompt),
            finalizeCurrentTurnResponse: thread => FinalizeCurrentTurnResponse(thread),
            collapseCurrentTurnThinking: thread => CollapseCurrentTurnThinking(thread),
            renderToolEntry: entry => RenderToolEntry(entry),
            updateToolSpinnerState: () => UpdateToolSpinnerState(),
            syncActiveToolName: () => SyncActiveToolName(),
            syncThreadChip: thread => SyncThreadChip(thread),
            syncTaskToolTranscriptLink: thread => SyncTaskToolTranscriptLink(thread),
            appendText: (thread, text) => AppendText(thread, text),
            syncAgentCards: () => RefreshAgentCards(),
            syncAgentCardsWithThreads: () => SyncAgentCardsWithThreads(),
            getKnownTeamAgentDescriptors: () => GetKnownTeamAgentDescriptors(),
            updateTranscriptThreadBadge: () => UpdateTranscriptThreadBadge(),
            isThreadActiveForDisplay: thread => _backgroundTaskPresenter.IsThreadActiveForDisplay(thread),
            observeBackgroundAgentActivity: (thread, reason) => _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, reason),
            renderConversationHistory: (thread, turns) => _conversationManager.RenderConversationHistoryAsync(thread, turns),
            resolveBackgroundAgentDisplayLabel: agent => _backgroundTaskPresenter.ResolveBackgroundAgentDisplayLabel(agent),
            buildAgentLabel: thread => BackgroundTaskPresenter.BuildBackgroundAgentLabel(thread));
        _backgroundTaskPresenter = new BackgroundTaskPresenter(
            agentThreadRegistry: _agentThreadRegistry,
            appendLine: (text, brush) => AppendLine(text, brush),
            syncAgentCards: () => SyncAgentCardsWithThreads(),
            isPromptRunning: () => _isPromptRunning,
            currentTurn: () => _currentTurn,
            themeBrush: key => ThemeBrush(key),
            tryPostToUi: (action, source) => TryPostToUi(action, source),
            isClosing: () => _isClosing,
            updateLeadAgent: (status, bubble, detail) => UpdateLeadAgent(status, bubble, detail),
            updateSessionState: state => UpdateSessionState(state),
            persistAgentThreadSnapshot: thread => _conversationManager.PersistAgentThreadSnapshot(thread),
            currentTurnSnapshot: () => new CurrentTurnStatusSnapshot(
                                                  _isPromptRunning,
                                                  _pec.PromptNoActivityWarningShown,
                                                  _pec.PromptStallWarningShown,
                                                  _pec.CurrentPromptStartedAt,
                                                  _pec.LastPromptActivityAt,
                                                  _pec.LastPromptActivityName,
                                                  _pec.SessionReadyAt,
                                                  _pec.FirstToolAt,
                                                  _pec.FirstResponseAt,
                                                  _pec.LastResponseAt,
                                                  _pec.ResponseDeltaCount,
                                                  _pec.ResponseCharacterCount,
                                                  _pec.LongestResponseGap,
                                                  _pec.AverageResponseGap,
                                                  _pec.FirstThinkingTextAt,
                                                  _pec.LastThinkingTextAt,
                                                  _pec.ThinkingDeltaCount,
                                                  _pec.ThinkingTextDeltaCount,
                                                  _pec.ThinkingCharacterCount,
                                                  _pec.LongestThinkingGap,
                                                  _pec.AverageThinkingGap,
                                                  _pec.ToolStartCount,
                                                  _pec.ToolCompleteCount),
            agentActiveDisplayLinger: AgentActiveDisplayLinger,
            dynamicAgentHistoryRetention: DynamicAgentHistoryRetention,
            appendAgentReport: (agentLabel, header, body) => AppendAgentReport(agentLabel, header, body));
        _conversationManager = new TranscriptConversationManager(
            getWorkspace: () => _currentWorkspace,
            getPromptText: () => PromptTextBox.Text,
            setPromptText: (text, caretIndex, selectionStart, selectionLength) =>
            {
                SetPromptTextBoxLogicalBuffer(text, caretIndex, selectionStart, selectionLength, "conversation-apply");
            },
            getPromptCaretState: () => (PromptTextBox.CaretIndex, PromptTextBox.SelectionStart, PromptTextBox.SelectionLength),
            isClosing: () => _isClosing,
            renderPersistedTurn: (thread, turn, isLast) => RenderPersistedTurn(thread, turn, isLast),
            coordinatorThread: () => CoordinatorThread,
            selectedThread: () => _selectedTranscriptThread,
            maybePublishRoutingIssue: reason => MaybePublishRoutingIssueSystemEntry(reason),
            syncAgentCardsWithThreads: () => SyncAgentCardsWithThreads(),
            dispatcher: Dispatcher,
            scrollOutputToEnd: thread => ScrollTranscriptToEndAfterRender(thread),
            agentThreadRegistry: _agentThreadRegistry,
            getToolEntries: () => _agentThreadRegistry.ToolEntries,
            getCurrentTurn: () => _currentTurn,
            setCurrentTurnNull: () => {
                SquadDashTrace.Write("Persistence", $"Coordinator CurrentTurn cleared reason=conversation-manager currentTurnWasPresent={_currentTurn is not null}");
                _currentTurn = null;
            },
            // Bracket bulk history-load in a RichTextBox BeginChange/EndChange pair.
            // This prevents the WPF TextEditor from issuing a layout pass after every
            // Blocks.Add call; instead exactly one layout pass fires when EndChange() is
            // called, collapsing N layout invalidations into one.
            beginBulkDocumentLoad: thread => BeginBulkTranscriptDocumentLoad(thread),
            endBulkDocumentLoad: thread => EndBulkTranscriptDocumentLoad(thread),
            prependTurnsBatch: (thread, turns) => PrependPersistedTurnsBatch(thread, turns),
            getScrollableHeight: () => _coordinatorScrollController.GetScrollableHeight(),
            getVerticalOffset: () => _coordinatorScrollController.GetVerticalOffset(),
            scrollToAbsoluteOffset: target => _coordinatorScrollController.ScrollToAbsoluteOffset(target),
            updateScrollLayout: () => OutputTextBox.UpdateLayout(),
            getDraftAttachments: () => _followUpAttachments.TryGetValue("", out var list)
                ? list.AsReadOnly()
                : (IReadOnlyList<FollowUpAttachment>)Array.Empty<FollowUpAttachment>(),
            applyDraftAttachments: attachments => {
                if (attachments.Count == 0)
                    _followUpAttachments.Remove("");
                else
                    _followUpAttachments[""] = new List<FollowUpAttachment>(attachments);
                UpdateFollowUpStrip();
            });
        // Wire the near-top prepend trigger: when the user scrolls within 400 px of the
        // top of the coordinator transcript, TranscriptScrollController calls this to
        // load the next batch of older turns from the virtual window.
        _coordinatorScrollController.RequestPrependOlderTurns =
            () => _ = _conversationManager.PrependOlderTurnsAsync();
        _instanceActivationChannel = new InstanceActivationChannel(
            _workspacePaths.ApplicationRoot,
            Environment.ProcessId,
            _processStartedAtUtcTicks,
            () => TryPostToUi(ActivateOwnedWindow, "InstanceActivation.Request"),
            ex => SquadDashTrace.Write("Workspace", $"Activation listener failed: {ex.Message}"));
        _instanceActivationChannel.Start();
        SquadDashTrace.Write(TraceCategory.Startup, $"Constructor: InstanceActivationChannel started {ctorSw.ElapsedMilliseconds}ms.");

        ActiveAgentItemsControl.ItemsSource = _activeAgentCards;
        InactiveAgentItemsControl.ItemsSource = _inactiveAgentCards;
        _activeAgentCards.CollectionChanged += (_, _) =>
        {
            try { HandleActiveAgentCountdownCheck(); }
            catch (Exception ex) { HandleUiCallbackException("_activeAgentCards.CollectionChanged", ex); }
        };
        _selectionController = new TranscriptSelectionController(_agents);
        _selectionController.OpenPanelRequested += (card, thread, isAuto) =>
            QueueDeferredTranscriptPanelOperation(
                $"open card={card.Name} thread={thread.ThreadId}",
                () => OpenSecondaryPanel(card, thread, isAutoOpenedInMultiMode: isAuto));
        _selectionController.ClosePanelRequested += (card, thread) =>
        {
            QueueDeferredTranscriptPanelOperation(
                $"close card={card.Name} thread={thread.ThreadId}",
                () =>
                {
                    var entry = _secondaryTranscripts.FirstOrDefault(e => e.Agent == card && e.Thread == thread);
                    if (entry is not null) CloseSecondaryPanel(entry);
                });
        };
        _selectionController.ShowMainRequested += () =>
        {
            QueueDeferredTranscriptPanelOperation(
                "show-main coordinator",
                () =>
                {
                    ShowMainTranscript();
                    SelectTranscriptThread(CoordinatorThread);
                });
        };
        _selectionController.HideMainRequested += () =>
            QueueDeferredTranscriptPanelOperation("hide-main", HideMainTranscript);
        StatusAgentPanelsGrid.SizeChanged += (_, e) =>
        {
            try
            {
                UpdateAgentPanelWidths();
                SquadDashTrace.Write("AgentCards",
                    $"SizeChanged: H {e.PreviousSize.Height:F0} → {e.NewSize.Height:F0} " +
                    $"W {e.PreviousSize.Width:F0} → {e.NewSize.Width:F0} | " +
                    $"StackTrace caller: {new System.Diagnostics.StackTrace(1, false).GetFrame(0)?.GetMethod()?.Name ?? "?"}");
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("StatusAgentPanelsGrid.SizeChanged", ex);
            }
        };

        OutputTextBox.Document = CoordinatorThread.Document;
        OutputTextBox.CommandBindings.Add(new CommandBinding(
            System.Windows.Input.ApplicationCommands.Copy,
            OutputTextBox_CopyExecuted,
            OutputTextBox_CopyCanExecute));
        ApplyTranscriptFontSize();
        ApplyPromptFontSize();
        SelectTranscriptThread(CoordinatorThread);

        _historyHintTimer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(5)
        };
        _historyHintTimer.Tick += (_, _) =>
        {
            try
            {
                HideHistoryHint();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("HistoryHintTimer.Tick", ex);
            }
        };

        _hintRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(1) };
        _hintRefreshTimer.Tick += (_, _) =>
        {
            try { BuildShortcutsHint(); }
            catch (Exception ex) { HandleUiCallbackException("HintRefreshTimer.Tick", ex); }
        };
        _hintRefreshTimer.Start();

        _toolSpinnerTimer = new DispatcherTimer
        {
            Interval = TimeSpan.FromMilliseconds(150)
        };
        _toolSpinnerTimer.Tick += (_, _) =>
        {
            try
            {
                AdvanceToolSpinner();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("ToolSpinnerTimer.Tick", ex);
            }
        };

        _promptHealthTimer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(5)
        };
        // Tick handler wired by PromptExecutionController

        _statusPresentationTimer = new DispatcherTimer
        {
            Interval = TimeSpan.FromSeconds(1)
        };
        _statusPresentationTimer.Tick += (_, _) =>
        {
            try
            {
                RefreshStatusPresentation();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("StatusPresentationTimer.Tick", ex);
            }
        };
        _statusPresentationTimer.Start();

        _responseRenderTimer = new DispatcherTimer(DispatcherPriority.ContextIdle, Dispatcher)
        {
            Interval = ResponseRenderCadence
        };
        _responseRenderTimer.Tick += (_, _) =>
        {
            try
            {
                FlushPendingResponseEntryRenders();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("ResponseRenderTimer.Tick", ex);
            }
        };

        _teamRefreshDebounceTimer = new DispatcherTimer
        {
            Interval = TimeSpan.FromMilliseconds(350)
        };
        _teamRefreshDebounceTimer.Tick += TeamRefreshDebounceTimer_Tick;

        _bridge.EventReceived += (_, evt) => QueueBridgeEventForUi(evt);
        _bridge.ErrorReceived += (_, text) => TryPostToUi(() => HandleBridgeError(text), "Bridge.ErrorReceived");
        InputManager.Current.PreProcessInput += MainWindow_PreProcessInput;
        AttachDispatcherDiagnostics();

        _uiResponsivenessTimer = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
        {
            Interval = TimeSpan.FromMilliseconds(500)
        };
        _uiResponsivenessTimer.Tick += (_, _) =>
        {
            var now = DateTimeOffset.Now;
            var elapsedMs = (now - _lastUiResponsivenessTick).TotalMilliseconds;
            _lastUiResponsivenessTick = now;
            if (elapsedMs >= UiResponsivenessLagTraceMs)
            {
                SquadDashTrace.Write(
                    TraceCategory.UI,
                    $"UI_THREAD_LAG elapsedMs={elapsedMs:0} promptFocused={PromptTextBox?.IsKeyboardFocusWithin == true} " +
                    $"textLen={PromptTextBox?.Text.Length ?? 0} postedPending={_postedUiActionTracker.PendingCount} " +
                    $"promptRenderPendingMs={GetPromptRenderPendingMs()} uiNativeThreadId={NativeMethods.GetCurrentNativeThreadId()} " +
                    $"pttState={_pttState} pttDraining={_pttDraining} pttVolumeQueued={_pttVolumeUpdateQueued} " +
                    $"lastLongDispatcherOp={_lastLongDispatcherOperationSummary}");
            }
        };
        _uiResponsivenessTimer.Start();

        UpdateStatusTitle();
        UpdateLeadAgent("Ready", string.Empty, string.Empty);
        UpdateSessionState("Ready");

        Closing += MainWindow_Closing;
        Closed += MainWindow_Closed;
        Loaded += MainWindow_Loaded;
        ContentRendered += MainWindow_ContentRendered;
        Activated += MainWindow_Activated;
        Microsoft.Win32.SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
        Microsoft.Win32.SystemEvents.PowerModeChanged += OnPowerModeChanged;
        LocationChanged += (_, _) =>
        {
            try
            {
                OnMainWindowMoved();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("Window.LocationChanged", ex);
            }
        };
        SizeChanged += (_, e) =>
        {
            try
            {
                OnMainWindowMoved();
                UpdateAgentCardImageVisibility(e.NewSize.Height);
                UpdateRosterHeightCap();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("Window.SizeChanged", ex);
            }
        };
        StateChanged += (_, _) =>
        {
            try
            {
                OnMainWindowMoved();
                UpdateMaximizeRestoreIcon();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("Window.StateChanged", ex);
            }
        };

        SourceInitialized += (_, _) =>
        {
            try
            {
                var source = System.Windows.Interop.HwndSource.FromHwnd(
                    new System.Windows.Interop.WindowInteropHelper(this).Handle);
                source?.AddHook(NativeMethods.MaximizeWorkAreaHook);
                source?.AddHook(TitlebarNcMouseWheelHook);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("Window.SourceInitialized", ex);
            }
        };

        IntelliSensePopup.PreviewMouseDown += (_, e) =>
        {
            try
            {
                // Find which ListBoxItem was clicked and accept that suggestion.
                // PreviewMouseDown fires before focus can change, ensuring the state is intact.
                var clickedContainer = FindAncestorListBoxItem(e.OriginalSource as DependencyObject);
                if (clickedContainer is not null && _intelliSenseState is not null)
                {
                    var index = IntelliSenseList.ItemContainerGenerator.IndexFromContainer(clickedContainer);
                    if (index >= 0)
                    {
                        _intelliSenseState = _intelliSenseState with { SelectedIndex = index };
                        ApplyIntelliSenseAccept(andSubmit: false);
                        e.Handled = true;
                    }
                }
                (_intelliSenseOwnerBox ?? PromptTextBox).Focus();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("IntelliSensePopup.PreviewMouseDown", ex);
            }
        };

        // ── Search box event wiring ────────────────────────────────────────────
        SearchBox.TextChanged += (_, _) =>
        {
            try
            {
                _searchDebounceTimer?.Stop();
                _searchDebounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
                _searchDebounceTimer.Tick += async (_, _) =>
                {
                    try
                    {
                        _searchDebounceTimer?.Stop();
                        _searchDebounceTimer = null;
                        await ExecuteSearchAsync(SearchBox.Text);
                    }
                    catch (Exception ex)
                    {
                        HandleUiCallbackException("SearchDebounceTimer.Tick", ex);
                    }
                };
                _searchDebounceTimer.Start();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("SearchBox.TextChanged", ex);
            }
        };
        SearchBox.KeyDown += async (_, e) =>
        {
            try
            {
                if (e.Key == Key.Enter)
                {
                    await NavigateToMatchAsync(_searchMatchCursor + 1);
                    e.Handled = true;
                }
                else if (e.Key == Key.Escape)
                {
                    ClearSearch();
                    _ = Dispatcher.BeginInvoke(DispatcherPriority.Input, () => PromptTextBox.Focus());
                    e.Handled = true;
                }
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("SearchBox.KeyDown", ex);
            }
        };
        FindPrevButton.Click += async (_, _) =>
        {
            try
            {
                await NavigateToMatchAsync(_searchMatchCursor - 1);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("FindPrevButton.Click", ex);
            }
        };
        FindNextButton.Click += async (_, _) =>
        {
            try
            {
                await NavigateToMatchAsync(_searchMatchCursor + 1);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("FindNextButton.Click", ex);
            }
        };

        _pec = new PromptExecutionController(
            runPromptAsync: (prompt, cwd, sessionId, configDir) => _bridge.RunPromptAsync(prompt, cwd, sessionId, configDir),
            runNamedAgentDelegationAsync: (selectedOption, targetAgentHandle, cwd, sessionId, configDir) =>
                _bridge.RunNamedAgentDelegationAsync(selectedOption, targetAgentHandle, cwd, sessionId, configDir),
            runNamedAgentDirectAsync: (targetAgentHandle, selectedOption, handoffContext, cwd, sessionId, configDir) =>
                _bridge.RunNamedAgentDirectAsync(targetAgentHandle, selectedOption, handoffContext, cwd, sessionId, configDir),
            getCurrentWorkspace: () => _currentWorkspace,
            getSettingsSnapshot: () => _settingsSnapshot,
            conversationManager: _conversationManager,
            backgroundTaskPresenter: _backgroundTaskPresenter,
            squadCliAdapter: _squadCliAdapter,
            beginTranscriptTurn: prompt => BeginTranscriptTurn(prompt),
            finalizeCurrentTurnResponse: () => FinalizeCurrentTurnResponse(),
            appendLine: (text, brush) => AppendLine(text, brush),
            selectTranscriptThread: thread => {
                // When the coordinator thread is shown during prompt execution, make the
                // main transcript visible if it was hidden (e.g. user had only agent
                // secondary panels open). We do NOT close secondary panels — the user
                // keeps seeing what they were looking at, and the coordinator is added.
                if (!_mainTranscriptVisible)
                    ShowMainTranscript();
                SelectTranscriptThread(thread);
            },
            getCoordinatorThread: () => CoordinatorThread,
            getAgents: () => _agents,
            getCurrentSessionState: () => _currentSessionState,
            getIsPromptRunning: () => _isPromptRunning,
            setIsPromptRunning: v =>
            {
                _isPromptRunning = v;
                _idleDetectionService?.SetPromptActive(v);
                if (v)
                {
                    // Clear stale completion timestamp AND remove the "Completed N ago" footer
                    // paragraph so it doesn't linger while the new turn is streaming.
                    MaybeReactivateThread(CoordinatorThread);
                }
                else
                {
                    CoordinatorThread.CompletedAt = DateTimeOffset.Now;
                    SyncQueuePauseLabel();
                    UpdateCompletedTimeFooters();
                    _backgroundTaskPresenter.PromoteDeferredBackgroundAgentReports("coordinator_idle");
                    SquadDashTrace.Write("Queue", $"setIsPromptRunning(false): queueCount={_promptQueue.Count} deferred={_deferredShutdown} restartPending={_restartPending} isClosing={_isClosing}");
                    ApplyPendingBridgeSettingsRestartIfIdle("prompt-finished");
                    if (_deferredShutdown == DeferredShutdownMode.AfterCurrentTurn)
                    {
                        // User chose "close after this turn" — don't drain, just close.
                        Close();
                    }
                    else if (_deferredShutdown == DeferredShutdownMode.AfterAllQueued)
                    {
                        if (_promptQueue.Count > 0)
                        {
                            if (CanAutoDispatchPromptQueue("deferred-shutdown") && GetAutoDispatchCandidate() is not null)
                                _ = DrainQueueAsync(); // keep draining
                            else
                                SquadDashTrace.Write("Shutdown", $"Deferred shutdown still waiting for queued items count={_promptQueue.Count}.");
                        }
                        else
                        {
                            Close(); // queue exhausted — shut down
                        }
                    }
                    else if (_restartPending)
                    {
                        // Rebuild-triggered restart: close only after every in-flight activity
                        // that can mutate transcript/agent state is safely idle.
                        TryCompletePendingRestart("prompt-finished");
                    }
                    else
                    {
                        if (_promptQueue.Count > 0 && CanAutoDispatchPromptQueue("prompt-finished") && GetAutoDispatchCandidate() is not null)
                        {
                            if (LastTurnNeedsInput())
                                HandleQueuePausedForInput();
                            else
                                _ = DrainQueueAsync();
                        }
                        else
                        {
                            // Only play the "queue empty" sound when the queue genuinely drained.
                            // GetAutoDispatchCandidate() also returns null when the queue is
                            // manually paused (items still present), so guard on Count == 0 to
                            // avoid firing the sound when the user clicked Pause.
                            if (_promptQueue.Count == 0)
                            {
                                SoundNotifications.Play(SoundEvent.QueueEmpty);
                                // Resume a queued loop now that there are no more items to drain.
                                _ = MaybeFireQueuedLoopAsync();
                            }
                        }
                    }
                }
                SyncQueuePanel();
                SyncLoopPanel();
                if (_remoteAccessActive)
                    _ = _bridge.BroadcastRcStatusAsync(v);
            },
            getIsClosing: () => _isClosing,
            getRestartPending: () => _restartPending,
            close: () => TryCompletePendingRestart("prompt-controller-finally"),
            clearPromptTextBox: () => ClearPromptTextBoxLogicalBuffer("prompt-executed"),
            focusPromptTextBox: () => PromptTextBox.Focus(),
            isPromptTextBoxEnabled: () => PromptTextBox.IsEnabled,
            getQueueCount: () => _promptQueue.Count,
            getPromptBoxText: () => PromptTextBox.Text,
            setPromptBoxText: text => SetPromptTextBoxLogicalBuffer(text, text.Length, reason: "test-queue-draft"),
            enqueueSimItem: item => {
                item.SequenceNumber = ++_promptQueueSeq;
                _promptQueue.EnqueueItem(item);
                SyncQueuePanel();
                _ = DrainQueueIfNeededAsync();
            },
            getPendingRoutingRepairRecheck: () => _pendingRoutingRepairRecheck,
            setPendingRoutingRepairRecheck: v => _pendingRoutingRepairRecheck = v,
            getPendingSupplementalInstruction: () => _pendingSupplementalPromptInstruction,
            clearPendingSupplementalInstruction: () => { _pendingSupplementalPromptInstruction = null; },
            getPendingQuickReplyRoutingInstruction: () => _pendingQuickReplyRoutingInstruction,
            getPendingQuickReplyRouteMode: () => _pendingQuickReplyLaunch?.RouteMode,
            updateInteractiveControlState: () => UpdateInteractiveControlState(),
            updateLeadAgent: (status, bubble, detail) => UpdateLeadAgent(status, bubble, detail),
            updateSessionState: state => UpdateSessionState(state),
            refreshAgentCards: () => RefreshAgentCards(),
            refreshSidebar: () => RefreshSidebar(),
            setInstallStatus: msg => SetInstallStatus(msg),
            canShowOwnedWindow: () => CanShowOwnedWindow(),
            showTextWindow: (title, content) => ShowTextWindow(title, content),
            clearSessionView: () => ClearSessionView(),
            showTasksStatusWindow: () => ShowTasksStatusWindow(),
            hideTasksStatusWindow: () => HideTasksStatusWindow(),
            showApprovalWindow: () => ShowApprovalPanel(),
            showLiveTraceWindow: () => ShowTraceWindow(),
            runDoctor: () => RunDoctorButton_Click(null!, null!),
            showHireAgentWindow: () => ShowHireAgentWindow(),
            enqueuePrompt: (text, isSystemInjected) => EnqueuePrompt(text, isSystemInjected),
            showScreenshotOverlay: () => ShowScreenshotOverlay(),
            showRuntimeIssue: msg => ShowRuntimeIssue(msg),
            clearRuntimeIssue: () => ClearRuntimeIssue(),
            waitForRoutingRepairSettleAsync: () => WaitForRoutingRepairStateToSettleAsync(),
            maybePublishRoutingIssue: (reason, force) => MaybePublishRoutingIssueSystemEntry(reason, force),
            promptHealthTimer: _promptHealthTimer,
            waitForPostedUiActionsAsync: () => _postedUiActionTracker.WaitForDrainAsync(),
            getModelObservedThisSession: () => _modelObservedThisSession,
            getLastQuickReplyEntry: () => _lastQuickReplyEntry,
            setLastQuickReplyEntryNull: () => ClearActiveQuickReplyState(),
            renderResponseEntry: entry => RenderResponseEntry(entry),
            ensureThreadFooterAtEnd: thread => EnsureThreadFooterAtEnd(thread),
            scrollToEndIfAtBottom: () => ScrollToEndIfAtBottom(),
            getToolEntries: () => _agentThreadRegistry.ToolEntries.Values,
            renderToolEntry: entry => RenderToolEntry(entry),
            updateToolSpinnerState: () => UpdateToolSpinnerState(),
            workspacePaths: _workspacePaths,
            getSubmittedAttachments: () => _pendingTranscriptAttachments ?? Array.Empty<FollowUpAttachment>());

        InitializeHostCommands();
        SquadDashTrace.Write(TraceCategory.Startup, $"Constructor: InitializeHostCommands {ctorSw.ElapsedMilliseconds}ms.");

        _loopController = new LoopController(
            // ExecuteLoopIterationAsync accesses WPF components — must run on the UI thread.
            // It spans multiple AI turns when the AI responds with quick replies.
            executePromptAsync: (prompt, sessionId) =>
                Dispatcher.InvokeAsync(() => ExecuteLoopIterationAsync(prompt, sessionId)).Task.Unwrap(),
            abortPrompt: () => _bridge.AbortPrompt(),
            onIterationStarted: n =>
                Dispatcher.Invoke(() => OnNativeLoopIterationStarted(n)),
            onStopped: () =>
                Dispatcher.Invoke(OnNativeLoopStopped),
            onError: msg =>
                Dispatcher.Invoke(() => OnNativeLoopError(msg)),
            onIterationCompleted: n =>
                Dispatcher.Invoke(() => OnNativeLoopIterationCompleted(n)),
            onWaiting: nextAt =>
                Dispatcher.Invoke(() => OnNativeLoopWaiting(nextAt)),
            onBeforeIteration: () =>
                Dispatcher.InvokeAsync(() => DrainQueueBeforeLoopIterationAsync())
                          .Task.Unwrap(),
            onBeforeWait: () =>
                Dispatcher.InvokeAsync(() => DrainQueueIfNeededAsync())
                          .Task.Unwrap());

        _markdownRenderer = new MarkdownDocumentRenderer(
            getFontSize: () => _transcriptFontSize,
            getWorkspaceGitHubUrl: () => _workspaceGitHubUrl,
            onLinkClicked: target => HandleTranscriptLinkClick(target),
            onException: (op, ex) => HandleUiCallbackException(op, ex),
            resolveContinuationThread: entry => TryResolveQuickReplyContinuationThread(entry),
            onQuickReplyButtonClick: QuickReplyButton_Click,
            appendResponseSegment: (thread, text, newLine) => AppendResponseSegment(thread, text, newLine),
            scrollToEndIfAtBottom: thread => ScrollToEndIfAtBottom(thread),
            getCoordinatorThread: () => CoordinatorThread);

        RegisterUiReplayActions();
        RegisterFixtureLoaders();
        ctorSw.Stop();
        SquadDashTrace.Write(TraceCategory.Startup, $"Constructor: complete {ctorSw.ElapsedMilliseconds}ms total.");
    }

    // ── Replay action registration ──────────────────────────────────────────

    /// <summary>
    /// Registers all <see cref="IReplayableUiAction"/> instances with
    /// <see cref="_uiActionReplayRegistry"/> at startup.
    /// Add new concrete actions here as they are implemented in Phase 3.
    /// </summary>
    private void RegisterUiReplayActions()
    {
        _uiActionReplayRegistry.Register(new OpenPreferencesWindowAction(
            openPreferencesWindow: () => PreferencesMenuItem_Click(this, new System.Windows.RoutedEventArgs()),
            getPreferencesWindow: () => _preferencesWindow is { IsVisible: true } ? _preferencesWindow : null));
    }

    // ── Fixture loader registration ─────────────────────────────────────────

    /// <summary>
    /// Registers all <see cref="IFixtureLoader"/> implementations with
    /// <see cref="_fixtureLoaderRegistry"/> at startup.
    /// Each loader receives only the references it needs via constructor injection;
    /// none of them hold a back-reference to <c>MainWindow</c>.
    /// </summary>
    private void RegisterFixtureLoaders()
    {
        // windowGeometry must be first — all layout-dependent loaders require the window
        // to be at its target geometry before they run.
        _fixtureLoaderRegistry.Register("windowGeometry", new WindowGeometryFixtureLoader(
            mainWindow: this,
            dispatcher: Dispatcher));

        // viewMode must come before agentCard — view mode controls panel visibility, which
        // affects agent-card layout.
        _fixtureLoaderRegistry.Register("viewMode", new ViewModeFixtureLoader(
            getTranscriptFullScreen: () => _transcriptFullScreenEnabled,
            setTranscriptFullScreen: v => { _transcriptFullScreenEnabled = v; ApplyViewMode(); },
            dispatcher: Dispatcher));

        // agentOrder must come after viewMode (panel visibility is set) and before agentCard
        // (card state depends on cards already being in their final positions).
        _fixtureLoaderRegistry.Register("agentOrder", new AgentOrderFixtureLoader(
            agents: _agents,
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("agentCard", new AgentCardFixtureLoader(
            agents: _agents,
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("transcript", new TranscriptFixtureLoader(
            getCoordinatorThread: () => CoordinatorThread,
            scrollController: _coordinatorScrollController,
            dispatcher: Dispatcher,
            repoRoot: _workspacePaths.ApplicationRoot));

        _fixtureLoaderRegistry.Register("voiceFeedback", new VoiceFeedbackFixtureLoader(
            promptTextBox: PromptTextBox,
            ownerWindow: this,
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("backgroundTask", new BackgroundTaskFixtureLoader(
            getBackgroundAgents: () => _backgroundTaskPresenter.BackgroundAgents,
            setBackgroundAgents: agents => _backgroundTaskPresenter.BackgroundAgents = agents,
            refreshDisplay: () => _backgroundTaskPresenter.RefreshLeadAgentBackgroundStatus(),
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("quickReplies", new QuickReplyFixtureLoader(
            getCoordinatorThread: () => CoordinatorThread,
            dispatcher: Dispatcher));

        // scrollPosition must come after agentCard — agent-card layout must be settled
        // before scroll positions are meaningful.
        _fixtureLoaderRegistry.Register("scrollPosition", new ScrollPositionFixtureLoader(
            getTranscriptOffset: () => _coordinatorScrollController.GetVerticalOffset(),
            setTranscriptOffset: v => _coordinatorScrollController.ScrollToAbsoluteOffset(v),
            getActiveRosterOffset: () => ActiveAgentsScrollViewer.HorizontalOffset,
            setActiveRosterOffset: v => ActiveAgentsScrollViewer.ScrollToHorizontalOffset(v),
            getInactiveRosterOffset: () => InactiveAgentsScrollViewer.HorizontalOffset,
            setInactiveRosterOffset: v => InactiveAgentsScrollViewer.ScrollToHorizontalOffset(v),
            dispatcher: Dispatcher));

        // promptText must come after scrollPosition — complete UI layout should be
        // established before the prompt text is set.
        _fixtureLoaderRegistry.Register("promptText", new PromptTextFixtureLoader(
            promptTextBox: PromptTextBox,
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("tasksPanel", new Screenshots.Fixtures.TasksFixtureLoader(
            activePanel: TasksActivePanel,
            completedPanel: TasksCompletedPanel,
            refreshPanel: result => _tasksPanelController?.Refresh(result),
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("loopPanel", new Screenshots.Fixtures.LoopPanelFixtureLoader(
            getStatusText: () => LoopStatusLabel.Text,
            setStatusText: v => { LoopStatusLabel.Text = v; LoopStatusLabel.Visibility = string.IsNullOrEmpty(v) ? Visibility.Collapsed : Visibility.Visible; },
            getStopEnabled: () => StopLoopButton.IsEnabled,
            setStopEnabled: v => StopLoopButton.IsEnabled = v,
            getStartEnabled: () => StartLoopButton.IsEnabled,
            setStartEnabled: v => StartLoopButton.IsEnabled = v,
            getAbortVisibility: () => AbortLoopButton.Visibility,
            setAbortVisibility: v => AbortLoopButton.Visibility = v,
            dispatcher: Dispatcher));

        _fixtureLoaderRegistry.Register("approvalsPanel", new Screenshots.Fixtures.ApprovalsPanelFixtureLoader(
            getApprovalItems: () => _approvalItems,
            setApprovalItems: items => _approvalItems = items,
            replaceAllInPanel: items => _approvalPanel?.ReplaceAllItems(items),
            dispatcher: Dispatcher));
    }

    // ── ILiveElementLocator ─────────────────────────────────────────────────

    /// <inheritdoc/>
    FrameworkElement? ILiveElementLocator.FindByName(string name) =>
        FindName(name) as FrameworkElement;

    /// <inheritdoc/>
    Rect ILiveElementLocator.GetBoundsRelativeToWindow(FrameworkElement element)
    {
        try
        {
            var transform = element.TransformToAncestor(this);
            var origin    = transform.Transform(new Point(0, 0));
            return new Rect(origin.X, origin.Y, element.ActualWidth, element.ActualHeight);
        }
        catch { return Rect.Empty; }
    }

    /// <inheritdoc/>
    bool ILiveElementLocator.IsVisible(FrameworkElement element) =>
        element.Visibility == Visibility.Visible && element.ActualWidth > 0;

    private void AddWorkspaceMenuSeparator()
    {
        WorkspaceMenuItem.Items.Add(new Separator
        {
            Style = (Style)FindResource("ThemedMenuSeparatorStyle")
        });
    }
    private void MainWindow_ContentRendered(object? sender, EventArgs e)
    {
        try
        {
            ContentRendered -= MainWindow_ContentRendered;
            UpdateAgentPanelWidths();
            UpdateRosterHeightCap();
            SquadDashTrace.Write(
                "Startup",
                $"ContentRendered: ActiveH={ActiveAgentItemsControl.ActualHeight:F0} ActiveViewport={ActiveAgentsScrollViewer.ActualHeight:F0} " +
                $"InactiveH={InactiveAgentItemsControl.ActualHeight:F0} InactiveViewport={InactiveAgentsScrollViewer.ActualHeight:F0} RootH={StatusAgentPanelsGrid.ActualHeight:F0}");
            if (ActiveAgentItemsControl.ActualHeight < 1 || InactiveAgentItemsControl.ActualHeight < 1)
                ScheduleAgentPanelLayoutRefresh();
            TryNudgeAgentLaneLayout();
            SquadDashTrace.Write(
                "Startup",
                $"ContentRendered post-refresh: ActiveH={ActiveAgentItemsControl.ActualHeight:F0} ActiveViewport={ActiveAgentsScrollViewer.ActualHeight:F0} " +
                $"InactiveH={InactiveAgentItemsControl.ActualHeight:F0} InactiveViewport={InactiveAgentsScrollViewer.ActualHeight:F0} RootH={StatusAgentPanelsGrid.ActualHeight:F0}");
            PromptTextBox.Focus();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MainWindow_ContentRendered), ex);
        }
    }

    private void MainWindow_Activated(object? sender, EventArgs e)
    {
        try
        {
            // Re-sync scroll state on every activation so that an RDP reconnect —
            // which can leave the transcript viewport at the top without firing the
            // events that normally show/hide the scroll-to-bottom button — is corrected
            // the moment the user sees the window.
            ActiveScrollController.SyncScrollState();

            if (!_pendingPowerShellInstallRecheck)
                return;

            _pendingPowerShellInstallRecheck = false;
            RefreshInstallationState();
            if (WorkspaceIssueFactory.IsPowerShellAvailable() &&
                WorkspaceIssueFactory.IsMissingPowerShellIssue(_runtimeIssue))
            {
                ClearRuntimeIssue();
                RefreshInstallationState();
                SetInstallStatus("PowerShell 7 was detected. Setup looks good now.");
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MainWindow_Activated), ex);
        }
    }

    private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        try
        {
            DocEditorToolbar.TargetRichTextBox = DocSourceTextBox;

            DeveloperMenuItem.Visibility = Visibility.Collapsed;

            // Wire the search highlight adorner unconditionally (idempotent).
            if (_searchAdorner is null)
            {
                var adornerLayer = AdornerLayer.GetAdornerLayer(OutputTextBox);
                if (adornerLayer is not null)
                {
                    _searchAdorner = new SearchHighlightAdorner(OutputTextBox);
                    adornerLayer.Add(_searchAdorner);
                }
            }

            // Wire the scrollbar marker adorner onto the vertical ScrollBar inside OutputTextBox.
            if (_scrollbarAdorner is null)
            {
                var outputScrollViewer = FindScrollViewer(OutputTextBox);
                if (outputScrollViewer is not null)
                {
                    _transcriptScrollBar =
                        outputScrollViewer.Template?.FindName("PART_VerticalScrollBar", outputScrollViewer) as ScrollBar
                        ?? FindVerticalScrollBar(outputScrollViewer);

                    if (_transcriptScrollBar is not null)
                    {
                        var sbLayer = AdornerLayer.GetAdornerLayer(_transcriptScrollBar);
                        if (sbLayer is not null)
                        {
                            _scrollbarAdorner = new ScrollbarMarkerAdorner(_transcriptScrollBar);
                            sbLayer.Add(_scrollbarAdorner);
                        }
                    }
                }
            }
            RefreshActiveTranscriptScrollViewer();

            if (_startupInitialized)
                return;

            _startupInitialized = true;

            // Global shortcut: Ctrl+Alt+Shift+F12 → toggle UI Reveal on the active window.
            // InputManager.PreProcessInput fires before WPF dispatch for every window on this
            // thread, so the shortcut works even when a floating window has keyboard focus.
            InputManager.Current.PreProcessInput += OnGlobalPreProcessInput;

            // Capture Shift state once, before any async work, while we're still on the UI thread.
            _startupShiftHeld = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift);

            var loadedSw = Stopwatch.StartNew();
            var phaseSw = Stopwatch.StartNew();
            SquadDashTrace.Write(TraceCategory.Startup, "MainWindow_Loaded: begin deferred init.");

            ConfigureRestartRequestWatcher();
            SquadDashTrace.Write(TraceCategory.Startup, $"MainWindow_Loaded: ConfigureRestartRequestWatcher {phaseSw.ElapsedMilliseconds}ms.");

            // When an AI doc-revision completes, check if a deferred restart can now proceed.
            MarkdownDocumentWindow.RevisionCompleted += OnDocRevisionCompleted;

            phaseSw.Restart();
            await InitializeWorkspace(_startupFolderArgument);
            SquadDashTrace.Write(TraceCategory.Startup, $"MainWindow_Loaded: InitializeWorkspace {phaseSw.ElapsedMilliseconds}ms.");

            // If Shift was held and we actually suppressed something, show a single transcript hint.
            if (_startupShiftHeld && (_promptQueue.HasReadyItems || _loopQueued || _settingsSnapshot.LoopActiveOnExit))
            {
                AppendLine("⏸ Startup paused — Shift was held. Queue and loop auto-resume suppressed.",
                           (Brush)FindResource("SubtleText"));
            }

            phaseSw.Restart();
            RestoreUtilityWindowVisibility();
            SquadDashTrace.Write(TraceCategory.Startup, $"MainWindow_Loaded: RestoreUtilityWindowVisibility {phaseSw.ElapsedMilliseconds}ms.");

            // Grant focus before the async version-check so the user can type immediately
            // without waiting for the npx squad --version probe to complete.
            if (PromptTextBox.IsVisible)
                _ = Dispatcher.BeginInvoke(DispatcherPriority.Input, () => PromptTextBox.Focus());

            // Fire-and-forget the version probe — it takes ~1.5 s and only affects status
            // display.  Show the title immediately with whatever is already known; the probe
            // will refresh it once it completes.
            UpdateStatusTitle();
            var resolveVersionSw = Stopwatch.StartNew();
            _ = _squadCliAdapter.ResolveSquadVersionAsync().ContinueWith(_ =>
                Dispatcher.Invoke(() =>
                {
                    resolveVersionSw.Stop();
                    SquadDashTrace.Write(TraceCategory.Startup, $"MainWindow_Loaded: ResolveSquadVersionAsync {resolveVersionSw.ElapsedMilliseconds}ms (async complete).");
                    UpdateStatusTitle();
                    SyncLoopPanel();
                }));
            SquadDashTrace.Write(TraceCategory.Startup, "MainWindow_Loaded: ResolveSquadVersionAsync started (non-blocking).");
            _ = _squadCliAdapter.CheckForSquadUpdateAsync().ContinueWith(_ => Dispatcher.Invoke(UpdateSquadUpdateBadge));

            loadedSw.Stop();
            SquadDashTrace.Write(TraceCategory.Startup, $"MainWindow_Loaded: complete {loadedSw.ElapsedMilliseconds}ms total.");

            // Screenshot refresh mode: run the automated pass then shut down.
            if (_screenshotRefreshOptions.Mode != ScreenshotRefreshMode.None)
                await RunScreenshotRefreshAsync(_screenshotRefreshOptions);

            // Pre-warm the definition registry so right-click "Refresh screenshot" is
            // available immediately without an async lookup delay.
            _ = WarmDefinitionRegistryCacheAsync();
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Startup", $"Deferred startup initialization failed: {ex}");
            HandleUiCallbackException(nameof(MainWindow_Loaded), ex);
        }
    }

    // ── Screenshot refresh mode ─────────────────────────────────────────────────────

    private async Task RunScreenshotRefreshAsync(ScreenshotRefreshOptions options)
    {
        SquadDashTrace.Write("Screenshot", $"Starting refresh run: Mode={options.Mode} Target={options.TargetName ?? "(all)"}");

        try
        {
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            var definitions = await ScreenshotDefinitionRegistry.LoadAsync(screenshotsDir).ConfigureAwait(true);
            var runner = new ScreenshotRefreshRunner(
                definitions,
                _uiActionReplayRegistry,
                _fixtureLoaderRegistry,
                screenshotsDir,
                applyThemeAsync: async name =>
                {
                    await Dispatcher.InvokeAsync(() => ApplyTheme(name));
                },
                getActiveTheme: () => _activeThemeName);

            runner.CaptureRequested += OnScreenshotCaptureRequested;

            try
            {
                await runner.RunAsync(options).ConfigureAwait(true);
            }
            finally
            {
                runner.CaptureRequested -= OnScreenshotCaptureRequested;
            }
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Screenshot", $"Refresh run failed: {ex}");
            Console.Error.WriteLine($"[screenshot] Refresh aborted: {ex.Message}");
        }
        finally
        {
            SquadDashTrace.Write("Screenshot", "Refresh run complete — shutting down.");
            Application.Current.Shutdown();
        }
    }

    private async Task WarmDefinitionRegistryCacheAsync()
    {
        try
        {
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            _cachedDefinitionRegistry = await Screenshots.ScreenshotDefinitionRegistry
                                                         .LoadAsync(screenshotsDir)
                                                         .ConfigureAwait(true);

            // Construct (or refresh) the health checker now that the definition registry
            // is loaded.  All fixture loaders are already registered at this point.
            ScreenshotHealthChecker = new Screenshots.ScreenshotHealthChecker(
                _cachedDefinitionRegistry,
                _uiActionReplayRegistry,
                _fixtureLoaderRegistry,
                this,
                screenshotsDir);
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Screenshot", $"WarmDefinitionRegistryCacheAsync failed: {ex.Message}");
        }
    }

    /// <summary>
    /// Runs a single-definition screenshot refreshfrom the right-click context menu,
    /// without shutting down afterwards.  Applies the fixture, replays the action,
    /// captures, restores, copies to the doc image path, and reloads the viewer.
    /// </summary>
    private async Task RefreshDocImageAsync(string definitionName)
    {
        SquadDashTrace.Write("Screenshot", $"RefreshDocImage: {definitionName}");
        try
        {
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            // Always reload from disk so we have the latest definition state.
            var definitions = await Screenshots.ScreenshotDefinitionRegistry.LoadAsync(screenshotsDir)
                                               .ConfigureAwait(true);
            _cachedDefinitionRegistry = definitions;

            // "Refresh screenshot" from the right-click menu always re-captures in the
            // currently visible theme.  If the stored definition says a different theme,
            // update it now so the runner applies the correct theme and the definition
            // stays in sync with the image the user is actually looking at.
            var existingDef = definitions.TryGet(definitionName);
            if (existingDef is not null
                && !string.Equals(existingDef.Theme, _activeThemeName, StringComparison.OrdinalIgnoreCase))
            {
                definitions.AddOrUpdate(existingDef with { Theme = _activeThemeName });
                await definitions.SaveAsync().ConfigureAwait(true);
                _cachedDefinitionRegistry = definitions;
            }

            var runner = new Screenshots.ScreenshotRefreshRunner(
                definitions,
                _uiActionReplayRegistry,
                _fixtureLoaderRegistry,
                screenshotsDir,
                applyThemeAsync: async name =>
                {
                    await Dispatcher.InvokeAsync(() => ApplyTheme(name));
                },
                getActiveTheme: () => _activeThemeName);

            runner.CaptureRequested += OnScreenshotCaptureRequested;
            try
            {
                await runner.RunAsync(
                    new Screenshots.ScreenshotRefreshOptions(
                        Mode:       Screenshots.ScreenshotRefreshMode.Named,
                        TargetName: definitionName)
                    ).ConfigureAwait(true);
            }
            finally
            {
                runner.CaptureRequested -= OnScreenshotCaptureRequested;
            }

            // Reload the doc viewer so the updated image appears immediately.
            if (!string.IsNullOrEmpty(_currentDocPath) && File.Exists(_currentDocPath))
            {
                var raw      = File.ReadAllText(_currentDocPath);
                var markdown = StripDocFrontMatter(raw, out _);
                var title    = (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Header?.ToString()
                               ?? "Documentation";
                var html = MarkdownHtmlBuilder.Build(
                    markdown, title, filePath: _currentDocPath,
                    isDark: AgentStatusCard.IsDarkTheme);
                DocMarkdownViewer.NavigateToString(html);
            }

            // Reload the Tasks panel from disk so any changes made before the refresh are visible.
            LoadTasksPanel();

            AppendLine($"✅ Screenshot refreshed: {definitionName}");
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Screenshot", $"RefreshDocImage failed for '{definitionName}': {ex}");
            AppendLine($"[screenshot] Refresh failed for '{definitionName}': {ex.Message}",
                       ThemeBrush("SystemErrorText"));
        }
    }


    private void OnScreenshotCaptureRequested(object? sender, ScreenshotCaptureRequestedEventArgs e)
    {
        try
        {
            // The runner may raise this event from a thread-pool thread (ConfigureAwait(false)
            // inside RunOneAsync). All WPF visual-tree access and RenderTargetBitmap work must
            // run on the dispatcher thread — so we marshal the entire capture body through
            // Dispatcher.Invoke, including the render-flush pass.
            Dispatcher.Invoke(() =>
            {
                try
                {
                    // Switch to Natural tint (stop 0) so all doc screenshots have a
                    // consistent baseline appearance, then restore the user's tint after.
                    int savedTintStop = _activeTintStop;
                    try
                    {
                        if (savedTintStop != 0)
                            SetWorkspaceTintStop(0);

                        // Allow WPF to finish any pending layout/rendering before capturing,
                        // including the tint change applied above.
                        Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Render);

                        var dpi = VisualTreeHelper.GetDpi(this);
                        var pxW = (int)Math.Round(ActualWidth * dpi.DpiScaleX);
                        var pxH = (int)Math.Round(ActualHeight * dpi.DpiScaleY);

                        var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(
                            pxW, pxH,
                            dpi.PixelsPerInchX, dpi.PixelsPerInchY,
                            System.Windows.Media.PixelFormats.Pbgra32);
                        rtb.Render(this);
                        rtb.Freeze();

                        // If the definition specified a sub-region, crop the RTB to that area.
                        // Prefer live anchor-based bounds so that panel moves are handled correctly;
                        // fall back to the stored CaptureBounds when live resolution fails.
                        BitmapSource bitmapToSave = rtb;
                        var liveBounds = TryResolveLiveBounds(e);
                        var cropBounds = liveBounds ?? e.CaptureBounds;
                        if (cropBounds is { } bounds)
                        {
                            // Always convert the logical-pixel bounds using the *current* runtime DPI
                            // scale — NOT the DPI stored in CaptureBounds.  The RTB was rendered at
                            // the current dpi.PixelsPerInchX/Y, so pixel coordinates must use the
                            // same scale.  Using the stored bounds.DpiX/Y would produce the wrong
                            // crop on any machine whose DPI differs from the original capture machine.
                            var pixelX = (int)Math.Round(bounds.X * dpi.DpiScaleX);
                            var pixelY = (int)Math.Round(bounds.Y * dpi.DpiScaleY);
                            var pixelW = (int)Math.Round(bounds.Width * dpi.DpiScaleX);
                            var pixelH = (int)Math.Round(bounds.Height * dpi.DpiScaleY);

                            // Clamp to the RTB dimensions so we never request an out-of-bounds rect.
                            pixelX = Math.Max(0, Math.Min(pixelX, rtb.PixelWidth - 1));
                            pixelY = Math.Max(0, Math.Min(pixelY, rtb.PixelHeight - 1));
                            pixelW = Math.Max(1, Math.Min(pixelW, rtb.PixelWidth - pixelX));
                            pixelH = Math.Max(1, Math.Min(pixelH, rtb.PixelHeight - pixelY));

                            bitmapToSave = new CroppedBitmap(
                                rtb, new System.Windows.Int32Rect(pixelX, pixelY, pixelW, pixelH));
                        }

                        // Normalise DPI metadata to 96 DPI so the saved PNG has a consistent,
                        // predictable size in documentation viewers regardless of the monitor's
                        // physical DPI.  Physical pixels are copied 1:1 — no resampling.
                        bitmapToSave = DpiHelper.NormalizeTo96Dpi(bitmapToSave);

                        var dir = Path.GetDirectoryName(e.OutputPath);
                        if (!string.IsNullOrEmpty(dir))
                            Directory.CreateDirectory(dir);

                        using var stream = File.Create(e.OutputPath);
                        var encoder = new PngBitmapEncoder();
                        encoder.Frames.Add(BitmapFrame.Create(bitmapToSave));
                        encoder.Save(stream);

                        e.SignalSaved();
                    }
                    finally
                    {
                        if (savedTintStop != 0)
                            SetWorkspaceTintStop(savedTintStop);
                    }
                }
                catch (Exception ex)
                {
                    e.SignalFailed(ex.Message);
                }
            });
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OnScreenshotCaptureRequested), ex);
        }
    }

    /// <summary>
    /// Attempts to derive a live <see cref="Screenshots.CaptureBounds"/> by locating
    /// the named WPF elements stored in each edge anchor of <paramref name="e"/> and
    /// computing the bounding box from their current screen positions.
    /// </summary>
    /// <returns>
    /// A freshly computed <see cref="Screenshots.CaptureBounds"/> when all four edge
    /// anchors resolve to a visible element; <c>null</c> when any anchor cannot be
    /// resolved (callers should fall back to the stored bounds).
    /// </returns>
    private Screenshots.CaptureBounds? TryResolveLiveBounds(
        Screenshots.ScreenshotCaptureRequestedEventArgs e)
    {
        // Require all four anchors to be present.
        if (e.TopAnchor    is not { } top    || top.ElementNames.Count    == 0) return null;
        if (e.RightAnchor  is not { } right  || right.ElementNames.Count  == 0) return null;
        if (e.BottomAnchor is not { } bottom || bottom.ElementNames.Count == 0) return null;
        if (e.LeftAnchor   is not { } left   || left.ElementNames.Count   == 0) return null;

        var topEl    = FindName(top.ElementNames[0])    as FrameworkElement;
        var rightEl  = FindName(right.ElementNames[0])  as FrameworkElement;
        var bottomEl = FindName(bottom.ElementNames[0]) as FrameworkElement;
        var leftEl   = FindName(left.ElementNames[0])   as FrameworkElement;

        if (topEl is null || rightEl is null || bottomEl is null || leftEl is null)
        {
            SquadDashTrace.Write("Screenshot",
                $"[{e.DefinitionName}] Live anchor resolution failed — one or more elements not found; falling back to stored bounds.");
            return null;
        }

        try
        {
            Rect TopRect    = GetElementBoundsInWindow(topEl);
            Rect RightRect  = GetElementBoundsInWindow(rightEl);
            Rect BottomRect = GetElementBoundsInWindow(bottomEl);
            Rect LeftRect   = GetElementBoundsInWindow(leftEl);

            if (TopRect.IsEmpty || RightRect.IsEmpty || BottomRect.IsEmpty || LeftRect.IsEmpty)
            {
                SquadDashTrace.Write("Screenshot",
                    $"[{e.DefinitionName}] Live anchor resolution failed — element has no layout; falling back to stored bounds.");
                return null;
            }

            // Each anchor stores the distance from the element's matching edge to the
            // capture region edge.  Invert to get the capture region edge coordinates.
            //   Top anchor:    captureTop    = element.Top    − distanceToEdge
            //   Bottom anchor: captureBottom = element.Bottom + distanceToEdge
            //   Left anchor:   captureLeft   = element.Left   − distanceToEdge
            //   Right anchor:  captureRight  = element.Right  + distanceToEdge
            var captureTop    = TopRect.Top      - top.DistanceToEdge;
            var captureBottom = BottomRect.Bottom + bottom.DistanceToEdge;
            var captureLeft   = LeftRect.Left    - left.DistanceToEdge;
            var captureRight  = RightRect.Right  + right.DistanceToEdge;

            var width  = captureRight - captureLeft;
            var height = captureBottom - captureTop;

            if (width <= 0 || height <= 0)
            {
                SquadDashTrace.Write("Screenshot",
                    $"[{e.DefinitionName}] Live anchor resolution produced zero/negative size; falling back to stored bounds.");
                return null;
            }

            var dpiScale = VisualTreeHelper.GetDpi(this);
            SquadDashTrace.Write("Screenshot",
                $"[{e.DefinitionName}] Live bounds resolved: ({captureLeft:F1}, {captureTop:F1}) {width:F1}×{height:F1}");

            return new Screenshots.CaptureBounds(
                captureLeft, captureTop, width, height,
                dpiScale.DpiScaleX, dpiScale.DpiScaleY);
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Screenshot",
                $"[{e.DefinitionName}] Live anchor resolution threw: {ex.Message}; falling back to stored bounds.");
            return null;
        }
    }

    private Rect GetElementBoundsInWindow(FrameworkElement element)
    {
        try
        {
            var transform = element.TransformToAncestor(this);
            var origin    = transform.Transform(new Point(0, 0));
            return new Rect(origin.X, origin.Y, element.ActualWidth, element.ActualHeight);
        }
        catch { return Rect.Empty; }
    }

    private async Task InitializeWorkspace(string? startupFolder)
    {
        var initWsSw = Stopwatch.StartNew();
        SquadDashTrace.Write(TraceCategory.Startup, "InitializeWorkspace: begin.");
        _settingsSnapshot = _settingsStore.Load();
        SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: settings loaded {initWsSw.ElapsedMilliseconds}ms.");
        _promptFontSize = Math.Clamp(_settingsSnapshot.PromptFontSize, PromptFontSizeMin, PromptFontSizeMax);
        _transcriptFontSize = Math.Clamp(_settingsSnapshot.TranscriptFontSize, TranscriptFontSizeMin, TranscriptFontSizeMax);
        _docSourceFontSize = Math.Clamp(_settingsSnapshot.DocSourceFontSize, DocSourceFontSizeMin, DocSourceFontSizeMax);
        _fontScaleLevel = Math.Clamp(_settingsSnapshot.FontSizeScaleLevel, 0, FontScaleFactors.Length - 1);
        _squadCliAdapter.LastObservedModel = _settingsSnapshot.LastUsedModel;
        ApplyViewMode();
        ApplyPromptFontSize();
        ApplyTranscriptFontSize();
        ApplyDocSourceFontSize();
        ApplyFontSizeScale();
        _themePreference = _settingsSnapshot.Theme ?? "Auto";
        var resolvedTheme = string.Equals(_themePreference, "Auto", StringComparison.OrdinalIgnoreCase)
            ? GetWindowsTheme()
            : _themePreference;
        ApplyTheme(resolvedTheme);
        RefreshRecentFoldersMenu(_settingsSnapshot.RecentFolders);
        UpdateVoiceHintVisibility();
        SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: theme/UI applied {initWsSw.ElapsedMilliseconds}ms.");
        RefreshInstallationState();
        RefreshDeveloperRuntimeIssuePreview();
        SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: installation state refreshed {initWsSw.ElapsedMilliseconds}ms.");

        var candidateFolder = _noWorkspaceOnStart
            ? null
            : StartupWorkspaceResolver.Resolve(
                startupFolder,
                _settingsSnapshot.LastOpenedFolder,
                TryGetApplicationRoot());

        if (!string.IsNullOrWhiteSpace(candidateFolder))
        {
            SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: opening workspace at {initWsSw.ElapsedMilliseconds}ms.");
            await OpenWorkspace(
                candidateFolder,
                rememberFolder: true,
                closeWindowIfActivatedExisting: true,
                showBlockedDialog: false);
            SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: complete (with workspace) {initWsSw.ElapsedMilliseconds}ms total.");
            return;
        }

        UpdateWindowTitle();
        RefreshAgentCards();
        RefreshSidebar();
        UpdateInteractiveControlState();
        SyncNoWorkspaceHintOverlay();
        UpdateRunningInstanceRegistration();
        SquadDashTrace.Write(TraceCategory.Startup, $"InitializeWorkspace: complete (no workspace) {initWsSw.ElapsedMilliseconds}ms total.");
    }

    private string? TryGetApplicationRoot()
    {
        try
        {
            return _workspacePaths.ApplicationRoot;
        }
        catch
        {
            return null;
        }
    }

    private async void RunButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null)
            {
                MessageBox.Show(
                    "Open a folder before sending a prompt.",
                    "No Workspace",
                    MessageBoxButton.OK,
                    MessageBoxImage.Information);
                return;
            }

            // Editing a queued tab while the queue is running or paused → return
            // keyboard focus to the main draft instead of dispatching the item.
            // When the queue is idle the original direct-dispatch path is correct.
            if (_activeTabId is not null)
            {
                if (_queuePausedNotificationFired && IsRightmostQueueTabActive())
                {
                    await DispatchRightmostQueuedTabAsQuickReplyAsync(_activeTabId);
                    return;
                }
                if (_isPromptRunning || IsNativeLoopRunning || _queueManuallyPaused || HasPendingDirectQuickReplyAgentFollowUp())
                {
                    OnQueueTabClicked(null);
                    return;
                }
                await DispatchQueuedTabAsync(_activeTabId);
                return;
            }

            var prompt = PromptTextBox.Text.Trim();
            if (string.IsNullOrWhiteSpace(prompt))
                return;

            SquadDashTrace.Write("UI", "Prompt sent: SendButton click");

            // Local-only slash commands(e.g. /trace, /hire) must execute immediately
            // regardless of whether the coordinator is busy or items are queued.
            // PromptExecutionController.TryHandleLocalCommand never touches the AI, so
            // there is no risk of a concurrent AI call.  For /hire specifically, if the
            // user completes a hire while a prompt is running, the PEC's enqueuePrompt
            // callback adds the resulting hire prompt to the back of the queue.
            if (LocalPromptSubmissionPolicy.IsImmediateLocalCommand(prompt))
            {
                await _pec.ExecutePromptAsync(prompt, addToHistory: true, clearPromptBox: true);
                SyncPromptTextBoxSimBorder();
                return;
            }

            if (_isPromptRunning || IsNativeLoopRunning || _queueManuallyPaused || HasPendingDirectQuickReplyAgentFollowUp())
            {
                if (IsNativeLoopRunning && _loopFollowUpTcs == null)
                    _loopInterruptedByQueue = true;
                EnqueueCurrentPrompt();
                return;
            }

            if (_promptHasVoiceInput)
            {
                _promptHasVoiceInput = false;
                prompt += "\n(some or all of this prompt was dictated by voice)";
            }

            _markdownRenderer.DismissKeyboardHint();
            ResetQueuePausedState();
            if (_remoteAccessActive)
                _ = _bridge.BroadcastRcPromptAsync(prompt);
            await _pec.ExecutePromptAsync(ApplyFollowUpHeader(prompt, ""), addToHistory: true, clearPromptBox: true);

            // In fullscreen mode the prompt was peeked temporarily — hide it again now.
            if (_transcriptFullScreenEnabled && _fullScreenPromptVisible)
                HideFullScreenPrompt();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException("Run", ex);
        }
    }

    // ── Prompt Queue ──────────────────────────────────────────────────────────

    private void EnqueuePrompt(string text, bool isSystemInjected)
    {
        if (string.IsNullOrWhiteSpace(text))
            return;

        var item = new PromptQueueItem {
            Text             = text,
            SequenceNumber   = ++_promptQueueSeq,
            IsSystemInjected = isSystemInjected,
        };
        if (isSystemInjected && _activeTabId is null && !_queueManuallyPaused) {
            _promptQueue.EnqueueItemAtFront(item);
            _promptQueue.RenumberSequentially();
        }
        else {
            _promptQueue.EnqueueItem(item);
        }
        SquadDashTrace.Write(
            "Queue",
            $"Enqueued prompt {DescribeQueueItemForTrace(item)} queueCount={_promptQueue.Count}");
        SyncQueuePanel();
        _ = DrainQueueIfNeededAsync();
    }

    private void EnqueueCurrentPrompt()
    {
        var text = PromptTextBox.Text.Trim();
        if (string.IsNullOrWhiteSpace(text)) return;

        var isDictated = _promptHasVoiceInput;
        _promptHasVoiceInput = false;

        _promptQueue.Enqueue(text, ++_promptQueueSeq, isDictated);
        var item = _promptQueue.Items[^1];
        SquadDashTrace.Write("Queue", $"Enqueued current prompt {DescribeQueueItemForTrace(item)} queueCount={_promptQueue.Count}");
        _loopFollowUpTcs?.TrySetResult(true);

        // Transfer any draft follow-up attachments to the new queue item.
        if (_followUpAttachments.TryGetValue("", out var draftList) && draftList.Count > 0)
        {
            _followUpAttachments.Remove("");
            _followUpAttachments[item.Id] = draftList;
        }

        ClearPromptTextBoxLogicalBuffer("enqueue-current");
        SyncQueuePanel();
        UpdateFollowUpStrip();
        PersistDraftFollowUp();
    }

    private void EnqueueRcPrompt(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return;
        _promptQueue.Enqueue(text, ++_promptQueueSeq, isFromRemote: true);
        SquadDashTrace.Write("Queue", $"Enqueued remote prompt {DescribeQueueItemForTrace(_promptQueue.Items[^1])} queueCount={_promptQueue.Count}");
        SyncQueuePanel();
        _ = DrainQueueIfNeededAsync();
    }

    /// <summary>
    /// Ctrl+Enter handler. Moves the active tab to the front of the queue.
    /// If the user is on the Active Draft tab, enqueues the current text at the front.
    /// Shows a transient "« Now at the front of the queue." label in the tab strip.
    /// </summary>
    private void PrioritizeActiveTabToFront()
    {
        if (_activeTabId is null)
        {
            // Active Draft — enqueue at front if there is text.
            var text = PromptTextBox.Text.Trim();
            if (string.IsNullOrWhiteSpace(text)) return;

            RecordHintFeatureUsed(PromptHintFeature.CtrlEnterPrioritize);

            var isDictated = _promptHasVoiceInput;
            _promptHasVoiceInput = false;

            var item = _promptQueue.EnqueueAtFront(text, ++_promptQueueSeq);
            _promptQueue.RenumberSequentially();

            if (_followUpAttachments.TryGetValue("", out var draftList) && draftList.Count > 0)
            {
                _followUpAttachments.Remove("");
                _followUpAttachments[item.Id] = draftList;
            }

            ClearPromptTextBoxLogicalBuffer("prioritize-draft");
            UpdateFollowUpStrip();
            PersistDraftFollowUp();
            ShowPriorityFeedback(item.Id);
            SyncQueuePanel();
        }
        else
        {
            // Queued tab — move it to the front (no-op if already first).
            var capturedId = _activeTabId;
            if (!IsCtrlEnterPrioritizeApplicable()) return;

            RecordHintFeatureUsed(PromptHintFeature.CtrlEnterPrioritize);

            _promptQueue.MoveToFront(capturedId);
            _promptQueue.RenumberSequentially();
            ShowPriorityFeedback(capturedId);
            SyncQueuePanel();

            // Restore prompt box state for the still-active tab.
            var activeItem = _promptQueue.Items.FirstOrDefault(i => i.Id == capturedId);
            if (activeItem is not null)
            {
                SetPromptTextBoxLogicalBuffer(
                    activeItem.Text,
                    activeItem.CaretIndex,
                    activeItem.SelectionStart,
                    activeItem.SelectionLength,
                    "prioritize-restore-active-tab");
            }
        }
    }

    private void AddEmptyQueueSlot()
    {
        _promptQueue.EnqueueAtFront(string.Empty, ++_promptQueueSeq);
        var newId = _promptQueue.Items[0].Id;
        SyncQueuePanel();
        OnQueueTabClicked(newId);
        PromptTextBox.Focus();
    }

    // Appends to the TAIL of the internal list, which SyncQueuePanel renders as the leftmost tab.
    private void AddEmptyQueueSlotAtEnd()
    {
        _promptQueue.Enqueue(string.Empty, ++_promptQueueSeq);
        var newId = _promptQueue.Items[^1].Id;
        SyncQueuePanel();
        OnQueueTabClicked(newId);
        PromptTextBox.Focus();
    }

    /// <summary>
    /// Records <paramref name="id"/> as the recently-prioritized item and starts a 3-second
    /// timer that clears the feedback label once it expires. <see cref="SyncQueuePanel"/> reads
    /// <c>_priorityFeedbackId</c> to inject the "« Now at the front of the queue." label.
    /// </summary>
    private void ShowPriorityFeedback(string id)
    {
        _priorityFeedbackTimer?.Stop();
        _priorityFeedbackId = id;

        var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
        timer.Tick += (_, _) =>
        {
            timer.Stop();
            _priorityFeedbackTimer = null;
            _priorityFeedbackId = null;
            SyncQueuePanel();
        };
        _priorityFeedbackTimer = timer;
        timer.Start();
    }

    private async Task DispatchQueuedTabAsync(string id)
    {
        var item = _promptQueue.Items.FirstOrDefault(i => i.Id == id);
        if (item is null) return;

        var prompt = PromptTextBox.Text.Trim();
        if (string.IsNullOrWhiteSpace(prompt)) return;

        item.Text = prompt;

        // Switch back to Active Draft.
        _activeTabId = null;
        SetPromptTextBoxLogicalBuffer(
            _queuePreEditDraft ?? string.Empty,
            _queuePreEditDraftCaretIndex,
            _queuePreEditDraftSelectionStart,
            _queuePreEditDraftSelectionLength,
            "dispatch-queued-tab-restore-draft");
        _queuePreEditDraft = null;
        UpdateFollowUpStrip();

        if (_isPromptRunning || IsNativeLoopRunning || HasPendingDirectQuickReplyAgentFollowUp())
        {
            // Coordinator busy — item stays in queue with updated text.
            SyncQueuePanel();
            return;
        }

        _promptQueue.Remove(id);
        // Resuming via Send: clear any manual pause so the queue continues after dispatch.
        if (_queueManuallyPaused || _queuePausePending)
            SetQueuePaused(false);
        SyncQueuePanel();

        try
        {
            ResetQueuePausedState();
            await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DispatchQueuedTabAsync), ex);
        }
    }

    private async Task DispatchRightmostQueuedTabAsQuickReplyAsync(string id)
    {
        var item = _promptQueue.Items.FirstOrDefault(i => i.Id == id);
        if (item is null) return;

        var prompt = PromptTextBox.Text.Trim();
        if (string.IsNullOrWhiteSpace(prompt)) return;

        item.Text = prompt;

        var entry = _lastQuickReplyEntry;
        if (entry is not null)
            _pec.DisableQuickReplies(entry);
        ClearActiveQuickReplyState(entry);

        _activeTabId = null;
        SetPromptTextBoxLogicalBuffer(
            _queuePreEditDraft ?? string.Empty,
            _queuePreEditDraftCaretIndex,
            _queuePreEditDraftSelectionStart,
            _queuePreEditDraftSelectionLength,
            "dispatch-queued-tab-as-qr-restore-draft");
        _queuePreEditDraft = null;
        UpdateFollowUpStrip();

        _promptQueue.Remove(id);
        if (_queueManuallyPaused || _queuePausePending)
            SetQueuePaused(false);
        ResetQueuePausedState();
        SyncQueuePanel();

        try
        {
            SquadDashTrace.Write("UI", $"Prompt sent: rightmost queued tab as quick-reply answer id={id}");
            await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DispatchRightmostQueuedTabAsQuickReplyAsync), ex);
        }
        finally
        {
            _loopFollowUpTcs?.TrySetResult(true);
        }
    }

    private PromptQueueItem? GetAutoDispatchCandidate()
    {
        if (_queueManuallyPaused) return null;
        var items = _promptQueue.Items;
        // If the user is editing the rightmost (first-to-dispatch) tab, hold the entire
        // queue so they can finish editing before anything fires.
        if (items.Count > 0 && items[0].Id == _activeTabId)
            return null;
        for (int i = 0; i < items.Count; i++)
        {
            if (items[i].Id != _activeTabId)
                return items[i];
        }
        return null;
    }

    private bool CanAutoDispatchPromptQueue(string reason)
    {
        if (_isPromptRunning || IsNativeLoopRunning || _isClosing || _restartPending)
            return false;

        if (_backgroundTaskPresenter.HasBackgroundTasks())
        {
            SquadDashTrace.Write(
                "Queue",
                $"Auto-dispatch blocked by active background work reason={reason} queueCount={_promptQueue.Count}");
            return false;
        }

        if (HasPendingDirectQuickReplyAgentFollowUp())
        {
            SquadDashTrace.Write(
                "Queue",
                $"Auto-dispatch blocked by direct named-agent quick reply follow-up reason={reason} queueCount={_promptQueue.Count} pending={_directQuickReplyAgentThreadIds.Count}");
            return false;
        }

        if (_loopFollowUpTcs != null)
        {
            SquadDashTrace.Write("Queue", $"Auto-dispatch blocked — loop follow-up TCS pending (loop owns dispatch) reason={reason}");
            return false;
        }

        return true;
    }

    private async Task DrainQueueAsync()
    {
        if (!CanAutoDispatchPromptQueue(nameof(DrainQueueAsync)))
        {
            SquadDashTrace.Write(
                "Queue",
                $"DrainQueueAsync: skipped running={_isPromptRunning} loop={IsNativeLoopRunning} closing={_isClosing} restart={_restartPending} background={_backgroundTaskPresenter.HasBackgroundTasks()}");
            return;
        }

        var item = GetAutoDispatchCandidate();
        if (item is null)
        {
            if (IsRightmostQueueTabActive())
                HandleRightmostTabHold();
            await MaybeFireQueuedLoopAsync();
            return;
        }

        // Silently discard items that have no text and no attachments.
        while (item is not null && IsEmptyQueueItem(item))
        {
            SquadDashTrace.Write("Queue", $"DrainQueueAsync: discarding empty item #{item.SequenceNumber}");
            _promptQueue.Remove(item.Id);
            _followUpAttachments.Remove(item.Id);
            SyncQueuePanel();
            item = GetAutoDispatchCandidate();
        }
        if (item is null)
        {
            if (IsRightmostQueueTabActive())
                HandleRightmostTabHold();
            await MaybeFireQueuedLoopAsync();
            return;
        }

        SquadDashTrace.Write("Queue", $"DrainQueueAsync: dispatching {DescribeQueueItemForTrace(item)} queueCount={_promptQueue.Count}");
        _promptQueue.Remove(item.Id);
        SyncQueuePanel();

        AppendLine("📤 Dispatching queued item…", (Brush)FindResource("SubtleText"));

        try
        {
            if (_remoteAccessActive && !item.IsFromRemote)
                _ = _bridge.BroadcastRcPromptAsync(item.Text);
            _pec.PendingQueueItemCount = _promptQueue.Count;
            _pec.CurrentDispatchedItem = item;
            _queueDrainActive = true;
            _pendingPromptIsSystemInjected = item.IsSystemInjected;
            await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), item.Id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DrainQueueAsync), ex);
        }
        finally
        {
            _queueDrainActive = false;
            _pendingPromptIsSystemInjected = false;
            _pec.PendingQueueItemCount = 0;
            _pec.CurrentDispatchedItem = null;
        }
        // Further drain is triggered by setIsPromptRunning(false) callback.
    }

    private static string ApplyDictationAnnotation(PromptQueueItem item) =>
        item.IsDictated ? item.Text + "\n(some or all of this prompt was dictated by voice)" : item.Text;

    private static string DescribeQueueItemForTrace(PromptQueueItem item) =>
        $"id={ShortQueueItemId(item.Id)} seq={item.SequenceNumber} chars={item.Text.Length} hash={StableQueueTextHash(item.Text)} dictated={item.IsDictated} remote={item.IsFromRemote} system={item.IsSystemInjected} sim={item.IsSimEntry}";

    private static string DescribeQueueForTrace(IReadOnlyList<PromptQueueItem> items) {
        if (items.Count == 0)
            return "[]";

        return "[" + string.Join(", ", items.Select(DescribeQueueItemForTrace)) + "]";
    }

    private string QueueLastChangedForTrace() =>
        _conversationManager.ConversationState.QueueLastChangedAt?.ToUniversalTime().ToString("O") ?? "null";

    private static string ShortQueueItemId(string id) =>
        id.Length <= 8 ? id : id[..8];

    private static string StableQueueTextHash(string text) {
        unchecked
        {
            const uint offset = 2166136261;
            const uint prime = 16777619;
            var hash = offset;
            foreach (var ch in text)
            {
                hash ^= ch;
                hash *= prime;
            }
            return hash.ToString("x8");
        }
    }

    // Returns true when a queued item has no text and no attachments — it should be silently discarded.
    private bool IsEmptyQueueItem(PromptQueueItem item) =>
        string.IsNullOrWhiteSpace(item.Text) &&
        (!_followUpAttachments.TryGetValue(item.Id, out var atts) || atts.Count == 0);

    private async Task DrainQueueIfNeededAsync()
    {
        while (CanAutoDispatchPromptQueue(nameof(DrainQueueIfNeededAsync)) && !LastTurnNeedsInput())
        {
            var item = GetAutoDispatchCandidate();
            if (item is null) break;

            // Silently discard items that have no text and no attachments.
            if (IsEmptyQueueItem(item))
            {
                SquadDashTrace.Write("Queue", $"DrainQueueIfNeededAsync: discarding empty item #{item.SequenceNumber}");
                _promptQueue.Remove(item.Id);
                _followUpAttachments.Remove(item.Id);
                SyncQueuePanel();
                continue;
            }

            SquadDashTrace.Write("Queue", $"DrainQueueIfNeededAsync: dispatching {DescribeQueueItemForTrace(item)} queueCount={_promptQueue.Count}");
            SquadDashTrace.Write("UI", "Prompt sent: queue drain (item dispatched)");
            _promptQueue.Remove(item.Id);
            SyncQueuePanel();

            AppendLine("📤 Dispatching queued item…", (Brush)FindResource("SubtleText"));

            try
            {
                if (_remoteAccessActive && !item.IsFromRemote)
                    _ = _bridge.BroadcastRcPromptAsync(item.Text);
                _pec.PendingQueueItemCount = _promptQueue.Count;
                _pec.CurrentDispatchedItem = item;
                _queueDrainActive = true;
                _pendingPromptIsSystemInjected = item.IsSystemInjected;
                await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), item.Id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException(nameof(DrainQueueIfNeededAsync), ex);
                break;
            }
            finally
            {
                _queueDrainActive = false;
                _pendingPromptIsSystemInjected = false;
                _pec.PendingQueueItemCount = 0;
                _pec.CurrentDispatchedItem = null;
            }
        }

        if (_isClosing || _restartPending)
        {
            SquadDashTrace.Write("Queue", $"DrainQueueIfNeededAsync: aborted closing={_isClosing} restart={_restartPending} remaining={_promptQueue.Count}");
            return;
        }

        if (LastTurnNeedsInput() && _promptQueue.Count > 0)
            HandleQueuePausedForInput();
        else if (IsRightmostQueueTabActive())
            HandleRightmostTabHold();

        await MaybeFireQueuedLoopAsync();
    }

    private void DrainQueueAfterBackgroundWorkChanged(string reason)
    {
        if (_promptQueue.Count == 0)
            return;

        if (!CanAutoDispatchPromptQueue(reason))
            return;

        if (LastTurnNeedsInput())
        {
            HandleQueuePausedForInput();
            return;
        }

        if (GetAutoDispatchCandidate() is not null)
            _ = DrainQueueIfNeededAsync();
        else if (IsRightmostQueueTabActive())
            HandleRightmostTabHold();
    }

    /// <summary>
    /// Drains all queued prompts one at a time before a loop iteration fires.
    /// Unlike the normal drain paths this runs while <see cref="IsNativeLoopRunning"/>
    /// is true — the loop intentionally pauses to let queued items complete first.
    /// </summary>
    private async Task DrainQueueBeforeLoopIterationAsync()
    {
        bool dispatched = false;
        while (!_isPromptRunning && !_isClosing && !_restartPending && !HasPendingDirectQuickReplyAgentFollowUp() && !LastTurnNeedsInput())
        {
            var item = GetAutoDispatchCandidate();
            if (item is null) break;

            // Silently discard items that have no text and no attachments.
            if (IsEmptyQueueItem(item))
            {
                SquadDashTrace.Write("Queue", $"DrainQueueBeforeLoopIterationAsync: discarding empty item #{item.SequenceNumber}");
                _promptQueue.Remove(item.Id);
                _followUpAttachments.Remove(item.Id);
                SyncQueuePanel();
                continue;
            }

            dispatched = true;
            SquadDashTrace.Write("Queue", $"DrainQueueBeforeLoopIterationAsync: dispatching {DescribeQueueItemForTrace(item)} queueCount={_promptQueue.Count}");
            _promptQueue.Remove(item.Id);
            SyncQueuePanel();

            AppendLine("📤 Dispatching queued item…", (Brush)FindResource("SubtleText"));

            try
            {
                if (_remoteAccessActive && !item.IsFromRemote)
                    _ = _bridge.BroadcastRcPromptAsync(item.Text);
                _pec.PendingQueueItemCount = _promptQueue.Count;
                _pec.CurrentDispatchedItem = item;
                _queueDrainActive = true;
                _pendingPromptIsSystemInjected = item.IsSystemInjected;
                await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), item.Id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException(nameof(DrainQueueBeforeLoopIterationAsync), ex);
                break;
            }
            finally
            {
                _queueDrainActive = false;
                _pendingPromptIsSystemInjected = false;
                _pec.PendingQueueItemCount = 0;
                _pec.CurrentDispatchedItem = null;
            }
        }

        // A queued prompt produced quick replies that need user input before the loop
        // can continue.  Stop the loop gracefully (the interrupt flag causes
        // OnNativeLoopStopped to re-queue it so it resumes once the user responds).
        // Only interrupt if we actually dispatched items this cycle — stale quick-reply
        // state from the loop's own previous iteration must not be treated as a pause signal.
        if (dispatched && LastTurnNeedsInput())
        {
            _loopInterruptedByQueue = true;
            SquadDashTrace.Write("Queue", "Loop paused — dispatched queue item produced quick replies requiring user input; _loopInterruptedByQueue set, loop RequestStop called.");
            _loopController.RequestStop();
            HandleQueuePausedForInput();
        }
    }

    private bool LastTurnNeedsInput()
    {
        // Quick replies are active (AI gave the user choices to pick from).
        if (_lastQuickReplyEntry?.AllowQuickReplies == true)
        {
            SquadDashTrace.Write("Queue", "LastTurnNeedsInput: true — AllowQuickReplies is set on _lastQuickReplyEntry.");
            return true;
        }

        // AI explicitly signalled it needs human input before the queue continues.
        var lastResponseText = CoordinatorThread.CurrentTurn?.ResponseTextBuilder.ToString();
        var hasSentinel = lastResponseText?.Contains(PromptExecutionController.QueueAwaitInputSentinel,
                                                     StringComparison.Ordinal) == true;
        if (hasSentinel)
            SquadDashTrace.Write("Queue", "LastTurnNeedsInput: true — QueueAwaitInputSentinel found in last response text.");
        return hasSentinel;
    }

    private bool _queuePausedNotificationFired;
    private bool _rightmostTabHoldNotificationFired;

    private void HandleQueuePausedForInput()
    {
        // Only show the message once per pause to avoid spam.
        if (_queuePausedNotificationFired) return;
        _queuePausedNotificationFired = true;
        SquadDashTrace.Write("Queue", "Loop paused for user input — quick reply notification fired.");

        SyncSendButton();
        BuildShortcutsHint();
        SetQueuePaused(_queueManuallyPaused);

        _ = _pushNotificationService.NotifyEventAsync(
            "quick_reply_needed",
            "SquadDash",
            "AI needs your input before the queue continues.");
    }

    private void ResetQueuePausedState()
    {
        _queuePausedNotificationFired = false;
        _rightmostTabHoldNotificationFired = false;
        SyncSendButton();
        BuildShortcutsHint();
    }

    /// <summary>
    /// Returns true when the currently active queue tab is the rightmost one —
    /// i.e. the oldest/first-to-dispatch item (<c>items[0]</c>) is being edited.
    /// In this state the entire queue is held until the user clicks Send or switches tabs.
    /// </summary>
    private bool IsRightmostQueueTabActive() =>
        _activeTabId is not null &&
        _promptQueue.Count > 0 &&
        _promptQueue.Items[0].Id == _activeTabId;

    private int? GetActiveQueueTabIndex()
    {
        if (_activeTabId is null) return null;
        for (var i = 0; i < _promptQueue.Items.Count; i++)
            if (_promptQueue.Items[i].Id == _activeTabId) return i;
        return null;
    }

    private void HandleRightmostTabHold()
    {
        if (_rightmostTabHoldNotificationFired) return;
        _rightmostTabHoldNotificationFired = true;

        AppendLine("✋ Queued prompt is active — paused for edits. Click **Send** (or click another tab) to submit immediately.");

        SyncSendButton();
    }


    private async Task MaybeFireQueuedLoopAsync()
    {
        bool shouldResume = _loopQueued || _loopInterruptedByQueue || _loopPausedForQuickReply;
        if (!shouldResume || _isPromptRunning || IsLoopRunning) return;
        bool wasQueueResume = _loopQueued || _loopInterruptedByQueue;
        bool wasQrResume    = _loopPausedForQuickReply;
        _loopQueued = false;
        _loopInterruptedByQueue = false;
        _loopPausedForQuickReply = false;
        _conversationManager.UpdateQueuedPromptsState(
            _promptQueue.Items, _followUpAttachments,
            queueRightmostHeld: IsRightmostQueueTabActive(),
            loopQueuedToDequeue: false,
            activeDraftSimEntry: _pec.ActiveDraftSimEntry,
            activeTabIndex: GetActiveQueueTabIndex());
        SyncLoopPanel();
        try
        {
            var resumeMsg = wasQueueResume
                ? $"▶ Loop starting — {LoopTimestamp()} — queue drained."
                : $"▶ Loop resuming — {LoopTimestamp()} — quick replies answered.";
            AppendLoopOutputLine(resumeMsg, LoopLifecycleBrush);
            AppendLine("▶ Starting queued loop…", (Brush)FindResource("SubtleText"));
            await StartLoopImmediateAsync(resumeFromIteration: _loopCurrentIteration);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MaybeFireQueuedLoopAsync), ex);
        }
    }

    private void SyncQueuePanel()
    {
        var swFull = Stopwatch.StartNew();
        var items = _promptQueue.Items;
        QueueTabBorder.Visibility = items.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
        QueueTabStrip.Children.Clear();

        if (items.Count > 0)
        {
            QueueTabStrip.Children.Add(CreateQueueTab(null, "Active Draft",
                tooltip: "This prompt has not been queued yet."));
            // Render newest item first (left) → oldest item last (far right).
            // The next-to-dispatch item is the first non-editing item in the list,
            // not necessarily the one with SequenceNumber==1 (items may have been dequeued
            // leaving gaps, so we identify the candidate by Id rather than by sequence number).
            var nextReadyId = items.FirstOrDefault(i => !i.IsEditing)?.Id;
            foreach (var item in items.Reverse())
            {
                bool isNext = item.Id == nextReadyId;
                var label = isNext ? $"Queue #{item.SequenceNumber}" : $"#{item.SequenceNumber}";
                var tooltip = isNext ? "This prompt is next in the Squad queue."
                                     : "This item is in the Squad queue.";
                QueueTabStrip.Children.Add(CreateQueueTab(item.Id, label, tooltip));
                if (item.Id == _priorityFeedbackId)
                    QueueTabStrip.Children.Add(CreatePriorityFeedbackLabel());
            }

        }

        SyncQueueTabHint();
        _conversationManager.UpdateQueuedPromptsState(items, _followUpAttachments, queueRightmostHeld: IsRightmostQueueTabActive(), loopQueuedToDequeue: _loopQueued, activeDraftSimEntry: _pec.ActiveDraftSimEntry, activeTabIndex: GetActiveQueueTabIndex());
        SyncSendButton();
        BuildShortcutsHint();
        // Keep play/pause button icon/label in sync with current paused state.
        SetQueuePaused(_queueManuallyPaused);
        SyncPromptTextBoxSimBorder();
        SquadDashTrace.Write(TraceCategory.Performance,
            $"SyncQueuePanel: full rebuild of {items.Count} queued tabs in {swFull.ElapsedMilliseconds}ms");
        // Keep loop status in sync when queue count changes while loop is queued.
        if (_loopQueued)
            SyncLoopPanel();
    }

    private void SyncQueueTabHint()
    {
        // Remove any existing hint block.
        var existing = QueueTabStrip.Children.OfType<TextBlock>()
                           .FirstOrDefault(tb => tb.Tag as string == "QueueActiveTabHint");
        if (existing is not null)
            QueueTabStrip.Children.Remove(existing);

        if (_activeTabId is null) return;  // draft tab — no hint needed

        // When the active tab IS the rightmost tab and the queue is already paused
        // waiting for it, the warning is redundant — suppress it.
        if (IsRightmostQueueTabActive() && !_isPromptRunning) return;

        var items = _promptQueue.Items;
        var nextReadyId = items.FirstOrDefault(i => !i.IsEditing)?.Id;
        var activeItem = items.FirstOrDefault(i => i.Id == _activeTabId);
        if (activeItem is null) return;

        bool isNext = activeItem.Id == nextReadyId;
        var activeTabLabel = isNext ? $"Queue #{activeItem.SequenceNumber}" : $"#{activeItem.SequenceNumber}";
        var hint = new TextBlock
        {
            Text = $"Automatic prompting will pause when it's time to send this active tab (\"{activeTabLabel}\")",
            VerticalAlignment = VerticalAlignment.Center,
            Margin = new Thickness(10, 0, 8, 0),
            FontStyle = FontStyles.Italic,
            Tag = "QueueActiveTabHint",
        };
        hint.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeSmall");
        hint.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
        QueueTabStrip.Children.Add(hint);
    }

    private void SetQueuePaused(bool paused)
    {
        _queueManuallyPaused = paused;
        if (paused && _isPromptRunning)
        {
            // Case 2: Pause requested while a turn is in progress → pending.
            _queuePausePending = true;
            QueueStatusLabel.Text = "Pausing after turn completes\u2026";
            QueuePlayPauseButton.ToolTip = "Click to cancel pause and resume automatic prompt queue processing.";
            // Show play icon ▶ — clicking again will cancel the pending pause.
            QueuePlayIcon.Visibility  = Visibility.Visible;
            QueuePauseIcon.Visibility = Visibility.Collapsed;
            // Pending is not fully-paused — ensure no stale styling.
            QueueStatusLabel.ClearValue(TextBlock.BackgroundProperty);
            QueueStatusLabel.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
        }
        else
        {
            _queuePausePending = false;
            var qrbSuffix = _queuePausedNotificationFired ? " (AI waiting for your response)" : string.Empty;

            QueuePlayPauseButton.ToolTip = paused ? "Click to resume automatic prompt queue processing." : "Click to pause automatic prompt queue processing.";
            // Icon shows what clicking will DO (inverted from current state):
            //   Running → pause icon ⏸ (click will pause)
            //   Paused  → play icon  ▶ (click will resume)
            QueuePlayIcon.Visibility  = paused ? Visibility.Visible   : Visibility.Collapsed;
            QueuePauseIcon.Visibility = paused ? Visibility.Collapsed : Visibility.Visible;

            // High-contrast styling: black/yellow when paused, default when running.
            if (paused)
            {
                QueueStatusLabel.Text = $"Queue is paused{qrbSuffix}";
                QueueStatusLabel.Background = Brushes.Black;
                QueueStatusLabel.Foreground = Brushes.Yellow;
            }
            else if (IsRightmostQueueTabActive())
            {
                // Queue is draining but blocked by the active (rightmost) tab.
                // Style the "(active tab pausing dispatch)" portion yellow-on-black,
                // matching the manual-pause indicator, to signal the hold clearly.
                QueueStatusLabel.ClearValue(TextBlock.BackgroundProperty);
                QueueStatusLabel.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
                QueueStatusLabel.Inlines.Clear();
                QueueStatusLabel.Inlines.Add(new Run("Queue is draining "));
                QueueStatusLabel.Inlines.Add(new Run("(active tab pausing dispatch)")
                {
                    Foreground = Brushes.Yellow,
                    Background = Brushes.Black,
                });
            }
            else
            {
                QueueStatusLabel.Text = $"Queue is draining{qrbSuffix}";
                QueueStatusLabel.ClearValue(TextBlock.BackgroundProperty);
                QueueStatusLabel.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
            }
        }
        // Save true when paused, false (not null) when resuming. Using null would cause the
        // merge logic in SaveDocsPanelState to preserve any stale "true" from a prior session.
        _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with { QueuePaused = paused ? (bool?)true : false };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void SyncPromptTextBoxSimBorder()
    {
        bool isSim = _activeTabId is not null
            ? _promptQueue.Items.FirstOrDefault(i => i.Id == _activeTabId)?.IsSimEntry == true
            : _pec.ActiveDraftSimEntry.HasValue;

        if (isSim)
        {
            PromptTextBox.BorderThickness = new Thickness(3);
            PromptTextBox.BorderBrush = Brushes.MediumOrchid;
        }
        else
        {
            PromptTextBox.ClearValue(TextBox.BorderThicknessProperty);
            PromptTextBox.ClearValue(TextBox.BorderBrushProperty);
        }
    }

    private void SyncQueuePauseLabel()
    {
        if (_queuePausePending)
        {
            _queuePausePending = false;
            QueueStatusLabel.Text = "Queue is paused";
            // Turn has completed so the pending pause is now fully in effect.
            // Icon is already showing play ▶ (set during Case 2); update tooltip only.
            QueuePlayPauseButton.ToolTip = "Click to resume automatic prompt queue processing.";
            // Apply high-contrast styling now that pause is fully active.
            QueueStatusLabel.Background = Brushes.Black;
            QueueStatusLabel.Foreground = Brushes.Yellow;
        }
        else
        {
            // Turn completed with no pending pause — ensure no stale styling remains.
            QueueStatusLabel.ClearValue(TextBlock.BackgroundProperty);
            QueueStatusLabel.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
        }
    }

    private void QueuePlayPauseButton_Click(object sender, RoutedEventArgs e)
    {
        if (_queuePausePending)
        {
            // Case 3: Cancel the pending pause — turn is still running but we resume.
            SetQueuePaused(false);
            return;
        }

        if (_queueManuallyPaused)
        {
            // Case 5: Resume from fully-paused state.
            SetQueuePaused(false);

            // If the rightmost tab is holding the queue, resume by submitting it directly.
            if (_rightmostTabHoldNotificationFired && _activeTabId is not null)
                _ = DispatchQueuedTabAsync(_activeTabId);
            else
                _ = DrainQueueIfNeededAsync();
        }
        else
        {
            // Case 1 or 2: Pause (or schedule pause if a turn is active).
            SetQueuePaused(true);
        }
    }

    /// <summary>
    /// Fast path for Ctrl+Tab cycling: only updates the two tabs whose active/inactive
    /// state changed, without tearing down and rebuilding the entire tab strip.
    /// </summary>
    private void FastSyncQueueTabActiveState(string? oldId, string? newId)
    {
        var sw = Stopwatch.StartNew();

        Border? oldBorder = null;
        Border? newBorder = null;
        TextBlock? hintBlock = null;

        foreach (UIElement child in QueueTabStrip.Children)
        {
            if (child is TextBlock tb)       { hintBlock = tb; continue; }
            if (child is not Border b)        continue;
            string? tabId = b.Tag as string;
            if (tabId == oldId || (tabId is null && oldId is null)) oldBorder = b;
            if (tabId == newId || (tabId is null && newId is null)) newBorder = b;
        }

        if (oldBorder is not null) ApplyQueueTabActiveState(oldBorder, isActive: false, isQueueItem: oldId is not null);
        if (newBorder is not null) ApplyQueueTabActiveState(newBorder, isActive: true,  isQueueItem: newId is not null);
        UpdateQueueTabHint(hintBlock, newId);

        _conversationManager.UpdateQueuedPromptsState(
            _promptQueue.Items, _followUpAttachments, queueRightmostHeld: IsRightmostQueueTabActive(), loopQueuedToDequeue: _loopQueued, activeDraftSimEntry: _pec.ActiveDraftSimEntry, activeTabIndex: GetActiveQueueTabIndex());
        SyncSendButton();

        SquadDashTrace.Write(TraceCategory.Performance,
            $"FastSyncQueueTabActiveState: {oldId ?? "draft"}→{newId ?? "draft"} in {sw.ElapsedMilliseconds}ms");
    }

    /// <summary>
    /// Applies active or inactive visual styling to an existing tab Border element.
    /// Handles TextBlock-only tabs and StackPanel tabs (with optional paperclip/pause icons).
    /// </summary>
    private void ApplyQueueTabActiveState(Border tab, bool isActive, bool isQueueItem)
    {
        tab.BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0);
        if (isActive)
            tab.SetResourceReference(Border.BorderBrushProperty, "QueueTabActiveBorder");
        else
            tab.BorderBrush = Brushes.Transparent;

        bool showPause = isQueueItem && isActive;

        void StyleText(TextBlock tb)
        {
            tb.FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal;
            tb.SetResourceReference(TextBlock.ForegroundProperty,
                isActive ? "LabelText" : "QueueTabInactiveText");
        }

        if (tab.Child is TextBlock directText)
        {
            StyleText(directText);
            if (showPause)
            {
                // Promote from single TextBlock to StackPanel to accommodate the pause icon.
                tab.Child = null; // detach before reparenting
                var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
                sp.Children.Add(directText);
                sp.Children.Add(CreatePauseIcon());
                tab.Child = sp;
            }
        }
        else if (tab.Child is StackPanel panel)
        {
            if (panel.Children.Count > 0 && panel.Children[0] is TextBlock tbFirst)
                StyleText(tbFirst);

            // Update paperclip icon color (it tracks active state).
            foreach (UIElement c in panel.Children)
            {
                if (c is System.Windows.Shapes.Path pp && pp.Data == s_paperclipGeometry)
                    pp.SetResourceReference(System.Windows.Shapes.Path.FillProperty,
                        isActive ? "LabelText" : "QueueTabInactiveText");
            }

            // Add or remove pause icon.
            System.Windows.Shapes.Path? existingPause = null;
            foreach (UIElement c in panel.Children)
            {
                if (c is System.Windows.Shapes.Path pp && pp.Data == s_pauseGeometry)
                { existingPause = pp; break; }
            }

            if (showPause && existingPause is null)
            {
                panel.Children.Add(CreatePauseIcon());
            }
            else if (!showPause && existingPause is not null)
            {
                panel.Children.Remove(existingPause);
                // Demote back to plain TextBlock when no icons remain.
                if (panel.Children.Count == 1 && panel.Children[0] is TextBlock onlyText)
                {
                    panel.Children.Clear(); // detach before reparenting
                    tab.Child = onlyText;
                }
            }
        }
    }

    /// <summary>
    /// Updates (or adds/removes) the italic hint TextBlock at the end of the queue tab strip
    /// that warns the user about automatic queue pausing when a queued tab is active.
    /// </summary>
    private void UpdateQueueTabHint(TextBlock? existingHint, string? newActiveId)
    {
        string? activeTabLabel = null;
        if (newActiveId is not null)
        {
            var item = _promptQueue.Items.FirstOrDefault(i => i.Id == newActiveId);
            if (item is not null)
            {
                var nextReadyId = _promptQueue.Items.FirstOrDefault(i => !i.IsEditing)?.Id;
                activeTabLabel = item.Id == nextReadyId
                    ? $"Queue #{item.SequenceNumber}"
                    : $"#{item.SequenceNumber}";
            }
        }

        // Suppress the hint when the queue is already paused because the active tab is
        // the rightmost (first-to-dispatch) tab and no turn is in progress.
        bool rightmostPaused = newActiveId is not null
            && _promptQueue.Count > 0
            && _promptQueue.Items[0].Id == newActiveId
            && !_isPromptRunning;
        if (rightmostPaused)
            activeTabLabel = null;

        if (activeTabLabel is not null)
        {
            var text = $"Automatic prompting will pause when it's time to send this active tab (\"{activeTabLabel}\")";
            if (existingHint is not null)
            {
                existingHint.Text = text;
            }
            else
            {
                var hint = new TextBlock
                {
                    Text = text,
                    VerticalAlignment = VerticalAlignment.Center,
                    Margin = new Thickness(10, 0, 8, 0),
                    FontStyle = FontStyles.Italic,
                };
                hint.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeSmall");
                hint.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
                QueueTabStrip.Children.Add(hint);
            }
        }
        else if (existingHint is not null)
        {
            QueueTabStrip.Children.Remove(existingHint);
        }
    }

    private static readonly Geometry s_paperclipGeometry = Geometry.Parse(
        "M16.5,6 V17.5 C16.5,19.71 14.71,21.5 12.5,21.5 C10.29,21.5 8.5,19.71 8.5,17.5 V5 C8.5,3.62 9.62,2.5 11,2.5 C12.38,2.5 13.5,3.62 13.5,5 V15.5 C13.5,16.05 13.05,16.5 12.5,16.5 C11.95,16.5 11.5,16.05 11.5,15.5 V6 H10 V15.5 C10,16.88 11.12,18 12.5,18 C13.88,18 15,16.88 15,15.5 V5 C15,2.79 13.21,1 11,1 C8.79,1 7,2.79 7,5 V17.5 C7,20.54 9.46,23 12.5,23 C15.54,23 18,20.54 18,17.5 V6 H16.5 Z");

    // Two vertical bars — standard pause symbol
    private static readonly Geometry s_pauseGeometry = Geometry.Parse(
        "M3,1 H6 V13 H3 Z M8,1 H11 V13 H8 Z");

    private UIElement CreatePriorityFeedbackLabel()
    {
        var tb = new TextBlock
        {
            Text              = "« Now at the front of the queue.",
            FontStyle         = FontStyles.Italic,
            VerticalAlignment = VerticalAlignment.Center,
            Margin            = new Thickness(8, 0, 8, 0),
        };
        tb.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeSmall");
        tb.SetResourceReference(TextBlock.ForegroundProperty, "QueueTabActiveBorder");
        return tb;
    }

    private UIElement CreatePaperclipIcon(bool isActive)
    {
        var path = new System.Windows.Shapes.Path
        {
            Data             = s_paperclipGeometry,
            Stretch          = Stretch.Uniform,
            Width            = 10,
            Height           = 10,
            StrokeThickness  = 0,
            VerticalAlignment  = VerticalAlignment.Center,
            // Half a space-width gap between number and clip (~3px at 12px font)
            Margin = new Thickness(3, 0, 0, 0),
        };
        path.SetResourceReference(
            System.Windows.Shapes.Path.FillProperty,
            isActive ? "LabelText" : "QueueTabInactiveText");
        return path;
    }

    private UIElement CreatePauseIcon()
    {
        var path = new System.Windows.Shapes.Path
        {
            Data              = s_pauseGeometry,
            Stretch           = Stretch.Uniform,
            Width             = 9,
            Height            = 10,
            StrokeThickness   = 0,
            VerticalAlignment = VerticalAlignment.Center,
            Margin            = new Thickness(4, 0, 0, 0),
            Opacity           = 0.50,
        };
        path.SetResourceReference(System.Windows.Shapes.Path.FillProperty, "LabelText");

        var tipText = new TextBlock
        {
            Text        = "This tab is active. Automatic queuing will pause when this prompt is reached. Select the Active Draft tab for uninterrupted prompt queuing.",
            TextWrapping = TextWrapping.Wrap,
            MaxWidth    = 280,
        };
        ToolTipService.SetToolTip(path, new ToolTip { Content = tipText });
        return path;
    }

    private UIElement CreateQueueTab(string? id, string label, string? tooltip = null)
    {
        bool isActive = _activeTabId == id;

        var textBlock = new TextBlock
        {
            Text = label,
            FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
            VerticalAlignment = VerticalAlignment.Center,
        };
        textBlock.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeBody");
        // SetResourceReference keeps the brush live — updates automatically on theme switch.
        textBlock.SetResourceReference(
            TextBlock.ForegroundProperty,
            isActive ? "LabelText" : "QueueTabInactiveText");

        var tabKey = id ?? "";
        bool hasAttachment = _followUpAttachments.TryGetValue(tabKey, out var attachList) && attachList.Count > 0;
        bool showPause = id is not null && isActive;

        UIElement tabChild;
        if (hasAttachment || showPause)
        {
            var sp = new StackPanel
            {
                Orientation = Orientation.Horizontal,
                VerticalAlignment = VerticalAlignment.Center,
            };
            sp.Children.Add(textBlock);
            if (hasAttachment)
                sp.Children.Add(CreatePaperclipIcon(isActive));
            if (showPause)
                sp.Children.Add(CreatePauseIcon());
            tabChild = sp;
        }
        else
        {
            tabChild = textBlock;
        }

        object? tipContent = null;
        if (tooltip is not null)
        {
            if (isActive && id is not null)
            {
                // Rich two-line tooltip: base hint + pause warning with bold inline.
                var hintBlock = new TextBlock
                {
                    Text = tooltip,
                    TextWrapping = TextWrapping.Wrap,
                    MaxWidth = 320,
                };
                var pauseBlock = new TextBlock
                {
                    TextWrapping = TextWrapping.Wrap,
                    MaxWidth = 320,
                    Margin = new Thickness(0, 4, 0, 0),
                    Opacity = 0.8,
                };
                pauseBlock.Inlines.Add(new System.Windows.Documents.Run("Because this tab is active, automatic queuing will pause when this prompt is reached. Select the "));
                pauseBlock.Inlines.Add(new System.Windows.Documents.Bold(new System.Windows.Documents.Run("Active Draft")));
                pauseBlock.Inlines.Add(new System.Windows.Documents.Run(" tab for uninterrupted prompt queuing."));
                var tipPanel = new StackPanel { MaxWidth = 320 };
                tipPanel.Children.Add(hintBlock);
                tipPanel.Children.Add(pauseBlock);
                tipContent = new ToolTip { Content = tipPanel };
            }
            else
            {
                tipContent = tooltip;
            }
        }

        var tab = new Border
        {
            Padding = new Thickness(12, 6, 12, 6),
            Margin = new Thickness(0, 0, 1, 0),
            Cursor = Cursors.Hand,
            BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
            Background = Brushes.Transparent,
            Child = tabChild,
            ToolTip = tipContent,
        };
        if (isActive)
            tab.SetResourceReference(Border.BorderBrushProperty, "QueueTabActiveBorder");
        else
            tab.BorderBrush = Brushes.Transparent;

        if (id is not null)
        {
            var capturedId = id;
            var cm = MakeMenu();

            // Activate the tab on right-click so user can see what they're deleting.
            cm.Opened += (_, _) => OnQueueTabClicked(capturedId);

            // Only show Prioritize when this item is not already next-to-dispatch (index 0).
            bool isAlreadyFirst = _promptQueue.Items.Count > 0 && _promptQueue.Items[0].Id == capturedId;
            if (!isAlreadyFirst)
            {
                var prioritizeItem = MakeItem("Prioritize — send this next");
                prioritizeItem.Click += (_, _) => OnQueueTabPrioritize(capturedId);
                cm.Items.Add(prioritizeItem);
            }

            var deleteItem = MakeItem("Delete queued item…");
            deleteItem.Click += (_, _) => OnQueueTabDeleteConfirm(capturedId, tab);
            cm.Items.Add(deleteItem);
            tab.ContextMenu = cm;

            // Drag-to-reorder: tag the tab so UpdateDropIndicator can identify it,
            // then wire up the four mouse events needed for the drag lifecycle.
            tab.Tag = capturedId;
            tab.MouseLeftButtonDown += (_, e) => OnQueueTabMouseDown(capturedId, tab, e);
            tab.MouseMove += (_, e) => OnQueueTabMouseMove(e);
            tab.MouseLeftButtonUp += (_, e) => OnQueueTabMouseUp(capturedId, e);
            tab.LostMouseCapture += (_, _) => CancelDrag();
        }
        else
        {
            // Active Draft tab: simple click only — never draggable.
            tab.MouseLeftButtonUp += (_, _) => OnQueueTabClicked(null);
        }

        return tab;
    }

    /// <summary>
    /// Cycles the active queue tab left (reverse=true) or right (reverse=false).
    /// Tab order is: Active Draft (index 0) → queued items left-to-right.
    /// </summary>
    private void CycleQueueTab(bool reverse)
    {
        // Build ordered list matching visual layout: null = Active Draft (leftmost),
        // then items newest→oldest (left→right), mirroring the Reverse() in SyncQueuePanel.
        var tabIds = new List<string?> { null };
        foreach (var item in _promptQueue.Items.Reverse())
            tabIds.Add(item.Id);

        var currentIndex = tabIds.IndexOf(_activeTabId);
        if (currentIndex < 0) currentIndex = 0;

        int nextIndex = reverse
            ? (currentIndex - 1 + tabIds.Count) % tabIds.Count
            : (currentIndex + 1) % tabIds.Count;

        OnQueueTabClicked(tabIds[nextIndex]);
    }

    private void OnQueueTabClicked(string? id)
    {
        if (_activeTabId == id) return;

        var sw = Stopwatch.StartNew();

        // Capture the old tab id before we change it — needed for the fast visual update.
        var previousId = _activeTabId;

        // Capture whether we're leaving the rightmost hold tab before the switch.
        bool wasRightmostHold = IsRightmostQueueTabActive();

        // Save current content + caret before switching.
        if (_activeTabId is null)
        {
            _queuePreEditDraft = PromptTextBox.Text;
            _queuePreEditDraftCaretIndex = PromptTextBox.CaretIndex;
            _queuePreEditDraftSelectionStart = PromptTextBox.SelectionStart;
            _queuePreEditDraftSelectionLength = PromptTextBox.SelectionLength;
        }
        else
        {
            var current = _promptQueue.Items.FirstOrDefault(i => i.Id == _activeTabId);
            if (current is not null)
            {
                current.Text = PromptTextBox.Text;
                current.CaretIndex = PromptTextBox.CaretIndex;
                current.SelectionStart = PromptTextBox.SelectionStart;
                current.SelectionLength = PromptTextBox.SelectionLength;
            }
        }

        _activeTabId = id;

        if (id is null)
        {
            SetPromptTextBoxLogicalBuffer(
                _queuePreEditDraft ?? string.Empty,
                _queuePreEditDraftCaretIndex,
                _queuePreEditDraftSelectionStart,
                _queuePreEditDraftSelectionLength,
                "queue-tab-draft");
            _queuePreEditDraft = null;
        }
        else
        {
            var target = _promptQueue.Items.FirstOrDefault(i => i.Id == id);
            if (target is not null)
            {
                SetPromptTextBoxLogicalBuffer(
                    target.Text,
                    target.CaretIndex,
                    target.SelectionStart,
                    target.SelectionLength,
                    "queue-tab-item");
            }
        }

        long msAfterText = sw.ElapsedMilliseconds;

        // Switching away from the rightmost hold tab releases the hold.
        // Reset the notification flag so it can fire again if re-activated, then drain.
        if (wasRightmostHold)
            _rightmostTabHoldNotificationFired = false;

        // Fast path: only update the two tabs whose visual state changed rather than
        // tearing down and rebuilding the entire tab strip on every Ctrl+Tab cycle.
        FastSyncQueueTabActiveState(previousId, id);
        SetQueuePaused(_queueManuallyPaused);
        long msAfterTabSync = sw.ElapsedMilliseconds;

        PromptTextBox.Focus();
        UpdateFollowUpStrip();

        SquadDashTrace.Write(TraceCategory.Performance,
            $"OnQueueTabClicked: {previousId ?? "draft"}→{id ?? "draft"} | " +
            $"text={msAfterText}ms tabSync={msAfterTabSync - msAfterText}ms " +
            $"followUp={(sw.ElapsedMilliseconds - msAfterTabSync)}ms total={sw.ElapsedMilliseconds}ms");

        if (wasRightmostHold && CanAutoDispatchPromptQueue("rightmost-tab-released"))
            _ = DrainQueueIfNeededAsync();
        SyncPromptTextBoxSimBorder();
    }

    private void OnQueueTabPrioritize(string id)
    {
        _promptQueue.MoveToFront(id);
        _promptQueue.RenumberSequentially();
        SyncQueuePanel();
        // _activeTabId is unchanged — restore prompt box to reflect the still-active tab.
        var activeItem = _activeTabId is not null
            ? _promptQueue.Items.FirstOrDefault(i => i.Id == _activeTabId)
            : null;
        if (activeItem is not null)
        {
            SetPromptTextBoxLogicalBuffer(
                activeItem.Text,
                activeItem.CaretIndex,
                activeItem.SelectionStart,
                activeItem.SelectionLength,
                "queue-prioritize-restore");
        }
    }

    private void OnQueueTabRemove(string id)
    {
        if (_activeTabId == id)
        {
            _activeTabId = null;
            SetPromptTextBoxLogicalBuffer(
                _queuePreEditDraft ?? string.Empty,
                _queuePreEditDraftCaretIndex,
                _queuePreEditDraftSelectionStart,
                _queuePreEditDraftSelectionLength,
                "queue-remove-restore-draft");
            _queuePreEditDraft = null;
            UpdateFollowUpStrip();
        }
        _promptQueue.Remove(id);
        SyncQueuePanel();
    }

    private void OnQueueTabDeleteConfirm(string id, FrameworkElement anchor)
    {
        var item = _promptQueue.Items.FirstOrDefault(i => i.Id == id);
        if (item is null) return;

        // When this item is the active tab, live edits are in PromptTextBox — not
        // yet written back to item.Text (that flush only happens on tab switch).
        // Read the live text so the empty-check reflects what the user sees.
        var effectiveText = _activeTabId == id ? PromptTextBox.Text : item.Text;

        // Empty items have nothing to confirm — delete immediately.
        if (string.IsNullOrWhiteSpace(effectiveText))
        {
            OnQueueTabRemove(id);
            return;
        }

        var preview = effectiveText.Length > 60 ? effectiveText[..57] + "…" : effectiveText;

        var dialog = new QueueItemDeleteConfirmWindow(
            $"#{item.SequenceNumber}",
            preview,
            GetScreenRect(anchor),
            effectiveText)
        {
            Owner = this
        };
        if (dialog.ShowDialog() == true)
            OnQueueTabRemove(id);
    }

    /// <summary>
    /// Handles the Ctrl+Delete keyboard shortcut: deletes the active queue tab,
    /// prompting for confirmation when the item contains text.  Selects the nearest
    /// adjacent tab after deletion (left neighbour preferred; rightmost remaining as
    /// fallback; stays on the draft tab if the queue becomes empty).
    /// </summary>
    private void DeleteActiveQueueTabWithKeyboard()
    {
        if (_activeTabId is null) return;
        var id = _activeTabId;

        var item = _promptQueue.Items.FirstOrDefault(i => i.Id == id);
        if (item is null) return;

        // Active tab's live text lives in PromptTextBox (not yet flushed to item.Text).
        var effectiveText = PromptTextBox.Text;

        if (!string.IsNullOrWhiteSpace(effectiveText))
        {
            var result = MessageBox.Show(
                "Are you sure you want to delete this queued item?",
                "Delete Queue Item",
                MessageBoxButton.YesNo,
                MessageBoxImage.Question);
            if (result != MessageBoxResult.Yes)
                return;
        }

        // Determine adjacent tab before removal.
        // Visual order matches CycleQueueTab: null (draft) at [0], Items.Reverse() after.
        var tabIds = new List<string?> { null };
        foreach (var itm in _promptQueue.Items.Reverse())
            tabIds.Add(itm.Id);

        var currentIndex = tabIds.IndexOf(id);
        string? nextId = currentIndex > 0
            ? tabIds[currentIndex - 1]               // prefer left neighbour
            : tabIds.LastOrDefault(t => t != id);    // rightmost remaining (fallback)

        OnQueueTabRemove(id);

        // Navigate to the adjacent tab; null means the draft tab, which OnQueueTabRemove
        // already restored, so only switch explicitly when nextId is a queue item.
        if (nextId is not null)
            OnQueueTabClicked(nextId);
    }

    // ── Queue tab drag-to-reorder ────────────────────────────────────────────

    /// <summary>
    /// Returns the shared drop-indicator <see cref="Border"/> (a narrow vertical bar),
    /// creating it on first call.  Uses a themed brush so it updates on theme switch.
    /// </summary>
    private Border GetOrCreateDropIndicator()
    {
        if (_dropIndicator is not null) return _dropIndicator;

        _dropIndicator = new Border
        {
            Width = 2,
            VerticalAlignment = VerticalAlignment.Stretch,
            IsHitTestVisible = false,
            Margin = new Thickness(0, 4, 0, 4),
        };
        _dropIndicator.SetResourceReference(Border.BackgroundProperty, "QueueTabActiveBorder");
        return _dropIndicator;
    }

    private void OnQueueTabMouseDown(string id, Border tab, MouseButtonEventArgs e)
    {
        _dragTabId = id;
        _dragStartPoint = e.GetPosition(QueueTabStrip);
        _isDragging = false;
        tab.CaptureMouse();
    }

    private void OnQueueTabMouseMove(MouseEventArgs e)
    {
        if (_dragTabId is null) return;

        var pos = e.GetPosition(QueueTabStrip);

        // Cancel if the cursor leaves the strip's bounds (with a small forgiveness margin).
        const double margin = 12.0;
        if (pos.X < -margin || pos.X > QueueTabStrip.ActualWidth + margin ||
            pos.Y < -margin || pos.Y > QueueTabStrip.ActualHeight + margin)
        {
            CancelDrag();
            return;
        }

        // Allow Escape to abort without dropping.
        if (Keyboard.IsKeyDown(Key.Escape))
        {
            CancelDrag();
            return;
        }

        if (!_isDragging)
        {
            var delta = pos - _dragStartPoint;
            if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold)
                return;   // haven't moved far enough — still treating as potential click
            _isDragging = true;
        }

        UpdateDropIndicator(pos.X);
    }

    private void OnQueueTabMouseUp(string id, MouseButtonEventArgs e)
    {
        if (_dragTabId != id)
        {
            CancelDrag();
            return;
        }

        bool wasDragging = _isDragging;
        string? insertBeforeId = _dragInsertBeforeTabId;

        // Clear drag state (and release capture) before doing any work.
        CancelDrag();

        if (wasDragging)
            CommitQueueDrop(id, insertBeforeId);
        else
            OnQueueTabClicked(id);   // short press with no drag movement → treat as click
    }

    /// <summary>
    /// Clears all drag state, removes the drop indicator, and releases mouse capture.
    /// Safe to call multiple times (idempotent).  Called from LostMouseCapture,
    /// MouseLeftButtonUp, and the Escape / leave-strip cancel paths.
    /// </summary>
    private void CancelDrag()
    {
        // Capture the element reference before clearing state so the release below
        // doesn't attempt to re-enter via the LostMouseCapture handler when _dragTabId
        // has already been nulled out.
        var captured = Mouse.Captured as UIElement;

        _dragTabId = null;
        _isDragging = false;
        _dragInsertBeforeTabId = null;

        if (_dropIndicator is not null && QueueTabStrip.Children.Contains(_dropIndicator))
            QueueTabStrip.Children.Remove(_dropIndicator);

        // Releasing capture fires LostMouseCapture, which calls CancelDrag again —
        // but at that point _dragTabId is null and Mouse.Captured is null, so the
        // second call is a harmless no-op.
        captured?.ReleaseMouseCapture();
    }

    /// <summary>
    /// Repositions the drop indicator inside <see cref="QueueTabStrip"/> based on the
    /// current mouse X coordinate.  Also updates <see cref="_dragInsertBeforeTabId"/>.
    /// </summary>
    private void UpdateDropIndicator(double mouseX)
    {
        var indicator = GetOrCreateDropIndicator();

        // Remove the indicator first so the remaining children reflect the real tab order.
        QueueTabStrip.Children.Remove(indicator);

        // Walk queue tabs (children 1…N; child 0 is the pinned Active Draft tab).
        // Find the first non-dragged tab whose horizontal mid-point is to the right of
        // the cursor — the indicator (and the eventual drop) go *before* that tab.
        int insertAt = QueueTabStrip.Children.Count; // default: after all tabs
        string? insertBeforeId = null;

        for (int i = 1; i < QueueTabStrip.Children.Count; i++)
        {
            if (QueueTabStrip.Children[i] is not FrameworkElement child) continue;

            var tagId = child.Tag as string;
            if (tagId == _dragTabId) continue; // skip the tab that is being dragged

            var childLeft = child.TranslatePoint(new Point(0, 0), QueueTabStrip).X;
            var midX = childLeft + child.ActualWidth / 2;

            if (mouseX < midX)
            {
                insertBeforeId = tagId;
                insertAt = i;
                break;
            }
        }

        _dragInsertBeforeTabId = insertBeforeId;
        QueueTabStrip.Children.Insert(insertAt, indicator);
    }

    /// <summary>
    /// Called on mouse-up after an actual drag gesture.  Maps the visual drop position
    /// back to a logical <see cref="PromptQueue"/> index and performs the reorder.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The strip renders <c>_items.Reverse()</c>, so visual-left = high logical index
    /// (dispatched last) and visual-right = low logical index (dispatched first).
    /// </para>
    /// <para>
    /// "Drop visually LEFT of tab X" means the dragged item is placed AFTER X in
    /// <c>_items</c> (higher logical index, dispatched later).
    /// </para>
    /// </remarks>
    private void CommitQueueDrop(string draggedId, string? insertBeforeId)
    {
        var items = _promptQueue.Items;

        // Locate the dragged item and the right-neighbour in the logical list.
        int dIdx = -1, rIdx = -1;
        for (int i = 0; i < items.Count; i++)
        {
            if (items[i].Id == draggedId) dIdx = i;
            if (items[i].Id == insertBeforeId) rIdx = i;
        }

        if (dIdx < 0) return; // dragged item no longer in queue — nothing to do

        int newIndex;
        if (insertBeforeId is null || rIdx < 0)
        {
            // No right-neighbour → drop at rightmost visual = logical index 0
            // (the item becomes the next to be dispatched).
            newIndex = 0;
        }
        else
        {
            // After removing the dragged item the right-neighbour's index shifts down
            // by 1 if it was logically after (= visually left of) the dragged item.
            int adjRIdx = rIdx > dIdx ? rIdx - 1 : rIdx;

            // Insert dragged AFTER right-neighbour in _items so it lands visually
            // to the LEFT of right-neighbour in the strip.
            newIndex = adjRIdx + 1;
        }

        _promptQueue.Reorder(draggedId, newIndex);
        _promptQueue.RenumberSequentially();
        SyncQueuePanel();
    }

    /// <summary>Returns the bounding rect of a UI element in screen coordinates.</summary>
    private static Rect GetScreenRect(FrameworkElement element)
    {
        var topLeft = element.PointToScreen(new Point(0, 0));
        var bottomRight = element.PointToScreen(new Point(element.ActualWidth, element.ActualHeight));
        return new Rect(topLeft, bottomRight);
    }

    private void SyncSendButton()
    {
        RunButton.Content = RunButtonLabelPolicy.Compute(
            coordinatorBusy: _isPromptRunning || IsNativeLoopRunning,
            queuePausedAwaitingInput: _queuePausedNotificationFired,
            queueCount: _promptQueue.Count,
            activeTabId: _activeTabId,
            queueManuallyPaused: _queueManuallyPaused,
            isRightmostTab: IsRightmostQueueTabActive());
    }

    private async void AbortButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var abortTargets = BuildAbortConfirmationTargets();
            if (abortTargets.Count == 0)
            {
                var selectedThread = _selectedTranscriptThread ?? CoordinatorThread;
                SquadDashTrace.Write(
                    "UI",
                    $"AbortButton clicked but no abortable prompt or background task was resolved for thread={selectedThread.ThreadId}");
                return;
            }

            var dialog = new AbortAgentsConfirmationWindow(
                abortTargets,
                BuildAbortConfirmationTargets,
                GetScreenRect(AbortButton))
            {
                Owner = this
            };

            if (dialog.ShowDialog() != true || dialog.ConfirmedTargets.Count == 0)
            {
                SquadDashTrace.Write("UI", "Stop running work confirmation cancelled.");
                return;
            }

            await StopConfirmedRunningWorkAsync(dialog.ConfirmedTargets).ConfigureAwait(true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException("Abort", ex);
        }
    }

    private IReadOnlyList<AbortAgentsConfirmationTarget> BuildAbortConfirmationTargets()
    {
        var targets = new List<AbortAgentsConfirmationTarget>();
        var seenBackgroundTaskIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        if (_isPromptRunning)
        {
            targets.Add(new AbortAgentsConfirmationTarget(
                "coordinator",
                "coordinator",
                "Coordinator",
                _pec.CurrentPromptStartedAt ?? CoordinatorThread.CurrentTurn?.StartedAt ?? DateTimeOffset.Now,
                IsCoordinator: true));
        }

        foreach (var target in _backgroundTaskPresenter.GetAbortTargets())
        {
            if (string.IsNullOrWhiteSpace(target.TaskId) || !seenBackgroundTaskIds.Add(target.TaskId))
                continue;

            targets.Add(new AbortAgentsConfirmationTarget(
                target.TaskId,
                target.TaskKind,
                target.DisplayLabel,
                target.StartedAt,
                IsCoordinator: false,
                target.AgentId,
                target.ToolCallId,
                target.TaskIdSource));
        }

        return targets;
    }

    private Task StopConfirmedRunningWorkAsync(IReadOnlyList<AbortAgentsConfirmationTarget> targets)
    {
        SquadDashTrace.Write(
            "UI",
            $"Stop running work confirmed targets={targets.Count} coordinator={targets.Any(target => target.IsCoordinator)} background={targets.Count(target => !target.IsCoordinator)} loop={IsLoopRunning} queueCount={_promptQueue.Count}");
        foreach (var target in targets)
        {
            SquadDashTrace.Write(
                "UI",
                $"Stop running work target kind={target.TaskKind} task={target.TaskId} idSource={target.TaskIdSource} coordinator={target.IsCoordinator} agentId={target.AgentId ?? "(none)"} toolCallId={target.ToolCallId ?? "(none)"} label={target.DisplayLabel}");
        }

        if (_promptQueue.Count > 0)
            SetQueuePaused(true);

        if (IsLoopRunning)
            ForceStopLoopForRunningWorkStop();

        ForceFinalizeStoppedBackgroundThreads(targets);

        var result = _bridge.ForceStopRunningWork("abort-button-stop-all");
        _ignoreBridgeEventsThroughGeneration = Math.Max(
            _ignoreBridgeEventsThroughGeneration,
            result.ProcessGeneration);
        if (targets.Any(target => target.IsCoordinator))
            StartCoordinatorAbortWatchdog("force-stop", "[force-stopped]");

        _backgroundTaskPresenter.BackgroundAgents = Array.Empty<SquadBackgroundAgentInfo>();
        _backgroundTaskPresenter.BackgroundShells = Array.Empty<SquadBackgroundShellInfo>();
        _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;

        UpdateCompletedTimeFooters();
        SyncAgentCardsWithThreads();
        _backgroundTaskPresenter.RefreshLeadAgentBackgroundStatus();
        SyncQueuePanel();
        SyncSendButton();
        UpdateInteractiveControlState();
        TryCompletePendingRestart("running-work-force-stopped", emergencySaveBeforeClose: true);

        SquadDashTrace.Write(
            "UI",
            $"Stop running work completed bridgeGeneration={result.ProcessGeneration} hadBridgeProcess={result.HadActiveProcess} ignoredEventsThroughGeneration={_ignoreBridgeEventsThroughGeneration}");
        return Task.CompletedTask;
    }

    /// <summary>
    /// Gap 2 fix: immediately marks a background thread as Cancelled and finalizes it
    /// rather than waiting for the SDK to deliver a subagent_cancelled event (which may
    /// never arrive cleanly after a hard abort).
    /// </summary>
    private void ForceFinalizeCancelledBackgroundThread(string taskId)
    {
        var normalized = taskId.Trim();
        var thread = _agentThreadRegistry.ThreadOrder.FirstOrDefault(t =>
            !AgentThreadRegistry.IsTerminalBackgroundStatus(t.StatusText) &&
            (string.Equals(t.BackgroundTaskId, normalized, StringComparison.OrdinalIgnoreCase) ||
             string.Equals(t.AgentId, normalized, StringComparison.OrdinalIgnoreCase) ||
             string.Equals(t.ToolCallId, normalized, StringComparison.OrdinalIgnoreCase)));
        if (thread is null)
            return;

        SquadDashTrace.Write("UI", $"ForceFinalize cancelled thread agentId={thread.AgentId} title={thread.Title}");
        var summary = BackgroundTaskPresenter.BuildThreadCancellationSummary(thread);
        thread.StatusText = "Cancelled";
        thread.DetailText = summary;
        thread.IsCurrentBackgroundRun = false;
        thread.CompletedAt ??= DateTimeOffset.Now;
        _agentThreadRegistry.FinalizeAgentThread(thread);
        UpdateCompletedTimeFooters();
        _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;
        _backgroundTaskPresenter.AppendBackgroundNotice(
            summary, ThemeBrush("SystemInfoText"),
            BackgroundTaskPresenter.BuildThreadAnnouncementKey(thread) + ":cancelled");
        SyncAgentCardsWithThreads();
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
    }

    private void ForceStopLoopForRunningWorkStop()
    {
        AppendLoopOutputLine("⚡ Loop force-stopped with running work.", new SolidColorBrush(Color.FromRgb(0xFF, 0x88, 0x44)));
        if (_activeLoopMode == LoopMode.NativeAgents)
        {
            _loopController.RequestAbort();
            _loopFollowUpTcs?.TrySetResult(false);
        }
        else
        {
            _pec.SetIsLoopRunning(false);
        }

        SyncLoopPanel();
    }

    private void ForceFinalizeStoppedBackgroundThreads(IReadOnlyList<AbortAgentsConfirmationTarget> targets)
    {
        var finalizedThreads = new HashSet<TranscriptThreadState>(ReferenceEqualityComparer.Instance);
        foreach (var target in targets.Where(target => !target.IsCoordinator))
        {
            var thread = FindThreadForStopTarget(target);
            if (thread is not null)
                ForceFinalizeStoppedBackgroundThread(thread, target.TaskId, finalizedThreads);
        }

        foreach (var thread in _agentThreadRegistry.ThreadOrder.Where(_backgroundTaskPresenter.IsThreadCurrentRunForDisplay).ToArray())
            ForceFinalizeStoppedBackgroundThread(thread, thread.BackgroundTaskId ?? thread.AgentId ?? thread.ToolCallId ?? thread.ThreadId, finalizedThreads);
    }

    private TranscriptThreadState? FindThreadForStopTarget(AbortAgentsConfirmationTarget target)
    {
        return _agentThreadRegistry.ThreadOrder.FirstOrDefault(thread =>
            !AgentThreadRegistry.IsTerminalBackgroundStatus(thread.StatusText) &&
            (MatchesStopTarget(thread.BackgroundTaskId, target.TaskId) ||
             MatchesStopTarget(thread.AgentId, target.TaskId) ||
             MatchesStopTarget(thread.ToolCallId, target.TaskId) ||
             MatchesStopTarget(thread.AgentId, target.AgentId) ||
             MatchesStopTarget(thread.ToolCallId, target.ToolCallId)));
    }

    private static bool MatchesStopTarget(string? left, string? right) =>
        !string.IsNullOrWhiteSpace(left) &&
        !string.IsNullOrWhiteSpace(right) &&
        string.Equals(left.Trim(), right.Trim(), StringComparison.OrdinalIgnoreCase);

    private void ForceFinalizeStoppedBackgroundThread(
        TranscriptThreadState thread,
        string taskId,
        HashSet<TranscriptThreadState> finalizedThreads)
    {
        if (!finalizedThreads.Add(thread))
            return;

        var summary = BackgroundTaskPresenter.BuildThreadForceStopSummary(thread);
        SquadDashTrace.Write(
            "UI",
            $"Force-stopping background thread task={taskId} thread={thread.ThreadId} agentId={thread.AgentId ?? "(none)"} toolCallId={thread.ToolCallId ?? "(none)"} title={thread.Title ?? "(unknown)"}");
        thread.StatusText = "Interrupted";
        thread.DetailText = summary;
        thread.IsCurrentBackgroundRun = false;
        thread.CompletedAt ??= DateTimeOffset.Now;
        _agentThreadRegistry.FinalizeAgentThread(thread);
        CompleteDirectQuickReplyAgentThread(thread, "force_stop_running_work");
        _backgroundTaskPresenter.AppendBackgroundNotice(
            summary,
            ThemeBrush("SystemInfoText"),
            BackgroundTaskPresenter.BuildThreadAnnouncementKey(thread) + ":force-stopped");
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
    }

    /// <summary>
    /// Gap 3 &amp; 4 fix: starts a short watchdog after a coordinator abort.  If
    /// <c>_isPromptRunning</c> is still true after the grace period the bridge task
    /// is assumed to be stuck and we force all prompt-running cleanup locally.
    /// </summary>
    private void StartCoordinatorAbortWatchdog(string reason = "abort", string marker = "[aborted]")
    {
        _ = Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ =>
            Dispatcher.InvokeAsync(() =>
            {
                if (!_isPromptRunning)
                    return;
                SquadDashTrace.Write("UI", $"AbortWatchdog: _isPromptRunning still true after 3 s reason={reason} — forcing coordinator cleanup.");
                ForceCoordinatorAbortCleanup(marker);
            }));
    }

    /// <summary>
    /// Resets all coordinator-turn UI state that would normally be cleared by the
    /// <c>setIsPromptRunning(false)</c> path in <c>ExecutePromptAsync</c>.  Only
    /// called from the abort watchdog when that path appears stuck.
    /// </summary>
    private void ForceCoordinatorAbortCleanup(string marker = "[aborted]")
    {
        _isPromptRunning = false;
        CoordinatorThread.CompletedAt ??= DateTimeOffset.Now;
        // Close any still-spinning coordinator tool entries.
        _agentThreadRegistry.CompleteOutstandingAgentTools(CoordinatorThread);
        AppendLine(marker, ThemeBrush("SystemInfoText"));
        FinalizeCurrentTurnResponse();
        SyncQueuePauseLabel();
        UpdateCompletedTimeFooters();
        SyncQueuePanel();
        UpdateInteractiveControlState();
        UpdateSessionState("Ready");
        SquadDashTrace.Write("UI", "AbortWatchdog: forced coordinator cleanup complete.");
    }

    private void HandleEvent(SquadSdkEvent evt)
    {
        TraceSdkEvent(evt);
        if (ShouldIgnoreForceStoppedBridgeEvent(evt))
            return;

        if (!string.Equals(evt.Type, "sdk_diagnostics", StringComparison.Ordinal))
            _pec.MarkActivity(evt);

        if (!string.IsNullOrWhiteSpace(evt.Model))
        {
            var model = evt.Model.Trim();
            _squadCliAdapter.LastObservedModel = model;
            if (!_modelObservedThisSession ||
                !string.Equals(_settingsSnapshot.LastUsedModel, model, StringComparison.Ordinal))
            {
                _modelObservedThisSession = true;
                _settingsSnapshot = _settingsStore.SaveLastUsedModel(model);
            }
        }

        switch (evt.Type)
        {
            case "session_ready":
                HandleSessionReady(evt);
                break;

            case "session_reset":
                HandleSessionReset(evt);
                break;

            case "thinking_started":
                EnsureCurrentTurnThinkingVisible();
                UpdateLeadAgent("Thinking", string.Empty, "Reasoning through the request.");
                UpdateSessionState("Thinking");
                break;

            case "thinking_delta":
                var thought = NormalizeThinkingChunk(evt.Text);
                if (!string.IsNullOrWhiteSpace(thought))
                {
                    UpdateLeadAgent("Thinking", string.Empty, FormatThinkingText(thought));
                    AppendThinkingText(thought, evt.Speaker);
                }
                break;

            case "tool":
                UpdateLeadAgent(
                    "Tooling",
                    string.Empty,
                    "Waiting for live tool execution events.");
                UpdateSessionState("Using tool");
                break;

            case "tool_start":
                StartToolExecution(evt);
                break;

            case "tool_progress":
                UpdateToolExecution(evt);
                break;

            case "tool_complete":
                CompleteToolExecution(evt);
                break;

            case "response_delta":
                NotePendingQuickReplyCoordinatorResponse();
                AppendText(evt.Chunk ?? string.Empty);
                UpdateLeadAgent("Streaming", string.Empty, "Writing the response.");
                UpdateSessionState("Streaming");
                break;

            case "sdk_diagnostics":
                HandleSdkDiagnostics(evt);
                break;

            case "background_tasks_changed":
                HandleBackgroundTasksChanged(evt);
                break;

            case "task_complete":
                HandleTaskComplete(evt);
                break;

            case "subagent_started":
                HandleSubagentStarted(evt);
                break;

            case "subagent_message_delta":
                HandleSubagentMessageDelta(evt);
                break;

            case "subagent_thinking_delta":
                HandleSubagentThinkingDelta(evt);
                break;

            case "subagent_message":
                HandleSubagentMessage(evt);
                break;

            case "subagent_tool_start":
                HandleSubagentToolStart(evt);
                break;

            case "subagent_tool_progress":
                HandleSubagentToolProgress(evt);
                break;

            case "subagent_tool_complete":
                HandleSubagentToolComplete(evt);
                break;

            case "subagent_completed":
                HandleSubagentCompleted(evt);
                break;

            case "subagent_failed":
                HandleSubagentFailed(evt);
                break;

            case "subagent_cancelled":
                HandleSubagentCancelled(evt);
                break;

            case "loop_started":
                HandleLoopStarted(evt);
                break;

            case "loop_iteration":
                HandleLoopIteration(evt);
                break;

            case "loop_stopped":
                HandleLoopStopped(evt);
                _ = _pushNotificationService.NotifyEventAsync("loop_stopped", "SquadDash", "Loop stopped");
                break;

            case "loop_error":
                HandleLoopError(evt);
                break;

            case "loop_output":
                HandleLoopOutput(evt);
                break;

            case "watch_fleet_dispatched":
                HandleWatchFleetDispatched(evt);
                break;

            case "watch_wave_dispatched":
                HandleWatchWaveDispatched(evt);
                break;

            case "watch_hydration":
                HandleWatchHydration(evt);
                break;

            case "watch_retro":
                HandleWatchRetro(evt);
                break;

            case "watch_monitor_notification":
                HandleWatchMonitorNotification(evt);
                break;

            case "rc_prompt":
                EnqueueRcPrompt(evt.Text ?? string.Empty);
                break;

            case "rc_started":
                HandleRcStarted(evt);
                break;

            case "rc_tunnel_started":
                HandleRcTunnelStarted(evt);
                break;

            case "rc_tunnel_error":
                HandleRcTunnelError(evt);
                break;

            case "rc_stopped":
                HandleRcStopped(evt);
                _ = _pushNotificationService.NotifyEventAsync("rc_connection_dropped", "SquadDash", "Remote connection dropped");
                break;

            case "subsquads_listed":
                HandleSubSquadsListed(evt);
                break;

            case "subsquads_activated":
                HandleSubSquadsActivated(evt);
                break;

            case "subsquads_error":
                HandleSubSquadsError(evt);
                break;

            case "personal_agents_listed":
                HandlePersonalAgentsListed(evt);
                break;

            case "personal_init_done":
                HandlePersonalInitDone(evt);
                break;

            case "personal_error":
                HandlePersonalError(evt);
                break;

            case "rc_error":
                HandleRcError(evt);
                break;

            case "rc_audio_start":
                _ = HandleRcAudioStartAsync(evt);
                break;

            case "rc_audio_chunk":
                HandleRcAudioChunk(evt);
                break;

            case "rc_audio_end":
                _ = HandleRcAudioEndAsync(evt);
                break;

            case "done":
                _pec.ActiveToolName = null;
                var doneCurrentTurn = CoordinatorThread.CurrentTurn;
                FinalizeCurrentTurnResponse();
                CollapseCurrentTurnThinking();
                _conversationManager.SaveCurrentTurnToConversation(DateTimeOffset.Now, "bridge-done-event");
                _backgroundTaskPresenter.RefreshLeadAgentBackgroundStatus();
                FlushDeferredSystemLines();
                {
                    var agentName = _leadAgent?.Name ?? "Agent";
                    var rawResponse = doneCurrentTurn?.ResponseTextBuilder.ToString();
                    var squadashPayload = PushNotificationService.ExtractSquadashPayload(rawResponse);
                    var notifSummary = (squadashPayload?.Notification is { Length: > 0 } sn ? sn : null)
                        ?? PushNotificationService.ExtractNotificationJson(rawResponse)
                        ?? PushNotificationService.BuildFallbackSummary(doneCurrentTurn?.Prompt);
                    var notifMessage = string.IsNullOrWhiteSpace(notifSummary)
                        ? $"{agentName} turn complete"
                        : notifSummary;
                    
                    // Collect tool outputs from main session and all spawned agent threads.
                    // Only include agent thread turns that STARTED at or after this main turn —
                    // SavedTurns accumulates for the entire session lifetime, so without this
                    // guard old turns from previous interactions would re-surface the same
                    // commit SHA on every subsequent turn completion.
                    var turnStartedAt = doneCurrentTurn?.StartedAt ?? DateTimeOffset.Now;
                    var allToolOutputs = new List<string?>();
                    if (doneCurrentTurn?.ToolEntries is not null)
                        allToolOutputs.AddRange(doneCurrentTurn.ToolEntries.Select(e => e.OutputText));
                    foreach (var agentThread in _agentThreadRegistry.ThreadOrder)
                    {
                        foreach (var turn in agentThread.SavedTurns.Where(t => t.StartedAt >= turnStartedAt))
                        {
                            if (turn.Tools is not null)
                                allToolOutputs.AddRange(turn.Tools.Select(t => t.OutputText));
                        }
                        if (agentThread.CurrentTurn?.ToolEntries is not null &&
                            agentThread.CurrentTurn.StartedAt >= turnStartedAt)
                            allToolOutputs.AddRange(agentThread.CurrentTurn.ToolEntries.Select(e => e.OutputText));
                    }

                    // Extract git commit info (SHA + message when available from git native output)
                    var commitInfo = PushNotificationService.ExtractGitCommitInfo(allToolOutputs, rawResponse);
                    if (commitInfo is not null)
                    {
                        notifMessage += $" [{commitInfo.CommitSha}]";
                        var commitUrl = _workspaceGitHubUrl is not null
                            ? $"{_workspaceGitHubUrl}/commit/{commitInfo.CommitSha}"
                            : null;
                        _ = _bridge.BroadcastRcCommitAsync(commitInfo.CommitSha, commitUrl);
                        SoundNotifications.Play(SoundEvent.CommitMade);

                        // ── Approval tracking ─────────────────────────────────────────────
                        // Prefer git's commit message when available; fallback to notifSummary or prompt hint
                        var description = !string.IsNullOrWhiteSpace(commitInfo.CommitMessage)
                            ? commitInfo.CommitMessage
                            : BuildApprovalDescription(notifSummary, doneCurrentTurn?.Prompt);
                        var hint = TruncatePromptHint(doneCurrentTurn?.Prompt, maxChars: 60);
                        var item = CommitApprovalItem.Create(commitInfo.CommitSha, commitUrl, description,
                                                                      turnStartedAt, hint,
                                                                      originalPrompt: doneCurrentTurn?.Prompt?.Trim());
                        // Guard: never add a duplicate SHA — a stale agent CurrentTurn or context
                        // echo can cause the same SHA to be detected on a subsequent turn.
                        if (!_approvalItems.Any(a => string.Equals(a.CommitSha, item.CommitSha,
                                                                    StringComparison.OrdinalIgnoreCase)))
                        {
                            _approvalItems.Add(item);
                            _approvalStore?.Save(_approvalItems);
                            _approvalPanel?.AddItem(item);
                            SoundNotifications.Play(SoundEvent.ApprovalNeeded);
                        }
                        // ─────────────────────────────────────────────────────────────────
                    }
                    _ = _pushNotificationService.NotifyEventAsync("assistant_turn_complete", "SquadDash", notifMessage);
                    SoundNotifications.Play(SoundEvent.PromptComplete);

                    // Handle SquadDash loop commands embedded in the AI response.
                    if (squadashPayload?.Command is string cmd)
                        HandleSquadashCommand(cmd);

                    // Process HOST_COMMAND_JSON commands from this turn's response.
                    // Strip <system_notification> tags first — the parser regex requires the JSON
                    // block at the very end (\s*$), so trailing system_notification tags break it.
                    if (_hostCommandExecutor is not null && rawResponse is not null)
                    {
                        var rawForCommandParsing = ToolTranscriptFormatter.StripSystemNotifications(rawResponse);
                        var commandResults = _hostCommandExecutor.TryParseAndExecute(
                            rawForCommandParsing, _hostCommandRegistry, _currentWorkspace?.FolderPath, out _);
                        if (commandResults is not null)
                        {
                            foreach (var (invocation, descriptor, result) in commandResults)
                            {
                                doneCurrentTurn?.HostCommandEntries.Add(new HostCommandTranscriptEntry(
                                    doneCurrentTurn, invocation, descriptor, result, DateTimeOffset.Now));

                                if (descriptor.ResultBehavior == HostCommandResultBehavior.InjectResultAsContext && result.HasOutput)
                                {
                                    _promptQueue.EnqueueAtFront(result.Output!, ++_promptQueueSeq);
                                    SyncQueuePanel();
                                    SyncSendButton();
                                }
                            }
                        }
                    }
                }
                if (_pendingRcRestartAfterReset)
                {
                    _pendingRcRestartAfterReset = false;
                    _ = RestartRcAfterSessionResetAsync();
                }
                break;

            case "error":
                _pec.ActiveToolName = null;
                _conversationManager.SaveCurrentTurnToConversation(DateTimeOffset.Now, "bridge-done-final-notification");
                UpdateLeadAgent("Error", string.Empty, evt.Message ?? "Unknown error");
                UpdateSessionState("Error");
                FlushDeferredSystemLines();
                SoundNotifications.Play(SoundEvent.PromptError);
                break;
        }
    }

    private void TraceSdkEvent(SquadSdkEvent evt)
    {
        switch (evt.Type)
        {
            case "thinking_delta":
            case "subagent_thinking_delta":
                _pendingThinkingDeltaTraceCount++;
                _pendingThinkingDeltaTraceChars += evt.Text?.Length ?? 0;
                FlushSdkDeltaTraceIfDue(force: false);
                return;

            case "response_delta":
                _pendingResponseDeltaTraceCount++;
                _pendingResponseDeltaTraceChars += evt.Chunk?.Length ?? 0;
                FlushSdkDeltaTraceIfDue(force: false);
                return;

            case "subagent_tool_start":
            case "subagent_tool_progress":
            case "subagent_tool_complete":
                if (string.Equals(evt.ToolName, "view", StringComparison.OrdinalIgnoreCase))
                {
                    TraceSubagentViewToolEvent();
                    return;
                }
                break;

            default:
                break;
        }

        FlushSdkDeltaTraceIfDue(force: true);
        FlushSubagentViewToolTraceIfDue(force: true);
        SquadDashTrace.Write(
            "UI",
            $"HandleEvent type={evt.Type ?? "(null)"} tool={evt.ToolName ?? "(none)"} chunkLen={evt.Chunk?.Length ?? 0}");
    }

    private void FlushSdkDeltaTraceIfDue(bool force)
    {
        if (!force && _sdkDeltaTraceStopwatch.ElapsedMilliseconds < 1000)
            return;

        var thinkingCount = _pendingThinkingDeltaTraceCount;
        var responseCount = _pendingResponseDeltaTraceCount;
        if (thinkingCount == 0 && responseCount == 0)
            return;

        var elapsedMs = Math.Max(1, _sdkDeltaTraceStopwatch.ElapsedMilliseconds);
        SquadDashTrace.Write(
            "UI",
            $"HandleEvent stream_delta summary elapsed={elapsedMs}ms " +
            $"thinkingCount={thinkingCount} thinkingChars={_pendingThinkingDeltaTraceChars} " +
            $"responseCount={responseCount} responseChars={_pendingResponseDeltaTraceChars}");

        _pendingThinkingDeltaTraceCount = 0;
        _pendingThinkingDeltaTraceChars = 0;
        _pendingResponseDeltaTraceCount = 0;
        _pendingResponseDeltaTraceChars = 0;
        _sdkDeltaTraceStopwatch.Restart();
    }

    private void TraceSubagentViewToolEvent()
    {
        FlushSdkDeltaTraceIfDue(force: true);
        _pendingSubagentViewToolTraceCount++;
        FlushSubagentViewToolTraceIfDue(force: false);
    }

    private void FlushSubagentViewToolTraceIfDue(bool force)
    {
        if (_pendingSubagentViewToolTraceCount == 0)
            return;

        if (!force && _subagentViewToolTraceStopwatch.ElapsedMilliseconds < 1000)
            return;

        var elapsedMs = Math.Max(1, _subagentViewToolTraceStopwatch.ElapsedMilliseconds);
        SquadDashTrace.Write(
            "UI",
            $"HandleEvent subagent view tool summary elapsed={elapsedMs}ms count={_pendingSubagentViewToolTraceCount}");
        _pendingSubagentViewToolTraceCount = 0;
        _subagentViewToolTraceStopwatch.Restart();
    }

    private void HandleSdkDiagnostics(SquadSdkEvent evt)
    {
        var summary = evt.DiagnosticPhase switch
        {
            "send_started" => $"send started method={evt.SendMethod ?? "(unknown)"}",
            "first_sdk_event" => $"first sdk event type={evt.DiagnosticEventType ?? evt.FirstSdkEventType ?? "(unknown)"} after={FormatSdkDiagnosticMs(evt.MillisecondsSinceSendStart)}",
            "first_thinking_event" => $"first thinking event after={FormatSdkDiagnosticMs(evt.TimeToFirstThinkingMs)}",
            "first_response_event" => $"first response event after={FormatSdkDiagnosticMs(evt.TimeToFirstResponseMs)}",
            "send_completed" => $"send completed total={FormatSdkDiagnosticMs(evt.MillisecondsSinceSendStart)} firstSdk={FormatSdkDiagnosticMs(evt.TimeToFirstSdkEventMs)} firstThinking={FormatSdkDiagnosticMs(evt.TimeToFirstThinkingMs)} firstResponse={FormatSdkDiagnosticMs(evt.TimeToFirstResponseMs)}",
            "send_failed" => $"send failed total={FormatSdkDiagnosticMs(evt.MillisecondsSinceSendStart)} firstSdk={FormatSdkDiagnosticMs(evt.TimeToFirstSdkEventMs)} firstThinking={FormatSdkDiagnosticMs(evt.TimeToFirstThinkingMs)} firstResponse={FormatSdkDiagnosticMs(evt.TimeToFirstResponseMs)} message={evt.Message ?? "(none)"}",
            "named_agent_handoff" => $"named-agent handoff {evt.Message ?? "(none)"}",
            "background_cancel_requested" => $"background cancel requested taskId={evt.TaskId ?? "(none)"} {evt.Message ?? "(none)"}",
            "background_cancel_completed" => $"background cancel completed taskId={evt.TaskId ?? "(none)"} {evt.Message ?? "(none)"}",
            _ => $"phase={evt.DiagnosticPhase ?? "(unknown)"} event={evt.DiagnosticEventType ?? evt.FirstSdkEventType ?? "(none)"} after={FormatSdkDiagnosticMs(evt.MillisecondsSinceSendStart)} message={evt.Message ?? "(none)"}"
        };

        SquadDashTrace.Write("SDK", summary);
    }

    private static string FormatSdkDiagnosticMs(int? value)
    {
        return value is int ms
            ? $"{ms}ms"
            : "(n/a)";
    }

    private void HandleBridgeError(string text)
    {
        SquadDashTrace.Write("UI", $"Bridge stderr: {text}");
        _pec.MarkActivity("bridge-stderr");

        if (text.Contains("ExperimentalWarning: SQLite") ||
            text.Contains("Use `node --trace-warnings") ||
            text.Contains("SquadClient connection took"))
        {
            return;
        }

        AppendLine("[stderr] " + text, ThemeBrush("SystemErrorText"));
    }

    private void HandleSessionReady(SquadSdkEvent evt)
    {
        if (_currentWorkspace is null || string.IsNullOrWhiteSpace(evt.SessionId))
            return;

        var sessionChanged = !string.Equals(
            _conversationManager.CurrentSessionId,
            evt.SessionId,
            StringComparison.OrdinalIgnoreCase);
        if (sessionChanged && evt.SessionResumed != true && _recentDelegationOutcomes.Count > 0)
        {
            _recentDelegationOutcomes.Clear();
            SquadDashTrace.Write("Routing", "Delegation outcome rollup reset for fresh coordinator session.");
        }

        _conversationManager.CurrentSessionId = evt.SessionId;
        _conversationManager.PersistConversationState(_conversationManager.ConversationState with
        {
            SessionId = _conversationManager.CurrentSessionId,
            SessionUpdatedAt = DateTimeOffset.UtcNow
        });

        SquadDashTrace.Write(
            "UI",
            $"Session ready id={evt.SessionId} resumed={evt.SessionResumed?.ToString() ?? "(unknown)"} diagnostics={SessionResumeDiagnosticsPresentation.BuildSummary(evt) ?? "(none)"}");
    }

    private void HandleSessionReset(SquadSdkEvent evt)
    {
        var diagnostics = PromptContextDiagnosticsPresentation.BuildTraceSummary(
            _conversationManager.CapturePromptContextDiagnostics(),
            DateTimeOffset.UtcNow);
        SquadDashTrace.Write(
            "Routing",
            $"Session reset requested after provider rejection. {diagnostics}");
        if (_recentDelegationOutcomes.Count > 0)
        {
            _recentDelegationOutcomes.Clear();
            SquadDashTrace.Write("Routing", "Delegation outcome rollup cleared after session reset.");
        }
        _conversationManager.CurrentSessionId = null;

        if (_currentWorkspace is not null)
        {
            _conversationManager.PersistConversationState(_conversationManager.ConversationState with
            {
                SessionId = null,
                SessionUpdatedAt = DateTimeOffset.UtcNow
            });
        }

        AppendLine(
            "[info] " + (string.IsNullOrWhiteSpace(evt.Message)
                ? "Squad reset the previous session after a provider error and is retrying your prompt in a fresh session."
                : evt.Message),
            ThemeBrush("SystemInfoText"));
        UpdateLeadAgent("Recovering", string.Empty, "Resetting the active Squad session and retrying the prompt.");
        UpdateSessionState("Recovering");

        if (_remoteAccessActive)
            _pendingRcRestartAfterReset = true;
    }

    private void HandleBackgroundTasksChanged(SquadSdkEvent evt)
    {
        var previousAgents = _backgroundTaskPresenter.BackgroundAgents;
        var previousShells = _backgroundTaskPresenter.BackgroundShells;
        _backgroundTaskPresenter.BackgroundAgents = evt.BackgroundAgents ?? Array.Empty<SquadBackgroundAgentInfo>();
        _backgroundTaskPresenter.BackgroundShells = evt.BackgroundShells ?? Array.Empty<SquadBackgroundShellInfo>();
        foreach (var shell in _backgroundTaskPresenter.BackgroundShells)
            if (!string.IsNullOrWhiteSpace(shell.ShellId) && !string.IsNullOrWhiteSpace(shell.Description))
                _shellDescriptions[shell.ShellId] = shell.Description;

        _agentThreadRegistry.SyncBackgroundAgentThreads(_backgroundTaskPresenter.BackgroundAgents);
        SyncAgentCardsWithThreads();

        SquadDashTrace.Write(
            "UI",
            $"Background tasks updated session={evt.SessionId ?? _conversationManager.CurrentSessionId ?? "(unknown)"} {_backgroundTaskPresenter.BuildBackgroundTaskTraceSummary()}");

        _backgroundTaskPresenter.HandleRemovedBackgroundTasks(previousAgents, previousShells);

        if (!_isPromptRunning)
            _backgroundTaskPresenter.RefreshLeadAgentBackgroundStatus();

        UpdateInteractiveControlState();
        DrainQueueAfterBackgroundWorkChanged("background-tasks-changed");
        if (!_restartPending)
            ApplyPendingBridgeSettingsRestartIfIdle("background-tasks-changed");
        TryCompletePendingRestart("background-tasks-changed");
    }

    private bool ShouldIgnoreForceStoppedBridgeEvent(SquadSdkEvent evt)
    {
        if (evt.BridgeProcessGeneration <= 0 ||
            evt.BridgeProcessGeneration > _ignoreBridgeEventsThroughGeneration)
            return false;

        SquadDashTrace.Write(
            "Bridge",
            $"Ignored event from force-stopped bridge generation={evt.BridgeProcessGeneration} type={evt.Type ?? "(none)"} session={evt.SessionId ?? "(none)"} requestId={evt.RequestId ?? "(none)"}");
        return true;
    }

    private void HandleTaskComplete(SquadSdkEvent evt)
    {
        SquadDashTrace.Write(
            "UI",
            $"Background task completed summary={evt.Summary ?? "(none)"}");

        if (!string.IsNullOrWhiteSpace(evt.Summary))
        {
            _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;
            _backgroundTaskPresenter.RecordBackgroundCompletion(
                evt.Summary.Trim(),
                $"task-summary:{evt.Summary.Trim()}");
        }

        if (!_isPromptRunning && !_backgroundTaskPresenter.HasBackgroundTasks())
            _backgroundTaskPresenter.RefreshLeadAgentBackgroundStatus();
    }

    private void HandleSubagentStarted(SquadSdkEvent evt)
    {
        var matchedPendingQuickReply = _pendingQuickReplyLaunch is { ExpectedAgentStarted: false } pendingLaunch &&
                                       QuickReplyAgentLaunchPolicy.MatchesExpectedAgent(
                                           pendingLaunch.ExpectedAgentHandle,
                                           pendingLaunch.ExpectedAgentLabel,
                                           evt);
        NotePendingQuickReplySubagentStarted(evt);
        if (ShouldSuppressSilentBackgroundAgent(evt))
        {
            SquadDashTrace.Write("UI", $"Silent background agent started agent={evt.AgentDisplayName ?? evt.AgentName ?? evt.AgentId ?? "(unknown)"}");
            return;
        }

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        _agentThreadRegistry.UpdateAgentThreadLifecycle(thread, evt, statusText: "Running", detailText: evt.AgentDescription ?? "Background work started.");
        if (matchedPendingQuickReply)
            TrackDirectQuickReplyAgentThread(thread);
        SquadDashTrace.Write(
            "UI",
            $"Subagent started {BackgroundTaskPresenter.BuildBackgroundAgentLabel(thread)} description={evt.AgentDescription?.Trim() ?? "(none)"}");
        SyncAgentCardsWithThreads();
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_started");
        // Persist the coordinator's completed "dispatching…" response immediately.
        // RunPromptAsync only resolves (and the ExecuteCoordinatorTurnAsync finally-block only
        // runs) after ALL background agents finish, so the coordinator turn would otherwise
        // remain in-memory and be lost if the app restarts while the sub-agent is running.
        _conversationManager.SaveCurrentTurnToConversation(DateTimeOffset.UtcNow, "subagent-started");
        _conversationManager.PersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentMessageDelta(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        MaybeReactivateThread(thread);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        thread.StatusText = "Streaming";
        thread.IsCurrentBackgroundRun = true;
        if (!string.IsNullOrWhiteSpace(evt.Chunk))
        {
            AppendText(thread, evt.Chunk!);
            thread.ResponseStreamed = true;
            thread.LatestResponse = GetSanitizedTurnResponseTextOrNull(thread.CurrentTurn);
        }

        if (!string.IsNullOrWhiteSpace(thread.LatestResponse))
            thread.DetailText = BuildThreadPreview(thread.LatestResponse!);

        SyncThreadChip(thread);
        FindAgentCardForThread(thread)?.FireActivityPulse(SpinnerActivityKind.Reading);
        UpdateAgentCardFromThread(thread, syncBuckets: false);
        _conversationManager.SchedulePersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentThinkingDelta(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thought = NormalizeThinkingChunk(evt.Text ?? evt.ReasoningText);
        if (string.IsNullOrWhiteSpace(thought))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        MaybeReactivateThread(thread);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        thread.StatusText = "Thinking";
        thread.IsCurrentBackgroundRun = true;
        AppendThinkingText(thread, thought, thread.Title);
        thread.DetailText = FormatThinkingText(thought);
        SyncThreadChip(thread);
        FindAgentCardForThread(thread)?.FireActivityPulse(SpinnerActivityKind.Thinking);
        UpdateAgentCardFromThread(thread, syncBuckets: false);
        _conversationManager.SchedulePersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentMessage(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        MaybeReactivateThread(thread);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        thread.IsCurrentBackgroundRun = true;

        if (!string.IsNullOrWhiteSpace(evt.ReasoningText))
            AppendThinkingText(thread, evt.ReasoningText!, thread.Title);

        if (!thread.ResponseStreamed && !string.IsNullOrWhiteSpace(evt.Text))
        {
            AppendText(thread, evt.Text!);
        }

        thread.LatestResponse = GetSanitizedTurnResponseTextOrNull(thread.CurrentTurn);
        thread.DetailText = !string.IsNullOrWhiteSpace(thread.LatestResponse)
            ? BuildThreadPreview(thread.LatestResponse!)
            : thread.DetailText;
        FinalizeCurrentTurnResponse(thread);
        thread.ResponseStreamed = false;
        SyncThreadChip(thread);
        UpdateAgentCardFromThread(thread);
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_message");
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
    }

    private void HandleSubagentToolStart(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        MaybeReactivateThread(thread);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        StartToolExecution(thread, evt);
        thread.StatusText = "Tooling";
        thread.IsCurrentBackgroundRun = true;
        thread.DetailText = ToolTranscriptFormatter.BuildRunningText(CreateToolDescriptor(evt), evt.ProgressMessage);
        SyncThreadChip(thread);
        FindAgentCardForThread(thread)?.FireActivityPulse(GetToolActivityKind(evt.ToolName));
        UpdateAgentCardFromThread(thread, syncBuckets: false);
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_tool_start");
        _conversationManager.SchedulePersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentToolProgress(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        UpdateToolExecution(thread, evt);
        thread.StatusText = "Tooling";
        thread.IsCurrentBackgroundRun = true;
        thread.DetailText = ToolTranscriptFormatter.BuildRunningText(CreateToolDescriptor(evt), evt.ProgressMessage);
        SyncThreadChip(thread);
        FindAgentCardForThread(thread)?.FireActivityPulse(GetToolActivityKind(evt.ToolName));
        UpdateAgentCardFromThread(thread, syncBuckets: false);
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_tool_progress");
        _conversationManager.SchedulePersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentToolComplete(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
            return;

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        _agentThreadRegistry.EnsureAgentThreadTurnStarted(thread);
        CompleteToolExecution(thread, evt);
        thread.StatusText = "Running";
        thread.IsCurrentBackgroundRun = true;
        if (!string.IsNullOrWhiteSpace(evt.OutputText))
        {
            var previewText = ToolTranscriptOutputLimiter.TrimForLiveTranscript(
                CreateToolDescriptor(evt),
                evt.OutputText);
            if (!string.IsNullOrWhiteSpace(previewText))
                thread.DetailText = BuildThreadPreview(previewText);
        }

        SyncThreadChip(thread);
        FindAgentCardForThread(thread)?.FireActivityPulse(GetToolActivityKind(evt.ToolName));
        UpdateAgentCardFromThread(thread, syncBuckets: false);
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_tool_complete");
        _conversationManager.SchedulePersistAgentThreadSnapshot(thread);
    }

    private void HandleSubagentCompleted(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
        {
            SquadDashTrace.Write("UI", $"Silent background agent completed agent={evt.AgentDisplayName ?? evt.AgentName ?? evt.AgentId ?? "(unknown)"}");
            return;
        }

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        _agentThreadRegistry.UpdateAgentThreadLifecycle(thread, evt, statusText: "Completed", detailText: AgentThreadRegistry.BuildThreadCompletionDetail(thread, evt));
        _agentThreadRegistry.FinalizeAgentThread(thread);
        UpdateCompletedTimeFooters();
        var summary = BackgroundTaskPresenter.BuildThreadCompletionSummary(thread);
        SquadDashTrace.Write("UI", $"Subagent completed {summary}");
        var needsDirectQuickReplyFollowUp = CompleteDirectQuickReplyAgentThread(thread, "subagent_completed");

        var promoted = _backgroundTaskPresenter.PromoteBackgroundAgentReportNow(thread, "subagent_completed");
        switch (DirectQuickReplyCompletionPolicy.Decide(
                    needsDirectQuickReplyFollowUp,
                    _isPromptRunning,
                    promoted))
        {
            case DirectQuickReplyCompletionAction.RegisterSilentWatchdog:
                _pec.NotifySubagentCompletedDuringTurn(thread, thread.Title ?? evt.AgentDisplayName ?? evt.AgentName ?? "Unknown agent");
                break;
            case DirectQuickReplyCompletionAction.EnqueueCoordinatorFollowUp:
                EnqueueDirectQuickReplyAgentFollowUp(thread);
                break;
        }
        _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;
        _backgroundTaskPresenter.RecordBackgroundCompletion(summary, BackgroundTaskPresenter.BuildThreadAnnouncementKey(thread), appendNotice: !promoted);
        SyncAgentCardsWithThreads();
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_completed");
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
        DrainQueueAfterBackgroundWorkChanged("subagent-completed");
        TryCompletePendingRestart("subagent-completed");
    }

    private void HandleSubagentFailed(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
        {
            SquadDashTrace.Write("UI", $"Silent background agent failed agent={evt.AgentDisplayName ?? evt.AgentName ?? evt.AgentId ?? "(unknown)"} message={evt.Message ?? "(none)"}");
            return;
        }

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        var summary = BackgroundTaskPresenter.BuildThreadFailureSummary(thread, evt.Message);
        _agentThreadRegistry.UpdateAgentThreadLifecycle(thread, evt, statusText: "Failed", detailText: summary);
        _agentThreadRegistry.FinalizeAgentThread(thread);
        UpdateCompletedTimeFooters();
        SquadDashTrace.Write("UI", $"Subagent failed {summary}");
        CompleteDirectQuickReplyAgentThread(thread, "subagent_failed");

        _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;
        _backgroundTaskPresenter.AppendBackgroundNotice(summary, ThemeBrush("TaskFailureText"), BackgroundTaskPresenter.BuildThreadAnnouncementKey(thread) + ":failed");
        SyncAgentCardsWithThreads();
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_failed");
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
        DrainQueueAfterBackgroundWorkChanged("subagent-failed");
        TryCompletePendingRestart("subagent-failed");
    }

    private void HandleSubagentCancelled(SquadSdkEvent evt)
    {
        if (ShouldSuppressSilentBackgroundAgent(evt))
        {
            SquadDashTrace.Write("UI", $"Silent background agent cancelled agent={evt.AgentDisplayName ?? evt.AgentName ?? evt.AgentId ?? "(unknown)"}");
            return;
        }

        var thread = _agentThreadRegistry.GetOrCreateAgentThread(evt);
        var summary = BackgroundTaskPresenter.BuildThreadCancellationSummary(thread);
        _agentThreadRegistry.UpdateAgentThreadLifecycle(thread, evt, statusText: "Cancelled", detailText: summary);
        thread.CompletedAt ??= DateTimeOffset.Now;
        _agentThreadRegistry.FinalizeAgentThread(thread);
        UpdateCompletedTimeFooters();
        SquadDashTrace.Write("UI", $"Subagent cancelled {summary}");
        CompleteDirectQuickReplyAgentThread(thread, "subagent_cancelled");

        _backgroundTaskPresenter.SkipNextBackgroundCompletionFallback = true;
        _backgroundTaskPresenter.AppendBackgroundNotice(summary, ThemeBrush("SystemInfoText"), BackgroundTaskPresenter.BuildThreadAnnouncementKey(thread) + ":cancelled");
        SyncAgentCardsWithThreads();
        _backgroundTaskPresenter.ObserveBackgroundAgentActivity(thread, "subagent_cancelled");
        _conversationManager.SaveAgentThreadToConversation(thread, DateTimeOffset.UtcNow);
        DrainQueueAfterBackgroundWorkChanged("subagent-cancelled");
        TryCompletePendingRestart("subagent-cancelled");
    }

    private void HandleLoopStarted(SquadSdkEvent evt)
    {
        _pec.SetIsLoopRunning(true);
        _settingsSnapshot = _settingsStore.SaveLoopActive(true);
        _loopCurrentIteration = 0;
        _loopQueued = false;
        _conversationManager.UpdateQueuedPromptsState(
            _promptQueue.Items, _followUpAttachments,
            queueRightmostHeld: IsRightmostQueueTabActive(),
            loopQueuedToDequeue: false,
            activeDraftSimEntry: _pec.ActiveDraftSimEntry,
            activeTabIndex: GetActiveQueueTabIndex());
        var label = string.IsNullOrWhiteSpace(evt.LoopMdPath)
            ? "🔁 Loop started"
            : $"🔁 Loop started: {evt.LoopMdPath.Replace('\\', '/')}";
        AppendLine(label);
        AppendLoopOutputLine($"▶ Loop started — {LoopTimestamp()}", LoopLifecycleBrush);
        SquadDashTrace.Write("UI", $"Loop started mdPath={evt.LoopMdPath ?? "(none)"}");
        SyncLoopPanel();
    }

    private void HandleLoopIteration(SquadSdkEvent evt)
    {
        if (evt.LoopIteration is int n) _loopCurrentIteration = n;
        var iterLabel = evt.LoopIteration is int m ? $"↩ Iteration {m}" : "↩ Iteration";
        AppendLine(iterLabel);
        SquadDashTrace.Write("UI", $"Loop iteration={evt.LoopIteration?.ToString() ?? "(unknown)"}");
        SoundNotifications.Play(SoundEvent.LoopIterationComplete);
        SyncLoopPanel();
    }

    private void HandleLoopStopped(SquadSdkEvent evt)
    {
        _pec.SetIsLoopRunning(false);
        ApplyPendingBridgeSettingsRestartIfIdle("loop-stopped");
        _settingsSnapshot = _settingsStore.SaveLoopActive(false);
        _loopCurrentIteration = 0;
        AppendLoopOutputLine($"✅ Loop stopped — {LoopTimestamp()}", LoopLifecycleBrush);
        AppendLine("✅ Loop stopped");
        SquadDashTrace.Write("UI", $"Loop stopped mdPath={evt.LoopMdPath ?? "(none)"}");
        SoundNotifications.Play(SoundEvent.LoopStopped);
        SyncLoopPanel();
    }

    private void HandleLoopError(SquadSdkEvent evt)
    {
        _pec.SetIsLoopRunning(false);
        ApplyPendingBridgeSettingsRestartIfIdle("loop-error");
        _settingsSnapshot = _settingsStore.SaveLoopActive(false);
        _loopCurrentIteration = 0;
        _loopInterruptedByQueue = false; // abort — don't auto-resume
        var errorLabel = string.IsNullOrWhiteSpace(evt.Message)
            ? "❌ Loop error"
            : $"❌ Loop error: {evt.Message}";
        AppendLoopOutputLine(errorLabel, new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0x88, 0x44)));
        AppendLine(errorLabel, ThemeBrush("SystemErrorText"));
        SquadDashTrace.Write("UI", $"Loop error message={evt.Message ?? "(none)"}");
        SyncLoopPanel();
    }

    // ── Native-loop controller callbacks (LoopMode.NativeAgents) ───────────

    /// <summary>
    /// Executes one loop iteration. After the initial AI response, if quick replies are
    /// presented the iteration stays "open": it waits for user follow-up, dispatches it,
    /// and repeats until the AI produces a response without quick replies. Only then does
    /// LoopController see the awaitable task complete, triggering _onIterationCompleted and
    /// the inter-iteration timer.
    /// </summary>
    private async Task ExecuteLoopIterationAsync(string prompt, string? sessionId)
    {
        var loopMdPath = Path.Combine(_currentWorkspace?.SquadFolderPath ?? "", "loop.md");
        var displayPrompt = $"🔁 Loop · Iteration {_loopCurrentIteration}  [View loop.md](app://open-loop-md:{loopMdPath})";
        await _pec.ExecutePromptAsync(
            prompt,
            addToHistory: false,
            clearPromptBox: false,
            sessionIdOverride: sessionId,
            displayPrompt: displayPrompt);

        while (LastTurnNeedsInput() && _loopController.StopState == LoopStopState.None)
        {
            _loopFollowUpTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
            AppendLoopOutputLine("⏸ Waiting for your response…", LoopLifecycleBrush);
            bool gotInput = await _loopFollowUpTcs.Task;
            _loopFollowUpTcs = null;
            if (!gotInput) return; // aborted

            // If a queue item arrived (typed message path), dequeue and dispatch it.
            // For quick-reply-click path, QuickReplyButton_Click dispatched the prompt
            // directly — item will be null, so we just re-check LastTurnNeedsInput().
            var item = GetAutoDispatchCandidate();
            if (item is null) continue;

            _promptQueue.Remove(item.Id);
            SyncQueuePanel();
            _pec.PendingQueueItemCount = _promptQueue.Count;
            _pec.CurrentDispatchedItem = item;
            _queueDrainActive = true;
            _pendingPromptIsSystemInjected = item.IsSystemInjected;
            try
            {
                await _pec.ExecutePromptAsync(ApplyFollowUpHeader(ApplyDictationAnnotation(item), item.Id), addToHistory: !item.IsSystemInjected, clearPromptBox: false);
            }
            finally
            {
                _queueDrainActive = false;
                _pendingPromptIsSystemInjected = false;
                _pec.PendingQueueItemCount = 0;
                _pec.CurrentDispatchedItem = null;
            }
        }
    }

    private void OnNativeLoopIterationStarted(int iteration)
    {
        _loopCurrentIteration = iteration;
        _loopIsWaiting = false;
        _pec.SetIsLoopRunning(true);
        _settingsSnapshot = _settingsStore.SaveLoopActive(true);
        AppendLoopOutputLine($"▶ Round {iteration} started — {LoopTimestamp()}", LoopLifecycleBrush);
        AppendLine($"↩ Round {iteration}");
        SyncLoopPanel();
    }

    private void OnNativeLoopStopped()
    {
        _pec.SetIsLoopRunning(false);
        ApplyPendingBridgeSettingsRestartIfIdle("native-loop-stopped");
        _loopCurrentIteration = 0;
        _loopIsWaiting = false;
        _settingsSnapshot = _settingsStore.SaveLoopActive(false);
        AppendLoopOutputLine($"✅ Loop stopped — {LoopTimestamp()}", LoopLifecycleBrush);
        AppendLine("✅ Loop stopped");

        // If queue items arrived while the loop was running (or are still pending),
        // mark the loop for resume once the queue drains.  Abort goes through
        // OnNativeLoopError and intentionally does NOT re-queue.
        bool hasInterrupt = _loopInterruptedByQueue;
        SquadDashTrace.Write("Queue", $"OnNativeLoopStopped: hasInterrupt={hasInterrupt} hasReadyItems={_promptQueue.HasReadyItems} loopQueued={_loopQueued}.");
        if ((hasInterrupt || _promptQueue.HasReadyItems) && !_loopQueued)
        {
            _loopQueued = true;
            _conversationManager.UpdateQueuedPromptsState(
                _promptQueue.Items, _followUpAttachments,
                queueRightmostHeld: IsRightmostQueueTabActive(),
                loopQueuedToDequeue: true,
                activeDraftSimEntry: _pec.ActiveDraftSimEntry,
                activeTabIndex: GetActiveQueueTabIndex());
            AppendLoopOutputLine("🔁 Queue items pending — loop will resume after queue drains.", LoopLifecycleBrush);
        }

        SoundNotifications.Play(SoundEvent.LoopStopped);
        SyncLoopPanel();
    }

    private void OnNativeLoopError(string msg)
    {
        _pec.SetIsLoopRunning(false);
        ApplyPendingBridgeSettingsRestartIfIdle("native-loop-error");
        _loopCurrentIteration = 0;
        _loopIsWaiting = false;
        _loopInterruptedByQueue = false; // abort — don't auto-resume
        _settingsSnapshot = _settingsStore.SaveLoopActive(false);
        AppendLine($"❌ Loop error: {msg}", ThemeBrush("SystemErrorText"));
        SyncLoopPanel();
    }

    private void OnNativeLoopIterationCompleted(int iteration)
    {
        _settingsSnapshot = _settingsStore.SaveLoopIteration(iteration);
        AppendLoopOutputLine($"✓ Round {iteration} completed — {LoopTimestamp()}", LoopLifecycleBrush);
        AppendLine($"  ✓ Round {iteration} complete");
        SoundNotifications.Play(SoundEvent.LoopIterationComplete);
        SyncLoopPanel();
    }

    private void OnNativeLoopWaiting(DateTimeOffset nextAt)
    {
        _loopNextIterationAt = nextAt;
        _loopIsWaiting = true;
        SyncLoopPanel();
    }

    private static readonly Regex AnsiEscapeRegex = new(@"\x1B\[[0-9;]*[A-Za-z]", RegexOptions.Compiled);
    private static readonly SolidColorBrush LoopStderrBrush = new(Color.FromRgb(0xFF, 0x44, 0x44));
    private static readonly SolidColorBrush LoopLifecycleBrush = new(Color.FromRgb(0x88, 0x88, 0x88));

    private static string LoopTimestamp() => DateTime.Now.ToString("h:mm tt");

    private void HandleLoopOutput(SquadSdkEvent evt)
    {
        if (string.IsNullOrWhiteSpace(evt.OutputLine)) return;
        var raw = evt.OutputLine!;
        SquadDashTrace.Write("LoopOutput", raw);
        var line = AnsiEscapeRegex.Replace(raw, string.Empty).Trim();
        if (string.IsNullOrWhiteSpace(line)) return;
        if (line.StartsWith("[stderr]", StringComparison.Ordinal))
            AppendLoopOutputLine(line, LoopLifecycleBrush);
        else
            AppendLoopOutputLine(line);
    }

    private void AppendLoopOutputLine(string text, Brush? brush = null)
    {
        EnsureLoopOutputWindow();
        _loopOutputWindow!.AppendLine(text);
    }

    private void BackupAndClearLoopOutput()
    {
        _loopOutputWindow?.SaveAndClear();
    }

    private void OpenLoopOutputWindow()
    {
        if (_loopOutputWindow is { IsVisible: true })
        {
            _loopOutputWindow.Activate();
            return;
        }

        if (_loopOutputWindow is null)
        {
            _loopOutputWindow = new LoopOutputWindow();
            _loopOutputWindow.Owner = this;
        }

        // Position upper-right of the main window (use actual bounds, correct when maximized)
        var mainBounds = NativeMethods.GetActualWindowBoundsLogical(this);
        double mainLeft  = mainBounds.Width > 0 ? mainBounds.Left  : Left;
        double mainTop   = mainBounds.Width > 0 ? mainBounds.Top   : Top;
        double mainWidth = mainBounds.Width > 0 ? mainBounds.Width : (ActualWidth > 0 ? ActualWidth : Width);
        var w = _loopOutputWindow.Width;
        var margin = 20;
        _loopOutputWindow.Left = mainLeft + mainWidth - w - margin;
        _loopOutputWindow.Top  = mainTop + margin;
        WindowPlacementHelper.EnsureOnScreen(_loopOutputWindow, this);

        _loopOutputWindow.Show();
        _loopOutputWindow.Activate();
    }

    private void EnsureLoopOutputWindow()
    {
        if (_loopOutputWindow is not { IsVisible: true })
            OpenLoopOutputWindow();
    }

    private void LoopPanelDequeueMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _loopQueued = false;
            _conversationManager.UpdateQueuedPromptsState(
                _promptQueue.Items, _followUpAttachments,
                queueRightmostHeld: IsRightmostQueueTabActive(),
                loopQueuedToDequeue: false,
                activeDraftSimEntry: _pec.ActiveDraftSimEntry,
                activeTabIndex: GetActiveQueueTabIndex());
            AppendLoopOutputLine($"⏹ Loop dequeued — {LoopTimestamp()} — will not resume after queue drains.", LoopLifecycleBrush);
            SyncLoopPanel();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelDequeueMenuItem_Click), ex); }
    }

    private void LoopPanelViewOutputMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { OpenLoopOutputWindow(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelViewOutputMenuItem_Click), ex); }
    }

    private void LoopOutputClearButton_Click(object sender, RoutedEventArgs e)
    {
        try { BackupAndClearLoopOutput(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopOutputClearButton_Click), ex); }
    }

    private void LoopOutputClearMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { BackupAndClearLoopOutput(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopOutputClearMenuItem_Click), ex); }
    }

    private void LoopOutputHideMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { _loopOutputWindow?.Hide(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopOutputHideMenuItem_Click), ex); }
    }

    private void LoopPanelShowOutputMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { OpenLoopOutputWindow(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelShowOutputMenuItem_Click), ex); }
    }

    private void HandleWatchFleetDispatched(SquadSdkEvent evt)
    {
        _watchCycleId = evt.WatchCycleId ?? Guid.NewGuid().ToString("N")[..8];
        _watchFleetSize = evt.WatchFleetSize ?? 0;
        _watchWaveIndex = 0;
        _watchWaveCount = 0;
        _watchAgentCount = 0;
        _watchPhase = null;
        AppendLine($"👁 Watch: fleet dispatched ({_watchFleetSize} agents)");
        SquadDashTrace.Write("Watch", $"fleet cycleId={_watchCycleId} size={_watchFleetSize}");
        SyncWatchPanel();
    }

    private void HandleWatchWaveDispatched(SquadSdkEvent evt)
    {
        _watchWaveIndex = evt.WatchWaveIndex ?? _watchWaveIndex;
        _watchWaveCount = evt.WatchWaveCount ?? _watchWaveCount;
        _watchAgentCount = evt.WatchAgentCount ?? _watchAgentCount;
        AppendLine($"👁 Watch: wave {_watchWaveIndex + 1}/{_watchWaveCount} ({_watchAgentCount} agents)");
        SquadDashTrace.Write("Watch", $"wave {_watchWaveIndex + 1}/{_watchWaveCount} agents={_watchAgentCount}");
        SyncWatchPanel();
    }

    private void HandleWatchHydration(SquadSdkEvent evt)
    {
        _watchPhase = evt.WatchPhase;
        SquadDashTrace.Write("Watch", $"hydration phase={_watchPhase ?? "(none)"}");
        SyncWatchPanel();
    }

    private void HandleWatchRetro(SquadSdkEvent evt)
    {
        var summary = evt.WatchRetroSummary;
        AppendLine(string.IsNullOrWhiteSpace(summary)
            ? "👁 Watch: retro complete"
            : $"👁 Watch: retro — {summary}");
        SquadDashTrace.Write("Watch", $"retro cycleId={_watchCycleId} summary={summary ?? "(none)"}");
        _watchCycleId = null;
        _watchFleetSize = 0;
        _watchWaveIndex = 0;
        _watchWaveCount = 0;
        _watchAgentCount = 0;
        _watchPhase = null;
        SyncWatchPanel();
    }

    private void HandleWatchMonitorNotification(SquadSdkEvent evt)
    {
        var channel = evt.WatchNotificationChannel ?? "unknown";
        var sent = evt.WatchNotificationSent == true ? "sent" : "skipped";
        AppendLine($"👁 Watch: monitor notification ({channel}, {sent})");
        SquadDashTrace.Write("Watch", $"monitor channel={channel} sent={sent} recipient={evt.WatchNotificationRecipient ?? "(none)"}");
    }

    private void SyncWatchPanel()
    {
        if (WatchPanelBorder is null) return;

        bool active = _watchCycleId is not null;
        WatchPanelBorder.Visibility = active ? Visibility.Visible : Visibility.Collapsed;

        if (!active) return;

        WatchStatusStack.Children.Clear();

        if (_watchFleetSize > 0)
            WatchStatusStack.Children.Add(MakeWatchRow($"Fleet: {_watchFleetSize} agents"));

        if (_watchWaveCount > 0)
            WatchStatusStack.Children.Add(MakeWatchRow($"Wave {_watchWaveIndex + 1} of {_watchWaveCount}"));
        else if (_watchAgentCount > 0)
            WatchStatusStack.Children.Add(MakeWatchRow($"{_watchAgentCount} agents dispatched"));

        if (!string.IsNullOrWhiteSpace(_watchPhase))
            WatchStatusStack.Children.Add(MakeWatchRow($"Phase: {_watchPhase}"));
    }

    private TextBlock MakeWatchRow(string text) =>
        new TextBlock
        {
            Text = text,
            Margin = new Thickness(0, 2, 0, 0),
            Foreground = (Brush)FindResource("ActivePanelSubtitle"),
            TextWrapping = TextWrapping.Wrap
        };

    private void HandleRcStarted(SquadSdkEvent evt)
    {
        _remoteAccessActive = true;
        _settingsSnapshot = _settingsStore.SaveRemoteAccessActive(true);
        if (!string.IsNullOrWhiteSpace(evt.RcToken))
            _settingsSnapshot = _settingsStore.SaveRcToken(evt.RcToken);
        UpdateRemoteAccessMenuHeader();
        var port = evt.RcPort is int p ? p : 0;
        _rcActivePort = port;
        if (port > 0)
            _settingsSnapshot = _settingsStore.SaveRcPort(port);
        var baseUrl = evt.RcLanUrl ?? evt.RcUrl ?? $"http://localhost:{port}";
        _rcPanelUrl = string.IsNullOrEmpty(evt.RcToken) ? baseUrl : $"{baseUrl}?token={Uri.EscapeDataString(evt.RcToken)}";

        AppendLine("📡 Remote access started — [Show RC panel](app://show-rc-panel)");

        ShowRcPanel();

        if (evt.RcFirewallRuleAdded == false)
            _rcPanel?.ShowFirewallWarning();

        _ = BroadcastRcAgentRosterToClientsAsync();

        SquadDashTrace.Write("UI", $"RC started port={port} url={_rcPanelUrl} firewallRuleAdded={evt.RcFirewallRuleAdded}");
    }

    private async Task BroadcastRcAgentRosterToClientsAsync()
    {
        var roster = _agents
            .Select(card => (
                Handle: card.IsLeadAgent ? "coordinator" : card.AccentStorageKey,
                DisplayName: card.IsLeadAgent ? "Coordinator" : card.Name,
                AccentHex: NormalizeCssHex(card.AccentColorHex)))
            .ToList();

        await _bridge.BroadcastRcAgentRosterAsync(roster).ConfigureAwait(false);
    }

    private static string NormalizeCssHex(string hex)
    {
        // Strip WPF ARGB alpha prefix: #FFRRGGBB → #RRGGBB
        if (!string.IsNullOrWhiteSpace(hex) &&
            hex.StartsWith('#') &&
            hex.Length == 9 &&
            hex.Substring(1, 2).Equals("FF", StringComparison.OrdinalIgnoreCase))
        {
            return "#" + hex.Substring(3);
        }
        return hex;
    }

    private void HandleRcTunnelStarted(SquadSdkEvent evt)
    {
        _rcTunnelUrl = evt.RcTunnelUrl;
        AppendLine("  🌐 Tunnel active — [Show RC panel](app://show-rc-panel)");
        _rcPanel?.SetTunnelUrl(_rcTunnelUrl);
        SquadDashTrace.Write("UI", $"RC tunnel started url={_rcTunnelUrl}");
    }

    private void ShowRcPanel()
    {
        if (_rcPanelUrl is null) return;

        if (_rcPanel is not null && _rcPanel.IsLoaded)
        {
            _rcPanel.SetPrimaryUrl(_rcPanelUrl);
            _rcPanel.Activate();
            return;
        }

        _rcPanel = new RcStatusPanel(
            primaryUrl: _rcPanelUrl,
            onStopRemoteAccess: () => _ = _bridge.StopRemoteAsync(),
            onRegenerateToken: RegenerateRcToken,
            onRestartAsAdmin: RestartAsAdministrator);
        _rcPanel.Owner = this;
        _rcPanel.Closed += (_, _) => _rcPanel = null;

        if (!string.IsNullOrWhiteSpace(_rcTunnelUrl))
            _rcPanel.SetTunnelUrl(_rcTunnelUrl);

        WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(_rcPanel, this);
        _rcPanel.Show();
    }

    private void HandleRcTunnelError(SquadSdkEvent evt)
    {
        var msg = string.IsNullOrWhiteSpace(evt.Message) ? "Tunnel failed to start" : evt.Message;
        AppendLine($"  ⚠️ Tunnel: {msg}", ThemeBrush("SystemErrorText"));
        SquadDashTrace.Write("UI", $"RC tunnel error message={msg}");
    }

    private void HandleSubSquadsListed(SquadSdkEvent evt)
    {
        if (evt.SubSquadsConfigured != true)
        {
            AppendLine("📦 SubSquads: not configured (.squad/workstreams.json not found)");
            AppendLine("  Create .squad/workstreams.json to define SubSquads for this workspace.");
            return;
        }

        var count = evt.SubSquadsCount ?? 0;
        AppendLine($"📦 SubSquads ({count} configured)");

        if (!string.IsNullOrWhiteSpace(evt.WorkstreamsJson))
        {
            try
            {
                using var doc = System.Text.Json.JsonDocument.Parse(evt.WorkstreamsJson);
                foreach (var ws in doc.RootElement.EnumerateArray())
                {
                    var name = ws.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty;
                    var label = ws.TryGetProperty("labelFilter", out var l) ? l.GetString() ?? string.Empty : string.Empty;
                    var workflow = ws.TryGetProperty("workflow", out var w) ? w.GetString() ?? string.Empty : string.Empty;
                    var isActive = string.Equals(name, evt.ActiveSubsquadName, StringComparison.OrdinalIgnoreCase);
                    var marker = isActive ? "●" : "○";
                    var suffix = isActive ? $"  ← active ({evt.ActiveSubsquadSource})" : string.Empty;
                    AppendLine($"  {marker} {name,-20} label:{label,-20} {workflow}{suffix}");
                }
            }
            catch
            {
                AppendLine($"  (could not parse workstreams list)");
            }
        }

        if (string.IsNullOrWhiteSpace(evt.ActiveSubsquadName))
            AppendLine("  No SubSquad is currently active.");

        SquadDashTrace.Write("UI", $"SubSquads listed count={count} active={evt.ActiveSubsquadName ?? "(none)"}");
    }

    private void HandleSubSquadsActivated(SquadSdkEvent evt)
    {
        var name = evt.SubSquadName ?? "(unknown)";
        AppendLine($"📦 SubSquad activated: {name}");
        SquadDashTrace.Write("UI", $"SubSquad activated name={name}");
    }

    private void HandleSubSquadsError(SquadSdkEvent evt)
    {
        var msg = string.IsNullOrWhiteSpace(evt.Message) ? "SubSquads operation failed" : evt.Message;
        AppendLine($"📦 SubSquads error: {msg}", ThemeBrush("SystemErrorText"));
        SquadDashTrace.Write("UI", $"SubSquads error message={msg}");
    }

    private void HandlePersonalAgentsListed(SquadSdkEvent evt)
    {
        if (evt.PersonalInitialized != true)
        {
            AppendLine("👤 Personal squad is not initialized. Use Workspace → Personal Squad → Initialize to set it up.");
            SquadDashTrace.Write("UI", "Personal squad not initialized");
            return;
        }

        var count = evt.PersonalAgentsCount ?? 0;
        AppendLine($"👤 Personal agents ({count}):");

        if (!string.IsNullOrWhiteSpace(evt.PersonalAgentsJson))
        {
            try
            {
                var agents = System.Text.Json.JsonSerializer.Deserialize<PersonalAgentInfo[]>(evt.PersonalAgentsJson);
                if (agents != null)
                {
                    foreach (var agent in agents)
                        AppendLine($"  • {agent.Name} — {agent.Role}");
                }
            }
            catch
            {
                AppendLine("  (could not parse agents list)");
            }
        }

        if (count == 0)
            AppendLine("  No personal agents configured. Run Workspace → Personal Squad → Initialize to add agents.");

        if (!string.IsNullOrWhiteSpace(evt.PersonalDir))
            AppendLine($"  Directory: {evt.PersonalDir}");

        SquadDashTrace.Write("UI", $"Personal agents listed count={count}");
    }

    private sealed class PersonalAgentInfo
    {
        [System.Text.Json.Serialization.JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;
        [System.Text.Json.Serialization.JsonPropertyName("role")]
        public string Role { get; set; } = string.Empty;
    }

    private void HandlePersonalInitDone(SquadSdkEvent evt)
    {
        var dir = evt.PersonalDir ?? "(unknown path)";
        AppendLine($"👤 Personal squad initialized at: {dir}");
        SquadDashTrace.Write("UI", $"Personal squad initialized dir={dir}");
    }

    private void HandlePersonalError(SquadSdkEvent evt)
    {
        var msg = string.IsNullOrWhiteSpace(evt.Message) ? "Personal squad operation failed" : evt.Message;
        AppendLine($"👤 Personal squad error: {msg}", ThemeBrush("SystemErrorText"));
        SquadDashTrace.Write("UI", $"Personal squad error message={msg}");
    }

    private void HandleRcStopped(SquadSdkEvent evt)
    {
        _remoteAccessActive = false;
        _settingsSnapshot = _settingsStore.SaveRemoteAccessActive(false);
        UpdateRemoteAccessMenuHeader();
        _rcPanelUrl = null;
        _rcTunnelUrl = null;

        if (_rcRegeneratingToken)
        {
            // Keep the panel open; restart RC with the new token immediately.
            _rcRegeneratingToken = false;
            _ = RestartRcAfterRegenerateAsync();
            return;
        }

        _rcActivePort = 0;
        _rcPanel?.Close();
        _rcPanel = null;
        AppendLine("📡 Remote access stopped");
        SquadDashTrace.Write("UI", "RC stopped");
    }

    private async Task RestartRcAfterSessionResetAsync()
    {
        if (_currentWorkspace is null) return;
        try
        {
            // Brief pause to let the OS release the TCP port before we rebind it.
            await Task.Delay(500).ConfigureAwait(false);
            await _bridge.StartRemoteAsync(
                repo: System.IO.Path.GetFileName(_currentWorkspace.FolderPath),
                branch: "main",
                machine: System.Environment.MachineName,
                squadDir: _currentWorkspace.SquadFolderPath,
                cwd: _currentWorkspace.FolderPath,
                port: _rcActivePort,
                sessionId: _conversationManager.CurrentSessionId,
                tunnelMode: _settingsSnapshot.TunnelMode,
                tunnelToken: _settingsSnapshot.TunnelToken,
                rcToken: _settingsSnapshot.RcPersistentToken).ConfigureAwait(true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RestartRcAfterSessionResetAsync), ex);
        }
    }

    private async Task RestartRcAfterRegenerateAsync()
    {
        if (_currentWorkspace is null) return;
        try
        {
            // Brief pause to let the OS release the TCP port before we rebind it.
            await Task.Delay(500).ConfigureAwait(false);
            await _bridge.StartRemoteAsync(
                repo: System.IO.Path.GetFileName(_currentWorkspace.FolderPath),
                branch: "main",
                machine: System.Environment.MachineName,
                squadDir: _currentWorkspace.SquadFolderPath,
                cwd: _currentWorkspace.FolderPath,
                port: _rcActivePort,
                sessionId: _conversationManager.CurrentSessionId,
                tunnelMode: _settingsSnapshot.TunnelMode,
                tunnelToken: _settingsSnapshot.TunnelToken,
                rcToken: _settingsSnapshot.RcPersistentToken).ConfigureAwait(true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RestartRcAfterRegenerateAsync), ex);
            _rcPanel?.Close();
            _rcPanel = null;
        }
    }

    private void RestartAsAdministrator()
    {
        try
        {
            var exePath = Environment.ProcessPath
                ?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
            if (string.IsNullOrEmpty(exePath)) return;

            var workspaceFolder = _currentWorkspace?.FolderPath;
            var arguments = string.IsNullOrWhiteSpace(workspaceFolder)
                ? string.Empty
                : $"--workspace \"{workspaceFolder}\"";

            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
            {
                FileName = exePath,
                Arguments = arguments,
                Verb = "runas",
                UseShellExecute = true,
            });

            // Flush any pending doc-source edits before shutting down.
            // RestartAsAdministrator calls Application.Current.Shutdown() directly and
            // therefore bypasses MainWindow_Closing, so the normal pre-close flush never
            // runs.  Stop the debounce timer and save synchronously here instead.
            _docSourceSaveTimer?.Stop();
            _docPreviewRefreshTimer?.Stop();
            SaveDocSourceToDisk();

            // Release workspace resources immediately so the elevated instance doesn't
            // find us still registered and fail with "Workspace Already Open".
            RemoveRunningInstanceRegistration();
            _instanceActivationChannel.Stop();
            _workspaceOwnershipLease?.Dispose();
            _workspaceOwnershipLease = null;

            Application.Current.Shutdown();
        }
        catch (System.ComponentModel.Win32Exception)
        {
            // User declined UAC — do nothing.
        }
    }

    private void RegenerateRcToken()
    {
        var newToken = Guid.NewGuid().ToString("N");
        _settingsSnapshot = _settingsStore.SaveRcToken(newToken);
        _rcRegeneratingToken = true;
        _ = _bridge.StopRemoteAsync();
    }

    private void HandleRcError(SquadSdkEvent evt)
    {
        _remoteAccessActive = false;
        _settingsSnapshot = _settingsStore.SaveRemoteAccessActive(false);
        UpdateRemoteAccessMenuHeader();
        _rcPanel?.Close();
        _rcPanel = null;
        _rcPanelUrl = null;
        _rcTunnelUrl = null;
        var errorLabel = string.IsNullOrWhiteSpace(evt.Message)
            ? "❌ Remote access error"
            : $"❌ Remote access error: {evt.Message}";
        AppendLine(errorLabel, ThemeBrush("SystemErrorText"));
        SquadDashTrace.Write("UI", $"RC error message={evt.Message ?? "(none)"}");
    }

    private async Task HandleRcAudioStartAsync(SquadSdkEvent evt)
    {
        var connId = evt.ConnectionId;
        if (string.IsNullOrWhiteSpace(connId)) return;

        var key = Environment.GetEnvironmentVariable("SQUAD_SPEECH_KEY", EnvironmentVariableTarget.User);
        var region = _settingsSnapshot.SpeechRegion;
        if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(region))
        {
            SquadDashTrace.Write("RC", $"rc_audio_start ignored — speech not configured (connId={connId})");
            return;
        }

        SquadDashTrace.Write("RC", $"rc_audio_start connId={connId}");
        try
        {
            var phraseHints = Dispatcher.Invoke(BuildSpeechPhraseHints);
            var session = await RemoteSpeechSession.StartAsync(connId, key, region, [.. phraseHints], _settingsSnapshot.SpeechLanguage)
                .ConfigureAwait(false);

            session.PhraseRecognized += (_, text) =>
                Dispatcher.BeginInvoke(() => HandleRcAudioTranscribed(text));

            session.RecognitionError += (_, msg) =>
                SquadDashTrace.Write("RC", $"rc_speech_error connId={connId}: {msg}");

            if (!_remoteSpeechSessions.TryAdd(connId, session))
            {
                // Duplicate start — discard the new session; old one wins
                await session.DisposeAsync().ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("RC", $"rc_audio_start failed connId={connId}: {ex.Message}");
        }
    }

    private void HandleRcAudioChunk(SquadSdkEvent evt)
    {
        var connId = evt.ConnectionId;
        var b64 = evt.AudioData;
        if (string.IsNullOrWhiteSpace(connId) || string.IsNullOrWhiteSpace(b64)) return;

        if (!_remoteSpeechSessions.TryGetValue(connId, out var session)) return;

        try
        {
            var bytes = Convert.FromBase64String(b64);
            session.WriteAudioChunk(bytes, bytes.Length);
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("RC", $"rc_audio_chunk decode error connId={connId}: {ex.Message}");
        }
    }

    private async Task HandleRcAudioEndAsync(SquadSdkEvent evt)
    {
        var connId = evt.ConnectionId;
        if (string.IsNullOrWhiteSpace(connId)) return;
        SquadDashTrace.Write("RC", $"rc_audio_end connId={connId}");

        if (_remoteSpeechSessions.TryRemove(connId, out var session))
        {
            await session.DisposeAsync().ConfigureAwait(false);
        }
    }

    /// <summary>
    /// Called on the UI thread when a remote phone PTT session produces a recognized phrase.
    /// Injects the text into the prompt box and auto-sends (same as local PTT with send enabled).
    /// </summary>
    private void HandleRcAudioTranscribed(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return;
        SquadDashTrace.Write("RC", $"rc_transcribed: {text}");

        // Put text in prompt box and auto-send if the prompt is empty (i.e. no text already there).
        PromptTextBox.Text = text.Trim();
        PromptTextBox.CaretIndex = PromptTextBox.Text.Length;
        if (!string.IsNullOrWhiteSpace(PromptTextBox.Text) && RunButton.IsEnabled)
            RunButton_Click(this, new System.Windows.RoutedEventArgs());
    }

    private void UpdateRemoteAccessMenuHeader()
    {
        if (_remoteAccessMenuItem is null) return;
        _remoteAccessMenuItem.Header = _remoteAccessActive
            ? "Stop _Remote Access"
            : "Start _Remote Access";
    }

    private bool _lastSyncedLoopRunning;

    private void SyncLoopPanel()
    {
        if (LoopPanelBorder is null) return;
        SyncSendButton();
        LoopPanelBorder.Visibility = _loopPanelVisible ? Visibility.Visible : Visibility.Collapsed;
        bool running = IsLoopRunning;

        if (running != _lastSyncedLoopRunning) {
            _lastSyncedLoopRunning = running;
            _idleDetectionService?.SetLoopActive(running);
        }

        bool nativeMode = _settingsSnapshot.LoopMode == LoopMode.NativeAgents;
        bool busyCoordinator = _isPromptRunning && nativeMode && !running;

        // In Queue Loop state: coordinator is busy in native mode, loop not yet running.
        if (busyCoordinator || _loopQueued)
        {
            StartLoopButton.Visibility = Visibility.Visible;
            StartLoopButton.IsEnabled = !_loopQueued; // disable once already queued
            StartLoopButton.Content = _loopQueued ? "Loop Queued" : "Queue Loop";
        }
        else
        {
            StartLoopButton.Visibility = running ? Visibility.Collapsed : Visibility.Visible;
            StartLoopButton.IsEnabled = !running;
            StartLoopButton.Content = "Start Loop";
        }

        StopLoopButton.IsEnabled = running || _loopQueued;
        StopLoopButton.Content = (_loopQueued && !running) ? "✕ Dequeue Loop" : "■ Stop After This";
        AbortLoopButton.Visibility = running ? Visibility.Visible : Visibility.Collapsed;

        if (LoopFilePicker is not null) LoopFilePicker.IsEnabled = !running;
        LoopContinuousContextCheckBox.IsEnabled = !running;
        LoopContinuousContextCheckBox.IsChecked = _settingsSnapshot.LoopContinuousContext;

        string status;
        if (_loopQueued)
            status = _promptQueue.Count > 0 ? "⏸ Paused — dequeuing prompts" : "⏸ Starting after this turn";
        else if (running
            && _activeLoopMode == LoopMode.NativeAgents
            && _loopController.StopState == LoopStopState.StopRequested)
            status = "◌ Stopping after this iteration…";
        else if (running && _loopIsWaiting)
        {
            var remaining = _loopNextIterationAt - DateTimeOffset.Now;
            status = remaining.TotalSeconds > 60
                ? $"⏳ Waiting · next in {(int)remaining.TotalMinutes}m"
                : remaining.TotalSeconds > 0
                    ? $"⏳ Waiting · next in {(int)remaining.TotalSeconds}s"
                    : "⏳ Waiting…";
        }
        else if (running)
            status = _loopCurrentIteration > 0
                ? $"● Running · Round {_loopCurrentIteration}"
                : "● Running";
        else
            status = string.Empty;

        LoopStatusLabel.Text = status;
        LoopStatusLabel.Visibility = string.IsNullOrEmpty(status) ? Visibility.Collapsed : Visibility.Visible;

        if (LoopPanelDequeueMenuItem is not null)
            LoopPanelDequeueMenuItem.Visibility = _loopQueued ? Visibility.Visible : Visibility.Collapsed;
    }

    private void SyncTasksPanel()
    {
        if (TasksPanelBorder is null) return;
        TasksPanelBorder.Visibility = _tasksPanelVisible ? Visibility.Visible : Visibility.Collapsed;
        if (_tasksPanelVisible)
            LoadTasksPanel();
    }

    private void PersistTasksPanelVisible()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { TasksPanelVisible = _tasksPanelVisible };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void PersistLoopPanelVisible()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { LoopPanelVisible = _loopPanelVisible };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void PopulateLoopFilePicker()
    {
        if (_currentWorkspace is null) return;
        var squadPath = _currentWorkspace.SquadFolderPath;
        _suppressLoopPickerChange = true;
        try
        {
            _loopFileEntries = LoopMdParser.ScanForLoopFiles(squadPath);
            LoopFilePicker.ItemsSource = _loopFileEntries.Select(e => {
                var item = new System.Windows.Controls.ComboBoxItem { Content = e.DisplayName };
                if (!string.IsNullOrEmpty(e.TooltipText))
                    item.ToolTip = e.TooltipText;
                return item;
            }).ToList();
            LoopFilePicker.Visibility = _loopFileEntries.Count > 1 ? Visibility.Visible : Visibility.Collapsed;

            var targetPath = _selectedLoopMdPath ?? _loopFileEntries.FirstOrDefault()?.FilePath;
            var idx = 0;
            for (int i = 0; i < _loopFileEntries.Count; i++)
            {
                if (string.Equals(_loopFileEntries[i].FilePath, targetPath, StringComparison.OrdinalIgnoreCase))
                {
                    idx = i;
                    break;
                }
            }
            if (_loopFileEntries.Count > 0)
                LoopFilePicker.SelectedIndex = idx;
        }
        finally
        {
            _suppressLoopPickerChange = false;
        }
        // Sync _selectedLoopMdPath so RefreshLoopOptionsPanel (called by callers right after)
        // has a valid path even when SelectionChanged was suppressed during population.
        _selectedLoopMdPath = GetSelectedLoopFileEntry()?.FilePath;
        UpdateLoopPanelButtonStates();
    }

    private void UpdateLoopFileSubtitle(){ }  // subtitle removed; kept to avoid call-site churn

    private LoopFileEntry? GetSelectedLoopFileEntry()
    {
        var idx = LoopFilePicker.SelectedIndex;
        if (idx >= 0 && idx < _loopFileEntries.Count)
            return _loopFileEntries[idx];
        return _loopFileEntries.FirstOrDefault();
    }

    private string GetEffectiveLoopMdPath()
    {
        var entry = GetSelectedLoopFileEntry();
        if (entry is not null)
            return entry.FilePath;
        return Path.Combine(_currentWorkspace?.SquadFolderPath ?? "", "loop.md");
    }

    private void LoopFilePicker_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
    {
        if (_suppressLoopPickerChange) return;
        var entry = GetSelectedLoopFileEntry();
        _selectedLoopMdPath = entry?.FilePath;
        PersistLoopFileSelection();
        UpdateLoopFileSubtitle();
        UpdateLoopPanelButtonStates();
        RefreshLoopOptionsPanel();
    }

    private void PersistLoopFileSelection()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { SelectedLoopFile = _selectedLoopMdPath };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void RefreshLoopOptionsPanel()
    {
        LoopOptionsPanel.Children.Clear();
        LoopOptionsPanel.Visibility = Visibility.Collapsed;

        if (_selectedLoopMdPath is null) return;

        LoopMdConfig? config;
        try { config = LoopMdParser.Parse(_selectedLoopMdPath); }
        catch { return; }

        if (config?.Options is not { Count: > 0 }) return;

        bool inGroup = false;
        foreach (var opt in config.Options)
        {
            if (opt.Type == "group")
            {
                var header = new TextBlock
                {
                    Text       = opt.Label ?? opt.Key,
                    FontWeight = FontWeights.SemiBold,
                    Margin     = new Thickness(0, 6, 0, 2),
                };
                header.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");
                header.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeBody");
                LoopOptionsPanel.Children.Add(header);
                inGroup = true;
                continue;
            }

            UIElement control = opt.Type switch
            {
                "bool" => CreateBoolOptionControl(opt),
                "int"  => CreateIntOptionControl(opt),
                "enum" => CreateEnumOptionControl(opt),
                _      => CreateIntOptionControl(opt),
            };

            if (inGroup && control is FrameworkElement fe)
                fe.Margin = new Thickness(fe.Margin.Left + 12, fe.Margin.Top, fe.Margin.Right, fe.Margin.Bottom);

            LoopOptionsPanel.Children.Add(control);
        }

        LoopOptionsPanel.Visibility = Visibility.Visible;
        RefreshLoopMergedView();
    }

    private CheckBox CreateBoolOptionControl(LoopOption opt)
    {
        var cb = new CheckBox
        {
            Content   = opt.Label ?? opt.Key,
            IsChecked = opt.RawValue.Equals("true", StringComparison.OrdinalIgnoreCase),
            ToolTip   = opt.Hint,
            Margin    = new Thickness(0, 0, 0, 4),
        };
        cb.SetResourceReference(Control.ForegroundProperty, "LabelText");
        cb.SetResourceReference(Control.FontSizeProperty, "FontSizeBody");

        var capturedPath = _selectedLoopMdPath;
        var capturedKey  = opt.Key;
        cb.Checked   += (_, _) => { LoopMdParser.UpdateOptionValue(capturedPath!, capturedKey, "true");  RefreshLoopMergedView(); };
        cb.Unchecked += (_, _) => { LoopMdParser.UpdateOptionValue(capturedPath!, capturedKey, "false"); RefreshLoopMergedView(); };
        return cb;
    }

    private UIElement CreateIntOptionControl(LoopOption opt)
    {
        var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

        var label = new TextBlock
        {
            Text              = opt.Label ?? opt.Key,
            ToolTip           = opt.Hint,
            VerticalAlignment = VerticalAlignment.Center,
            Margin            = new Thickness(0, 0, 6, 0),
        };
        label.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");
        label.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeBody");

        var tb = new TextBox
        {
            Text   = opt.RawValue,
            Width  = 50,
            HorizontalAlignment = HorizontalAlignment.Left,
        };
        tb.SetResourceReference(Control.BackgroundProperty, "InputSurface");
        tb.SetResourceReference(Control.BorderBrushProperty, "InputBorder");
        tb.SetResourceReference(Control.ForegroundProperty, "LabelText");
        tb.SetResourceReference(Control.FontSizeProperty, "FontSizeBody");

        var capturedPath = _selectedLoopMdPath;
        var capturedKey  = opt.Key;
        tb.LostFocus += (_, _) =>
        {
            var text = tb.Text.Trim();
            if (int.TryParse(text, out _))
            {
                tb.ClearValue(TextBox.BorderBrushProperty);
                if (TryFindResource("InputBorder") is System.Windows.Media.Brush b)
                    tb.BorderBrush = b;
                LoopMdParser.UpdateOptionValue(capturedPath!, capturedKey, text);
                RefreshLoopMergedView();
            }
            else
            {
                tb.BorderBrush = System.Windows.Media.Brushes.Red;
            }
        };

        Grid.SetColumn(label, 0);
        Grid.SetColumn(tb, 1);
        grid.Children.Add(label);
        grid.Children.Add(tb);
        return grid;
    }

    private UIElement CreateEnumOptionControl(LoopOption opt)
    {
        var capturedPath = _selectedLoopMdPath;
        var capturedKey  = opt.Key;

        // ≤ 5 choices → radio button group
        if (opt.Choices is not null && opt.Choices.Count <= 5)
        {
            var stack = new StackPanel { Margin = new Thickness(0, 0, 0, 4) };

            var label = new TextBlock
            {
                Text    = opt.Label ?? opt.Key,
                ToolTip = opt.Hint,
                Margin  = new Thickness(0, 0, 0, 2),
            };
            label.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");
            label.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeBody");
            stack.Children.Add(label);

            foreach (var choice in opt.Choices)
            {
                var rb = new RadioButton
                {
                    Content   = choice,
                    GroupName = opt.Key,
                    IsChecked = string.Equals(choice, opt.RawValue, StringComparison.Ordinal),
                    Margin    = new Thickness(12, 0, 0, 0),
                };
                rb.SetResourceReference(Control.StyleProperty, "ThemedRadioButtonStyle");
                rb.SetResourceReference(Control.ForegroundProperty, "LabelText");
                rb.SetResourceReference(Control.FontSizeProperty, "FontSizeBody");

                var capturedChoice = choice;
                rb.Checked += (_, _) =>
                {
                    LoopMdParser.UpdateOptionValue(capturedPath!, capturedKey, capturedChoice);
                    RefreshLoopMergedView();
                };

                stack.Children.Add(rb);
            }

            return stack;
        }

        // ≥ 6 choices (or null) → existing ComboBox layout
        var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

        var comboLabel = new TextBlock
        {
            Text              = opt.Label ?? opt.Key,
            ToolTip           = opt.Hint,
            VerticalAlignment = VerticalAlignment.Center,
            Margin            = new Thickness(0, 0, 6, 0),
        };
        comboLabel.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");
        comboLabel.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeBody");

        var combo = new ComboBox
        {
            HorizontalAlignment = HorizontalAlignment.Left,
            MaxWidth            = 90,
        };
        combo.SetResourceReference(Control.StyleProperty, "ThemedComboBoxStyle");
        combo.SetResourceReference(Control.FontSizeProperty, "FontSizeBody");

        if (opt.Choices is not null)
            foreach (var choice in opt.Choices)
                combo.Items.Add(choice);

        combo.SelectedItem = opt.RawValue;

        combo.SelectionChanged += (_, _) =>
        {
            if (combo.SelectedItem is string selected)
            {
                LoopMdParser.UpdateOptionValue(capturedPath!, capturedKey, selected);
                RefreshLoopMergedView();
            }
        };

        Grid.SetColumn(comboLabel, 0);
        Grid.SetColumn(combo, 1);
        grid.Children.Add(comboLabel);
        grid.Children.Add(combo);
        return grid;
    }

    private void LoadTasksPanel()
    {
        if (TasksActivePanel is null) return;

        _tasksPanelController ??= new TasksPanelController(
            activePanel: TasksActivePanel,
            completedPanel: TasksCompletedPanel,
            completedSection: TasksCompletedSection,
            outerBorder: TasksPanelBorder,
            getTasksPath: () => _currentWorkspace is null
                                     ? null
                                     : Path.Combine(_currentWorkspace.SquadFolderPath, "tasks.md"),
            editTasksAction: () => EditTasksMenuItem_Click(this, new RoutedEventArgs()),
            priorityDotColor: PriorityDotColor,
            reloadPanel: () => Dispatcher.BeginInvoke(LoadTasksPanel),
            attachFollowUp: task => AttachContextFollowUp(
                $"Task: {task.Text}",
                BuildTypedAttachmentBlock("task", task.Text, BuildTaskContentBody(task))),
            addToNotes: task => AddNoteFromTextWithTitle(
                $"Task - {task.Text}",
                BuildTaskContentBlock(task)),
            getRoster: () => _currentWorkspace is null
                                 ? []
                                 : _teamRosterLoader.Load(_currentWorkspace.FolderPath));

        var workspace = _currentWorkspace;
        if (workspace is null) { _tasksPanelController.ShowEmpty("No workspace open"); return; }

        var tasksPath = Path.Combine(workspace.SquadFolderPath, "tasks.md");
        if (!File.Exists(tasksPath)) { _tasksPanelController.ShowEmpty("No tasks.md found"); return; }

        string[] lines;
        try { lines = File.ReadAllLines(tasksPath); }
        catch { _tasksPanelController.ShowEmpty("Could not read tasks.md"); return; }

        var parseResult = TasksPanelParser.Parse(lines);
        BuildTasksAgentSuggestions(parseResult);

        // Also gather completed items from completed-tasks.md (most-recent-first by file order)
        var completedTasksPath = Path.Combine(workspace.SquadFolderPath, "completed-tasks.md");
        IReadOnlyList<TaskItem> extraCompleted = [];
        if (File.Exists(completedTasksPath))
        {
            try
            {
                var completedLines = File.ReadAllLines(completedTasksPath);
                extraCompleted = TasksPanelParser.ParseCompletedFile(completedLines);
            }
            catch { /* ignore — best-effort */ }
        }

        // tasks.md ✅ section first (most recent), then completed-tasks.md items.
        // Deduplicate: skip completed-tasks.md items whose text already appears in the tasks.md set.
        List<TaskItem> allCompleted;
        if (parseResult.CompletedItems.Count == 0 && extraCompleted.Count == 0)
        {
            allCompleted = [];
        }
        else
        {
            allCompleted = [.. parseResult.CompletedItems];
            var seenTexts = new System.Collections.Generic.HashSet<string>(
                parseResult.CompletedItems.Select(i => i.Text.Trim()),
                StringComparer.OrdinalIgnoreCase);
            foreach (var item in extraCompleted)
            {
                if (seenTexts.Add(item.Text.Trim()))
                    allCompleted.Add(item);
            }
        }

        var combined = new TaskParseResult(parseResult.OpenGroups, allCompleted);
        _tasksPanelController.Refresh(combined);
    }

    private Brush PriorityDotColor(string emoji) => emoji switch
    {
        "🔴" => (Brush)FindResource("TaskPriorityHigh"),
        "🟡" => (Brush)FindResource("TaskPriorityMid"),
        "🟢" => (Brush)FindResource("TaskPriorityLow"),
        "🔵" => (Brush)FindResource("TaskPriorityLow"),
        _ => Brushes.Gray
    };

    private static string PriorityResourceKey(string emoji) => emoji switch
    {
        "🔴" => "TaskPriorityHigh",
        "🟡" => "TaskPriorityMid",
        "🟢" => "TaskPriorityLow",
        "🔵" => "TaskPriorityLow",
        _ => "LabelText"
    };

    private async void StartLoopButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null) return;

            var effectiveLoopMode = (Keyboard.Modifiers & ModifierKeys.Shift) != 0
                ? LoopMode.SquadCli
                : LoopMode.NativeAgents;

            await QueueOrStartLoopAsync(effectiveLoopMode);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(StartLoopButton_Click), ex);
        }
    }

    /// <summary>
    /// Sets the active loop mode and starts the loop immediately, or queues it to start
    /// automatically once the currently-running prompt (and any queued prompts) finish.
    /// </summary>
    private async Task QueueOrStartLoopAsync(LoopMode mode)
    {
        _activeLoopMode = mode;
        if (mode == LoopMode.NativeAgents && (_isPromptRunning || _promptQueue.HasReadyItems))
        {
            _loopQueued = true;
            _conversationManager.UpdateQueuedPromptsState(
                _promptQueue.Items, _followUpAttachments,
                queueRightmostHeld: IsRightmostQueueTabActive(),
                loopQueuedToDequeue: true,
                activeDraftSimEntry: _pec.ActiveDraftSimEntry,
                activeTabIndex: GetActiveQueueTabIndex());
            AppendLoopOutputLine($"⏳ Loop queued — {LoopTimestamp()} — will start after queue drains.", LoopLifecycleBrush);
            SyncLoopPanel();
            return;
        }
        await StartLoopImmediateAsync();
    }

    private async Task StartLoopImmediateAsync(int resumeFromIteration = 0)
    {
        if (_currentWorkspace is null) return;
        BackupAndClearLoopOutput();
        var loopMdPath = GetEffectiveLoopMdPath();

        if (_activeLoopMode == LoopMode.NativeAgents)
        {
            var config = LoopMdParser.Parse(loopMdPath);
            if (config == null)
            {
                OpenLoopConfigFlyout(loopMdPath, LoopConfigFlyoutMode.Configure, existingConfig: null);
                return;
            }
            var filterText = TasksFilterBox?.Text?.Trim() ?? "";
            await _loopController.StartAsync(config, _settingsSnapshot.LoopContinuousContext, _currentWorkspace?.FolderPath, resumeFromIteration, filterText);
        }
        else
        {
            await _bridge.RunLoopAsync(loopMdPath, _currentWorkspace.FolderPath,
                _conversationManager.CurrentSessionId);
        }
    }

    // ── Loop config flyout helpers ───────────────────────────────────────────

    private void _LoopConfigFlyout_Opened(object sender, EventArgs e)
    {
        // Right-align popup's right edge with StartLoopButton's right edge.
        // ActualWidth is valid here because the popup has already been laid out.
        Dispatcher.InvokeAsync(() =>
        {
            _loopConfigFlyout.HorizontalOffset =
                StartLoopButton.ActualWidth - _loopConfigFlyoutBorder.ActualWidth;
        }, System.Windows.Threading.DispatcherPriority.Render);
    }

    private void OpenLoopConfigFlyout(string loopMdPath, LoopConfigFlyoutMode mode, LoopMdConfig? existingConfig)
    {
        _loopMdPathForConfig   = loopMdPath;
        _loopConfigFlyoutMode  = mode;

        LoopConfigIntervalBox.ClearValue(System.Windows.Controls.TextBox.BorderBrushProperty);
        LoopConfigTimeoutBox.ClearValue(System.Windows.Controls.TextBox.BorderBrushProperty);

        if (mode == LoopConfigFlyoutMode.Edit && existingConfig is not null)
        {
            LoopConfigHeaderText.Text     = "Edit loop settings:";
            LoopConfigIntervalBox.Text    = ((int)existingConfig.IntervalMinutes).ToString();
            LoopConfigTimeoutBox.Text     = ((int)existingConfig.TimeoutMinutes).ToString();
            LoopConfigDescriptionBox.Text = existingConfig.Description;
        }
        else
        {
            LoopConfigHeaderText.Text     = "loop.md is not configured. Click OK to start the loop with this configuration:";
            LoopConfigIntervalBox.Text    = "1";
            LoopConfigTimeoutBox.Text     = "60";
            LoopConfigDescriptionBox.Text = "My loop";
        }

        _loopConfigFlyout.PlacementTarget = StartLoopButton;
        _loopConfigFlyout.IsOpen = true;
    }

    private async void LoopConfigOk_Click(object sender, RoutedEventArgs e)
    {
        // Validate interval
        if (!int.TryParse(LoopConfigIntervalBox.Text.Trim(), out var interval) || interval <= 0)
        {
            LoopConfigIntervalBox.BorderBrush = Brushes.Red;
            return;
        }
        LoopConfigIntervalBox.ClearValue(System.Windows.Controls.TextBox.BorderBrushProperty);

        // Validate timeout
        if (!int.TryParse(LoopConfigTimeoutBox.Text.Trim(), out var timeout) || timeout <= 0)
        {
            LoopConfigTimeoutBox.BorderBrush = Brushes.Red;
            return;
        }
        LoopConfigTimeoutBox.ClearValue(System.Windows.Controls.TextBox.BorderBrushProperty);

        var description = LoopConfigDescriptionBox.Text.Trim();
        if (string.IsNullOrEmpty(description))
            description = "My loop";

        var loopMdPath = _loopMdPathForConfig;
        if (loopMdPath is null) return;

        var frontmatter = $"""
            ---
            configured: true
            interval: {interval}
            timeout: {timeout}
            description: "{description}"
            commands: [stop_loop]
            ---

            """;

        var existingContent = File.Exists(loopMdPath)
            ? await File.ReadAllTextAsync(loopMdPath)
            : string.Empty;

        // Strip old frontmatter so we don't double-up when editing an already-configured file.
        var bodyContent = _loopConfigFlyoutMode == LoopConfigFlyoutMode.Edit
            ? LoopMdParser.StripFrontmatter(existingContent)
            : existingContent;

        await File.WriteAllTextAsync(loopMdPath, frontmatter + (bodyContent.Length > 0 ? "\n" + bodyContent : ""));

        _loopConfigFlyout.IsOpen = false;

        if (_loopConfigFlyoutMode == LoopConfigFlyoutMode.Edit)
        {
            // Just refresh the picker labels to reflect the new description.
            PopulateLoopFilePicker();
            RefreshLoopOptionsPanel();
            UpdateLoopFileSubtitle();
            return;
        }

        try
        {
            await StartLoopImmediateAsync();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(LoopConfigOk_Click), ex);
        }
    }

    private void LoopConfigCancel_Click(object sender, RoutedEventArgs e)
    {
        _loopConfigFlyout.IsOpen = false;
    }

    // ── CommitApproval helpers ────────────────────────────────────────────────

    // Matches " committed <sha>" near the end of a notification summary, optionally preceded
    // by punctuation. Covers: ", committed abc1234.", "; committed abc1234", " committed abc1234."
    // The SHA is identified as a run of hex characters (5+) so the word "committed" in ordinary
    // prose is not accidentally stripped.
    private static readonly Regex _committedSuffixRe =
        new(@"[,;.]?\s+committed\s+[0-9a-f]{5,}\S*\.?\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);

    // Matches "Committed" + optional SHA + optional punctuation at the START of an approval description.
    // e.g. "Committed d2d48a8: Fixed login bug" → "Fixed login bug"
    //      "Committed: Fixed login bug"         → "Fixed login bug"
    //      "Committed Fixed login bug"           → "Fixed login bug"
    private static readonly Regex _committedPrefixRe =
        new(@"^committed(\s+[0-9a-f]{5,}[^\w\s]*)?\s*[,;:!?]*\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);

    // Matches " as <commit-code><punctuation>" at the end of a notification summary.
    // e.g. "Fixed the login bug as abc1234.", "Refactored auth as 3f9a2bc,"
    private static readonly Regex _asCommitSuffixRe =
        new(@"\s+as\s+[0-9a-f]{5,}\S*[.,;:!?]*\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);

    private static string BuildApprovalDescription(string? notifSummary, string? prompt)
    {
        if (!string.IsNullOrWhiteSpace(notifSummary))
        {
            var s = notifSummary.Trim();
            // Strip leading "Committed <sha><punct> " — the SHA is shown separately in the panel.
            s = _committedPrefixRe.Replace(s, string.Empty);
            // Strip trailing "<punct> committed XXXXXXX." —
            // the commit SHA link is already shown separately in the panel.
            var m = _committedSuffixRe.Match(s);
            if (m.Success)
                s = s[..m.Index].TrimEnd().TrimEnd('.').Trim();
            // Strip trailing " as <sha><punct>" — same reason.
            var m2 = _asCommitSuffixRe.Match(s);
            if (m2.Success)
                s = s[..m2.Index].TrimEnd().TrimEnd('.').Trim();
            return s;
        }
        if (string.IsNullOrWhiteSpace(prompt))
            return "Commit";
        var words = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return words.Length <= 8
            ? string.Join(' ', words)
            : string.Join(' ', words[..8]) + "…";
    }

    private static string? TruncatePromptHint(string? prompt, int maxChars) =>
        string.IsNullOrWhiteSpace(prompt) ? null
            : prompt.Length <= maxChars ? prompt.Trim()
            : prompt[..maxChars].Trim() + "…";

    private void OnApprovalItemChanged(CommitApprovalItem updated)
    {
        var idx = _approvalItems.FindIndex(i => i.Id == updated.Id);
        if (idx >= 0) _approvalItems[idx] = updated;
        _approvalStore?.Save(_approvalItems);
    }

    private void OnApprovalItemsRemoved(IReadOnlyList<CommitApprovalItem> removed)
    {
        foreach (var r in removed)
            _approvalItems.RemoveAll(i => i.Id == r.Id);
        _approvalStore?.Save(_approvalItems);
    }

    private void ScrollToApprovalTurn(CommitApprovalItem item)
    {
        var turnStartedAt = item.TurnStartedAt;

        // Dismiss any previous not-found popup immediately.
        DismissApprovalNotFoundPopup();

        // Capture mouse position now (on the UI thread during click handling) so the popup
        // can be positioned correctly even after the async retry completes.
        var relPos = Mouse.GetPosition(this);
        var screenPos = PointToScreen(relPos);

        // Ensure the coordinator transcript is visible in the main panel —
        // the prompt paragraphs live in CoordinatorThread's document.
        if (!ReferenceEquals(_selectedTranscriptThread, CoordinatorThread))
            SelectTranscriptThread(CoordinatorThread);

        var entry = CoordinatorThread.PromptParagraphs
            .FirstOrDefault(e => e.Timestamp == turnStartedAt);
        if (entry is not null)
        {
            ScrollToPromptParagraph(entry.Paragraph);
            return;
        }

        // The turn may exist but not yet be rendered (virtual window only shows recent turns).
        // Find its index in the full list and use EnsureTurnRenderedAsync to load batches until
        // it becomes visible, then scroll to it.
        _ = Dispatcher.BeginInvoke(async () =>
        {
            var turnIndex = _conversationManager.FindCoordinatorTurnIndexByTimestamp(turnStartedAt);
            if (turnIndex >= 0)
            {
                await _conversationManager.EnsureTurnRenderedAsync(turnIndex);
                var retryEntry = CoordinatorThread.PromptParagraphs
                    .FirstOrDefault(e => e.Timestamp == turnStartedAt);
                if (retryEntry is not null)
                {
                    ScrollToPromptParagraph(retryEntry.Paragraph);
                    return;
                }
            }
            // Only show the not-found popup if the turn truly doesn't exist in the transcript.
            ShowApprovalNotFoundPopup(screenPos, relPos, item.OriginalPrompt ?? item.TurnPromptHint);
        });
    }

    private void DismissApprovalNotFoundPopup()
    {
        if (_approvalNotFoundPopup is { IsOpen: true } p)
        {
            p.IsOpen = false;
            _approvalNotFoundPopup = null;
        }
    }

    private void ShowApprovalNotFoundPopup(System.Windows.Point screenPoint, System.Windows.Point windowOrigin, string? promptText)
    {
        // Dismiss any prior popup before showing a new one.
        DismissApprovalNotFoundPopup();

        // SetResourceReference doesn't work on Popup children that aren't in the logical tree.
        // Resolve brushes directly from the window's live merged resource dictionaries instead.
        var bgBrush = (TryFindResource("PopupSurface") as Brush) ?? new SolidColorBrush(Color.FromRgb(0x30, 0x2C, 0x28));
        var borderBrush = (TryFindResource("PopupBorder") as Brush) ?? new SolidColorBrush(Color.FromRgb(0x55, 0x4E, 0x47));
        var fgBrush = (TryFindResource("LabelText") as Brush) ?? Brushes.White;
        var subtleBrush = (TryFindResource("SubtleText") as Brush) ?? Brushes.LightGray;

        var stack = new StackPanel { Margin = new Thickness(10, 7, 10, 7) };

        var msgBlock = new TextBlock
        {
            Text = "Entry not found in transcript.",
            Foreground = fgBrush,
            FontWeight = FontWeights.SemiBold,
        };
        stack.Children.Add(msgBlock);

        if (!string.IsNullOrWhiteSpace(promptText))
        {
            var promptLabel = new TextBlock
            {
                Text = "Prompt:",
                Foreground = subtleBrush,
                Margin = new Thickness(0, 6, 0, 2),
            };
            promptLabel.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeXSmall");
            stack.Children.Add(promptLabel);

            var hintBlock = new TextBlock
            {
                Text = promptText,
                TextWrapping = TextWrapping.Wrap,
                MaxWidth = 340,
                Foreground = subtleBrush,
                FontStyle = FontStyles.Italic,
            };
            var scrollViewer = new ScrollViewer
            {
                Content = hintBlock,
                MaxHeight = 260,
                MaxWidth = 340,
                VerticalScrollBarVisibility = System.Windows.Controls.ScrollBarVisibility.Auto,
                HorizontalScrollBarVisibility = System.Windows.Controls.ScrollBarVisibility.Disabled,
            };
            stack.Children.Add(scrollViewer);
        }

        var border = new Border
        {
            Child = stack,
            Background = bgBrush,
            BorderBrush = borderBrush,
            CornerRadius = new CornerRadius(5),
            BorderThickness = new Thickness(1),
            Effect = new System.Windows.Media.Effects.DropShadowEffect
            {
                BlurRadius = 8,
                ShadowDepth = 2,
                Opacity = 0.35,
                Color = Colors.Black,
            },
        };

        var popup = new System.Windows.Controls.Primitives.Popup
        {
            Child = border,
            Placement = System.Windows.Controls.Primitives.PlacementMode.AbsolutePoint,
            HorizontalOffset = screenPoint.X,
            VerticalOffset = screenPoint.Y + 18,
            AllowsTransparency = true,
            StaysOpen = true,
            IsOpen = true,
        };
        _approvalNotFoundPopup = popup;

        bool dismissed = false;

        void FadeAndClose()
        {
            if (dismissed) return;
            dismissed = true;
            MouseMove -= OnWindowMouseMoved;
            var fade = new System.Windows.Media.Animation.DoubleAnimation(
                fromValue: 1.0,
                toValue: 0.0,
                duration: new Duration(TimeSpan.FromSeconds(0.4)),
                fillBehavior: System.Windows.Media.Animation.FillBehavior.Stop);
            fade.Completed += (_, _) =>
            {
                popup.IsOpen = false;
                if (ReferenceEquals(_approvalNotFoundPopup, popup))
                    _approvalNotFoundPopup = null;
            };
            border.BeginAnimation(UIElement.OpacityProperty, fade);
        }

        // Dismiss only once the mouse has moved ≥10 px from the original click position.
        // This prevents the popup from vanishing the instant the cursor drifts by a pixel or two.
        void OnWindowMouseMoved(object? sender, System.Windows.Input.MouseEventArgs e)
        {
            var pos = e.GetPosition(this);
            var dx  = pos.X - windowOrigin.X;
            var dy  = pos.Y - windowOrigin.Y;
            if (dx * dx + dy * dy >= 100.0) // 10 px radius
                FadeAndClose();
        }

        MouseMove += OnWindowMouseMoved;
    }

    // ─────────────────────────────────────────────────────────────────────────

    private void InitializeHostCommands()
    {
        _hostCommandExecutor = new HostCommandExecutor();
        _hostCommandExecutor.Register(new Commands.StartLoopCommandHandler(() =>
        {
            if (!_loopController.IsRunning)
                _ = StartLoopImmediateAsync();
        }));
        _hostCommandExecutor.Register(new Commands.StopLoopCommandHandler(() =>
        {
            _loopController.RequestStop();
            _loopFollowUpTcs?.TrySetResult(true); // unblock any follow-up wait
        }));
        _hostCommandExecutor.Register(new Commands.GetQueueStatusCommandHandler(() => _promptQueue.Items));
        _hostCommandExecutor.Register(new Commands.OpenPanelCommandHandler(panelName =>
        {
            switch (panelName.Trim().ToLowerInvariant())
            {
                case "approvals": ShowApprovalPanel(); break;
                case "tasks": ShowTasksStatusWindow(); break;
                case "trace": ShowTraceWindow(); break;
                case "health": ShowScreenshotHealthWindow(); break;
            }
        }));
        _hostCommandExecutor.Register(new Commands.InjectTextCommandHandler(_ =>
        {
            // Text injection is handled by the done-event handler via InjectResultAsContext behavior.
        }));
        _hostCommandExecutor.Register(new Commands.ClearApprovedCommandHandler(() =>
        {
            _approvalItems.Clear();
            _approvalStore?.Save(_approvalItems);
            _approvalPanel?.ReplaceAllItems(_approvalItems);
        }));
        _hostCommandExecutor.Register(new Commands.TriggerIdleCycleCommandHandler(() =>
        {
            _idleDetectionService?.ForceIdle();
        }));

        _pec.GetHostCommandCatalogInstruction = () =>
            _hostCommandRegistry.BuildCatalogInstruction(_currentWorkspace?.FolderPath);
    }

    /// <summary>
    /// Handles a <c>{"squadash": {"command": "..."}}</c> payload extracted from an AI response.
    /// Must be called on the UI thread (invoked from the SDK event dispatcher).
    /// </summary>
    private void HandleSquadashCommand(string command)
    {
        switch (command.Trim().ToLowerInvariant())
        {
            case "stop_loop":
                if (_loopController.IsRunning)
                {
                    AppendLoopOutputLine(
                        "🤖 AI requested loop stop — finishing current iteration then halting.",
                        LoopLifecycleBrush);
                    _loopController.RequestStop();
                    _loopFollowUpTcs?.TrySetResult(true); // unblock any follow-up wait
                    SyncLoopPanel();
                    _ = _pushNotificationService.NotifyEventAsync("squadash_command", "SquadDash", "AI command: stop_loop");
                }
                break;

            case "start_loop":
                if (!_loopController.IsRunning)
                {
                    AppendLoopOutputLine("🤖 AI requested loop start.", LoopLifecycleBrush);
                    _ = QueueOrStartLoopAsync(LoopMode.NativeAgents);
                    _ = _pushNotificationService.NotifyEventAsync("squadash_command", "SquadDash", "AI command: start_loop");
                }
                break;
        }
    }

    private async void StopLoopButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_loopQueued && !IsLoopRunning)
            {
                _loopQueued = false;
                _conversationManager.UpdateQueuedPromptsState(
                    _promptQueue.Items, _followUpAttachments,
                    queueRightmostHeld: IsRightmostQueueTabActive(),
                    loopQueuedToDequeue: false,
                    activeDraftSimEntry: _pec.ActiveDraftSimEntry,
                    activeTabIndex: GetActiveQueueTabIndex());
                AppendLoopOutputLine($"⏹ Loop dequeued — {LoopTimestamp()} — will not resume after queue drains.", LoopLifecycleBrush);
                SyncLoopPanel();
                return;
            }
            if (_activeLoopMode == LoopMode.NativeAgents)
            {
                AppendLoopOutputLine("⏹ Clean loop termination requested — current iteration will finish then stop.", LoopLifecycleBrush);
                _loopController.RequestStop();
                _loopFollowUpTcs?.TrySetResult(true); // unblock any follow-up wait so stop is honoured promptly
                SyncLoopPanel();
            }
            else
            {
                AppendLoopOutputLine("⏹ Clean loop termination requested — current iteration will finish then stop.", LoopLifecycleBrush);
                await _bridge.StopLoopAsync();
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(StopLoopButton_Click), ex);
        }
    }

    private void LoopContinuousContextCheckBox_Click(object sender, RoutedEventArgs e)
    {
        _settingsSnapshot = _settingsStore.SaveLoopContinuousContext(
            LoopContinuousContextCheckBox.IsChecked == true);
        _conversationManager.UpdateLoopSettingsState(_activeLoopMode, _settingsSnapshot.LoopContinuousContext);
    }

    private async void AbortLoopButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var result = MessageBox.Show(
                "Abort the current agent and stop the loop immediately? The current iteration's work may be incomplete.",
                "Abort Loop",
                MessageBoxButton.OKCancel,
                MessageBoxImage.Warning);
            if (result == MessageBoxResult.OK)
            {
                AppendLoopOutputLine("⚡ Loop abruptly terminated via Abort — current iteration may be incomplete.", new SolidColorBrush(Color.FromRgb(0xFF, 0x88, 0x44)));
                if (_activeLoopMode == LoopMode.NativeAgents)
                {
                    _loopController.RequestAbort();
                    _loopFollowUpTcs?.TrySetResult(false); // unblock any follow-up wait so abort takes effect
                }
                else
                    await _bridge.StopLoopAsync();
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AbortLoopButton_Click), ex);
        }
    }

    private static bool ShouldSuppressSilentBackgroundAgent(SquadSdkEvent evt) =>
        SilentBackgroundAgentPolicy.ShouldSuppressThread(evt.AgentId, evt.AgentName, evt.AgentDisplayName);


    private static readonly string[] SlashCommands = [
        "/activate", "/add-dir", "/agents", "/allow-all", "/approval", "/changelog", "/clear",
        "/context", "/copy", "/delegate", "/deactivate", "/diff", "/doctor", "/experimental", "/feedback",
        "/fleet", "/dropTasks", "/help", "/hire", "/ide", "/init", "/instructions", "/login", "/logout",
        "/lsp", "/mcp", "/model", "/new", "/plan", "/pr", "/queue-sim", "/research", "/restart",
        "/resume", "/review", "/rewind", "/rename", "/retire", "/session", "/sessions", "/share", "/skills",
        "/status", "/tasks", "/test-queue", "/trace", "/update", "/usage", "/version"
    ];

    private void SyncAgentCardsWithThreads()
    {
        foreach (var thread in _agentThreadRegistry.ThreadOrder)
        {
            _agentThreadRegistry.NormalizeThreadAgentIdentity(thread);
            _agentThreadRegistry.NormalizeInactiveThreadState(thread);
        }

        EnsureDynamicAgentCards();
        UpdateAvatarSizes();
        UpdateAgentCardVisibility();

        foreach (var card in _agents)
        {
            card.IsTranscriptTargetSelected = false;
            if (!card.IsLeadAgent)
                card.Threads.Clear();
        }

        // Reset IsSecondaryPanelOpen on all threads before re-sync
        foreach (var thread in _agentThreadRegistry.ThreadOrder)
            thread.IsSecondaryPanelOpen = false;

        foreach (var thread in _agentThreadRegistry.ThreadOrder.OrderBy(candidate => candidate.StartedAt))
        {
            var card = FindAgentCardForThread(thread);
            if (card is null)
                continue;

            card.Threads.Add(thread);
        }

        RefreshSecondaryTranscriptEntries();
        SyncSelectionControllerWithUiState("SyncAgentCardsWithThreads");
        SyncTranscriptTargetIndicators();

        foreach (var card in _agents)
            SyncCardThreads(card);

        SyncAgentCardBuckets();
        // Re-derive IsSecondaryPanelOpen from live secondary panels
        foreach (var entry in _secondaryTranscripts)
            entry.Thread.IsSecondaryPanelOpen = true;
        UpdateTranscriptThreadBadge();
        ScheduleAgentPanelLayoutRefresh();
        SchedulePrimaryAgentHostWarmup();
    }

    private void EnsureDynamicAgentCards()
    {
        var existingDynamicCards = _agents
            .Where(card => card.IsDynamicAgent)
            .ToArray();
        foreach (var dynamicCard in existingDynamicCards)
            _agents.Remove(dynamicCard);

        var now = DateTimeOffset.Now;
        var inactiveAllowance = DynamicAgentHistoryCardLimit;
        foreach (var thread in _agentThreadRegistry.ThreadOrder
                     .Where(candidate => BackgroundTaskPresenter.ShouldSurfaceDynamicAgentCard(candidate, now, DynamicAgentHistoryRetention))
                     .GroupBy(candidate => candidate.Title, StringComparer.OrdinalIgnoreCase)
                     .Select(group => group.OrderByDescending(AgentThreadRegistry.GetThreadLastActivityAt).First())
                     .OrderByDescending(_backgroundTaskPresenter.IsThreadActiveForDisplay)
                     .ThenByDescending(AgentThreadRegistry.GetThreadLastActivityAt))
        {
            if (!_backgroundTaskPresenter.IsThreadActiveForDisplay(thread))
            {
                if (now - AgentThreadRegistry.GetThreadLastActivityAt(thread) > DynamicAgentHistoryRetention)
                    continue;

                if (inactiveAllowance <= 0)
                    continue;

                inactiveAllowance--;
            }

            if (FindAgentCardForThread(thread, includeDynamicCards: false) is not null)
                continue;

            var card = new AgentStatusCard(
                thread.Title,
                GetAgentInitial(thread.Title),
                string.IsNullOrWhiteSpace(thread.AgentType) ? "Background Agent" : AgentThreadRegistry.HumanizeAgentName(thread.AgentType),
                thread.StatusText,
                string.Empty,
                thread.DetailText,
                DynamicAgentDefaultAccentHex,
                accentStorageKey: "dynamic:" + thread.Title,
                isDynamicAgent: true);
            ApplyAgentAccent(card, ResolveAgentAccentHex(card, isLeadAgent: false), persist: false);
            ApplyAgentImage(card, ResolveAgentImagePath(card), persist: false);
            _agents.Add(card);
        }
    }

    private AgentStatusCard? FindAgentCardForThread(
        TranscriptThreadState thread,
        bool includeDynamicCards = true)
    {
        return _agents.FirstOrDefault(card =>
            !card.IsLeadAgent &&
            (includeDynamicCards || !card.IsDynamicAgent) &&
            CardMatchesThread(card, thread));
    }

    private static bool CardMatchesThread(AgentStatusCard card, TranscriptThreadState thread)
    {
        if (!card.IsDynamicAgent && AgentThreadRegistry.HasRosterBackedIdentity(thread) &&
            !string.Equals(card.AccentStorageKey, thread.AgentCardKey?.Trim(), StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        if (!string.IsNullOrWhiteSpace(thread.AgentCardKey) &&
            string.Equals(card.AccentStorageKey, thread.AgentCardKey.Trim(), StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (!string.IsNullOrWhiteSpace(thread.AgentDisplayName) &&
            string.Equals(card.Name, thread.AgentDisplayName.Trim(), StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (!string.IsNullOrWhiteSpace(thread.AgentName))
        {
            if (string.Equals(card.AccentStorageKey, thread.AgentName.Trim(), StringComparison.OrdinalIgnoreCase))
                return true;

            if (string.Equals(card.Name, AgentThreadRegistry.HumanizeAgentName(thread.AgentName), StringComparison.OrdinalIgnoreCase))
                return true;
        }

        if (!string.IsNullOrWhiteSpace(thread.AgentId))
        {
            if (string.Equals(card.AccentStorageKey, thread.AgentId.Trim(), StringComparison.OrdinalIgnoreCase))
                return true;

            if (string.Equals(card.Name, AgentThreadRegistry.HumanizeAgentName(thread.AgentId), StringComparison.OrdinalIgnoreCase))
                return true;
        }

        if (!string.IsNullOrWhiteSpace(thread.Title) &&
            string.Equals(card.Name, AgentThreadRegistry.HumanizeAgentName(thread.Title), StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        return string.Equals(card.Name, thread.Title, StringComparison.OrdinalIgnoreCase);
    }

    private TranscriptThreadState? GetPrimaryThread(AgentStatusCard card)
    {
        var threads = card.Threads
            .Where(thread => !thread.IsPlaceholderThread)
            .ToArray();

        var currentRunThread = threads
            .Where(_backgroundTaskPresenter.IsThreadCurrentRunForDisplay)
            .OrderByDescending(AgentThreadRegistry.GetThreadLastActivityAt)
            .ThenByDescending(thread => thread.StartedAt)
            .FirstOrDefault();
        if (currentRunThread is not null)
            return currentRunThread;

        return threads
            .OrderByDescending(AgentThreadRegistry.GetThreadLastActivityAt)
            .ThenByDescending(thread => thread.StartedAt)
            .FirstOrDefault();
    }

    private string BuildAgentCardDisplayName(
        AgentStatusCard card,
        TranscriptThreadState? primaryThread,
        DateTimeOffset now)
    {
        if (primaryThread is null || !_backgroundTaskPresenter.IsThreadCurrentRunForDisplay(primaryThread))
            return card.Name;

        return StatusTimingPresentation.AppendRunningSuffix(card.Name, primaryThread.StartedAt, now);
    }

    private static string BuildAgentCardStatusText(TranscriptThreadState thread, DateTimeOffset now)
    {
        return BuildTimedStatusText(thread.StatusText, thread.StartedAt, thread.CompletedAt, now);
    }

    private static bool IsStickyTerminalBackgroundStatus(string? statusText)
    {
        if (string.IsNullOrWhiteSpace(statusText))
            return false;

        return statusText.Trim() switch
        {
            "Failed" => true,
            "Cancelled" => true,
            _ => false
        };
    }

    private static bool ShouldDisplayTerminalAgentStatus(TranscriptThreadState thread, DateTimeOffset now)
    {
        if (IsStickyTerminalBackgroundStatus(thread.StatusText))
            return true;

        return string.Equals(thread.StatusText?.Trim(), "Completed", StringComparison.OrdinalIgnoreCase)
            && now - AgentThreadRegistry.GetThreadLastActivityAt(thread) <= AgentActiveDisplayLinger;
    }

    private void SyncCardThreads(AgentStatusCard card, DateTimeOffset? nowOverride = null)
    {
        var now = nowOverride ?? DateTimeOffset.Now;
        var orderedThreads = card.Threads
            .OrderByDescending(thread => thread.StartedAt)
            .ToArray();

        var visibleChipLimit = 3;
        var chipIndex = 0;
        foreach (var orderedThread in orderedThreads)
        {
            if (AgentThreadRegistry.HasMeaningfulThreadTranscript(orderedThread))
            {
                chipIndex++;
                orderedThread.SequenceNumber = chipIndex;
                orderedThread.ChipVisibility = chipIndex <= visibleChipLimit
                    ? Visibility.Visible
                    : Visibility.Collapsed;
            }
            else
            {
                orderedThread.SequenceNumber = 0;
                orderedThread.ChipVisibility = Visibility.Collapsed;
            }

            SyncThreadChip(orderedThread);
        }

        // Reorder the Threads collection in-place so the ItemsControl renders chips
        // left-to-right as #1, #2, #3, [+N]. SequenceNumber=1 is the most recent thread.
        // Threads with SequenceNumber=0 (no meaningful transcript) are pushed to the end.
        SortThreadsForDisplay(card.Threads);

        var primaryThread = GetPrimaryThread(card);

        card.DisplayName = BuildAgentCardDisplayName(card, primaryThread, now);

        if (primaryThread is null)
        {
            if (card.IsDynamicAgent)
            {
                card.StatusText = string.Empty;
                card.DetailText = string.Empty;
            }

            card.ThreadChipsVisibility = Visibility.Collapsed;
            card.OverflowChipVisibility = Visibility.Collapsed;
            card.OverflowChipText = string.Empty;
            return;
        }

        var isCurrentRunThread = _backgroundTaskPresenter.IsThreadCurrentRunForDisplay(primaryThread);
        card.StatusText = isCurrentRunThread
            ? _backgroundTaskPresenter.IsThreadStalledForDisplay(primaryThread, now)
                ? _backgroundTaskPresenter.BuildStalledStatusText(primaryThread, now)
                : AgentThreadRegistry.HumanizeThreadStatus(primaryThread.StatusText) is { Length: > 0 } s ? s : "Running"
            : ShouldDisplayTerminalAgentStatus(primaryThread, now)
                ? BuildAgentCardStatusText(primaryThread, now)
                : string.Empty;
        card.DetailText = isCurrentRunThread
            ? primaryThread.DetailText
            : string.Empty;
        var meaningfulThreadCount = orderedThreads.Count(AgentThreadRegistry.HasMeaningfulThreadTranscript);
        card.ThreadChipsVisibility = meaningfulThreadCount >= 1
            ? Visibility.Visible
            : Visibility.Collapsed;
        var overflowCount = Math.Max(0, meaningfulThreadCount - visibleChipLimit);
        card.OverflowChipVisibility = overflowCount > 0 ? Visibility.Visible : Visibility.Collapsed;
        card.OverflowChipText = overflowCount > 0 ? $"+{overflowCount}" : string.Empty;
        if (card.IsDynamicAgent)
        {
            card.RoleText = string.IsNullOrWhiteSpace(primaryThread.AgentType)
                ? "Background Agent"
                : AgentThreadRegistry.HumanizeAgentName(primaryThread.AgentType);
        }
    }

    /// <summary>
    /// Sorts <paramref name="threads"/> in-place so the ItemsControl renders chip buttons
    /// left-to-right as #1, #2, #3 (most-recent first). Threads with SequenceNumber=0
    /// (no meaningful transcript) are moved to the end. Uses ObservableCollection.Move()
    /// so WPF receives fine-grained CollectionChanged notifications rather than a full reset.
    /// </summary>
    private static void SortThreadsForDisplay(ObservableCollection<TranscriptThreadState> threads)
    {
        // Build the desired order: numbered threads ascending (1, 2, 3…), then un-numbered (0).
        var sorted = threads
            .OrderBy(t => t.SequenceNumber == 0 ? int.MaxValue : t.SequenceNumber)
            .ToList();

        for (var targetIndex = 0; targetIndex < sorted.Count; targetIndex++)
        {
            var currentIndex = threads.IndexOf(sorted[targetIndex]);
            if (currentIndex != targetIndex)
                threads.Move(currentIndex, targetIndex);
        }
    }

    private void UpdateAgentCardFromThread(TranscriptThreadState thread, bool syncBuckets = true)
    {
        var card = FindAgentCardForThread(thread);
        if (card is null)
        {
            SquadDashTrace.Write("AgentCards",
                $"UpdateAgentCardFromThread: card missing for thread={thread.ThreadId} selected={thread.IsSelected}; falling back to full sync");
            SyncAgentCardsWithThreads();
            return;
        }

        if (!card.Threads.Contains(thread))
            card.Threads.Add(thread);

        SyncTranscriptTargetIndicators();
        SyncCardThreads(card);
        if (syncBuckets)
        {
            SquadDashTrace.Write("AgentCards",
                $"UpdateAgentCardFromThread: full bucket sync card={card.Name} thread={thread.ThreadId} selected={thread.IsSelected} status={thread.StatusText}");
            SyncAgentCardBuckets();
        }
        else
        {
            SquadDashTrace.Write("AgentCards",
                $"UpdateAgentCardFromThread: lightweight refresh card={card.Name} thread={thread.ThreadId} selected={thread.IsSelected} status={thread.StatusText}");
        }
        if (thread.IsSelected)
            UpdateTranscriptThreadBadge();
    }

    private void SyncThreadChip(TranscriptThreadState thread)
    {
        var isFailed = thread.StatusText.Trim() is "Failed" or "Cancelled";

        var chipLabel = thread.SequenceNumber > 0 ? $"#{thread.SequenceNumber}" : "#";
        if (!string.IsNullOrWhiteSpace(thread.RequestedAgentHandle))
            chipLabel += "*";
        if (isFailed)
            chipLabel += "!";
        thread.ChipLabel    = chipLabel;
        thread.ChipToolTip  = BuildThreadChipToolTip(thread);
        thread.ChipFontWeight = FontWeights.Normal;

        thread.ChipBackground = (Brush)Application.Current.Resources["ChipSurface"];
        if (isFailed)
        {
            thread.ChipBorderBrush = (Brush)Application.Current.Resources["ChipFailedBorder"];
            thread.ChipForeground  = (Brush)Application.Current.Resources["ChipFailedText"];
        }
        else
        {
            thread.ChipBorderBrush = (Brush)Application.Current.Resources["ChipBorder"];
            thread.ChipForeground  = (Brush)Application.Current.Resources["ChipText"];
        }

        var chipCard = FindAgentCardForThread(thread);
        // Guard on IsTranscriptTargetSelected so that chips never show an underline after their
        // parent card's selection bar has been cleared.
        thread.ChipSelectionIndicatorBrush =
            chipCard is not null &&
            chipCard.IsTranscriptTargetSelected &&
            (thread.IsSelected || thread.IsSecondaryPanelOpen)
                ? chipCard.EffectiveAccentBrush
                : Brushes.Transparent;
    }

    /// <summary>
    /// Lightweight post-selection sync: updates only chip appearance and card indicators.
    /// Used by SelectTranscriptThread to avoid the full SyncAgentCardsWithThreads cost
    /// (which rebuilds Threads collections and triggers ScheduleAgentPanelLayoutRefresh).
    /// </summary>
    private void SyncAgentCardsForSelectionChange(TranscriptThreadState? previousThread, TranscriptThreadState newThread)
    {
        // Re-sync chip visuals for threads that changed selection state
        if (previousThread is not null && !ReferenceEquals(previousThread, newThread))
            SyncThreadChip(previousThread);
        SyncThreadChip(newThread);

        SyncTranscriptTargetIndicators();
        SyncSelectionControllerWithUiState("SelectTranscriptThread");
    }

    private void TraceAgentCardVisualFirstRender(AgentStatusCard card, string reason, Stopwatch stopwatch)
    {
        var cardName = card.Name;
        EventHandler? renderingHandler = null;
        renderingHandler = (_, _) =>
        {
            CompositionTarget.Rendering -= renderingHandler;
            SquadDashTrace.Write(TraceCategory.Performance,
                $"AGENT_CARD_VISUAL_RENDER reason={reason} card={cardName} {stopwatch.ElapsedMilliseconds}ms");
        };
        CompositionTarget.Rendering += renderingHandler;
    }

    private void TraceTranscriptTitleFirstRender(TranscriptThreadState thread, string reason, Stopwatch stopwatch)
    {
        var threadId = thread.ThreadId;
        var title = TranscriptTitleTextBlock.Text;
        EventHandler? renderingHandler = null;
        renderingHandler = (_, _) =>
        {
            CompositionTarget.Rendering -= renderingHandler;
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_TITLE_RENDER reason={reason} thread={threadId} title=\"{title}\" {stopwatch.ElapsedMilliseconds}ms");
        };
        CompositionTarget.Rendering += renderingHandler;
    }

    private void QueueDeferredTranscriptPanelOperation(string reason, Action action)
    {
        var queuedAt = Stopwatch.GetTimestamp();
        _ = Dispatcher.BeginInvoke(PostVisualUpdatePriority, () =>
        {
            var queueMs = (long)((Stopwatch.GetTimestamp() - queuedAt) * 1000.0 / Stopwatch.Frequency);
            var sw = Stopwatch.StartNew();
            try
            {
                action();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException($"DeferredTranscriptPanelOperation.{reason}", ex);
            }
            finally
            {
                sw.Stop();
                if (queueMs >= 20 || sw.ElapsedMilliseconds >= 20)
                    SquadDashTrace.Write(TraceCategory.Performance,
                        $"TRANSCRIPT_PANEL_DEFERRED_OP reason={reason} queue={queueMs}ms work={sw.ElapsedMilliseconds}ms");
            }
        });
    }

    private TranscriptThreadState ApplyImmediatePrimaryTranscriptSelectionVisuals(AgentStatusCard agent, TranscriptThreadState thread)
    {
        var sw = Stopwatch.StartNew();
        var previousActualThread = _selectedTranscriptThread ?? CoordinatorThread;
        var previousVisualThread = _pendingPrimaryTranscriptVisualThread ?? previousActualThread;

        _pendingPrimaryTranscriptVisualThread = thread;

        foreach (var card in _agents)
            card.IsTranscriptTargetSelected = ReferenceEquals(card, agent);

        if (!ReferenceEquals(previousVisualThread, thread))
        {
            previousVisualThread.IsSelected = false;
            SyncThreadChip(previousVisualThread);
        }

        foreach (var entry in _secondaryTranscripts)
        {
            entry.Thread.IsSecondaryPanelOpen = false;
            if (!ReferenceEquals(entry.Thread, thread))
                SyncThreadChip(entry.Thread);
        }

        thread.IsSelected = true;
        SyncThreadChip(thread);

        UpdateTranscriptThreadBadge(thread);
        var clearedSelections = CollapseTranscriptSelectionsForFastSwitch("primary-visual");

        _selectionController.ReconcilePanels(
            Array.Empty<(AgentStatusCard Agent, TranscriptThreadState Thread)>(),
            mainVisible: true);

        sw.Stop();
        SquadDashTrace.Write(TraceCategory.Performance,
            $"AGENT_CARD_VISUAL_IMMEDIATE reason=primary-select card={agent.Name} thread={thread.ThreadId} title=\"{TranscriptTitleTextBlock.Text}\" selectionsCleared={clearedSelections} work={sw.ElapsedMilliseconds}ms");
        TraceAgentCardVisualFirstRender(agent, "primary-select", Stopwatch.StartNew());
        TraceTranscriptTitleFirstRender(thread, "primary-select", Stopwatch.StartNew());
        return previousActualThread;
    }

    private void QueueDeferredPrimaryTranscriptSelection(
        AgentStatusCard agent,
        TranscriptThreadState thread,
        TranscriptThreadState previousThread,
        bool scrollToStart = false)
    {
        var requestVersion = ++_deferredPrimaryTranscriptSelectionVersion;
        var queuedAt = Stopwatch.GetTimestamp();
        _ = Dispatcher.BeginInvoke(PostVisualUpdatePriority, () =>
        {
            if (requestVersion != _deferredPrimaryTranscriptSelectionVersion)
            {
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"AGENT_CARD_DEFERRED_TRANSCRIPT_SELECT_SKIP card={agent.Name} thread={thread.ThreadId} reason=superseded");
                return;
            }

            var queueMs = (long)((Stopwatch.GetTimestamp() - queuedAt) * 1000.0 / Stopwatch.Frequency);
            var sw = Stopwatch.StartNew();
            try
            {
                _selectedTranscriptThread = thread;

                foreach (var entry in _secondaryTranscripts.ToList())
                    CloseSecondaryPanel(entry);

                ShowMainTranscript();
                SelectTranscriptThreadCore(
                    thread,
                    scrollToStart,
                    allowSnapshotFastPath: true,
                    previousThreadOverride: previousThread);

                if (ReferenceEquals(_pendingPrimaryTranscriptVisualThread, thread))
                    _pendingPrimaryTranscriptVisualThread = null;
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("DeferredPrimaryTranscriptSelection", ex);
            }
            finally
            {
                sw.Stop();
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"AGENT_CARD_DEFERRED_TRANSCRIPT_SELECT card={agent.Name} thread={thread.ThreadId} queue={queueMs}ms work={sw.ElapsedMilliseconds}ms");
            }
        });
    }

    private void SyncImmediatePanelToggleVisuals(AgentStatusCard card, string reason, Stopwatch stopwatch)
    {
        foreach (var thread in card.Threads)
            SyncThreadChip(thread);

        SquadDashTrace.Write(TraceCategory.Performance,
            $"AGENT_CARD_VISUAL_IMMEDIATE reason={reason} card={card.Name} work={stopwatch.ElapsedMilliseconds}ms");
        TraceAgentCardVisualFirstRender(card, reason, Stopwatch.StartNew());
    }

    private static string BuildThreadChipToolTip(TranscriptThreadState thread)
    {
        var lines = new List<string> {
            thread.Title
        };

        if (!string.IsNullOrWhiteSpace(thread.LatestIntent))
            lines.Add(thread.LatestIntent.Trim());
        if (!string.IsNullOrWhiteSpace(thread.StatusText))
            lines.Add("Status: " + thread.StatusText);
        if (!string.IsNullOrWhiteSpace(thread.DetailText))
            lines.Add(thread.DetailText);
        if (!string.IsNullOrWhiteSpace(thread.AgentId))
            lines.Add("Agent: " + thread.AgentId);
        if (!string.IsNullOrWhiteSpace(thread.RequestedAgentHandle))
        {
            var requestedName = AgentThreadRegistry.HumanizeAgentName(thread.RequestedAgentHandle);
            var actualName = string.IsNullOrWhiteSpace(thread.Title) ? "unknown" : thread.Title;
            lines.Add($"{requestedName} was requested but the launched agent was identified as '{actualName}'. Response may not reflect {requestedName}'s charter.");
        }

        return string.Join(Environment.NewLine, lines);
    }

    private void AppendLine(string text, Brush? color = null) =>
        AppendLine(CoordinatorThread, text, color);

    private void AppendLine(TranscriptThreadState thread, string text, Brush? color = null)
    {
        if (thread.CurrentTurn is not null)
        {
            if (thread.CurrentTurn.ResponseTextBuilder.Length > 0)
                thread.CurrentTurn.ResponseTextBuilder.AppendLine();
            if (!string.IsNullOrEmpty(text))
                thread.CurrentTurn.ResponseTextBuilder.Append(text);
            AppendResponseSegment(thread, text, startOnNewLine: true);
            ScrollToEndIfAtBottom(thread);
            return;
        }

        var paragraph = CreateTranscriptParagraph();

        if (!string.IsNullOrEmpty(text))
        {
            if (color is null)
            {
                _markdownRenderer.AppendInlineMarkdown(paragraph.Inlines, text);
            }
            else
            {
                var run = new Run(text)
                {
                    Foreground = color
                };
                paragraph.Inlines.Add(run);
            }
        }

        thread.Document.Blocks.Add(paragraph);
        ScrollToEndIfAtBottom(thread);
    }

    /// <summary>
    /// Appends an 8px diagonal-stripe separator to the coordinator transcript with a styled
    /// tooltip showing the shutdown time, offline duration, and startup time.  Called once on
    /// workspace load when a previous shutdown timestamp is found in settings, and also when
    /// rendering a persisted <see cref="TranscriptTurnRecord"/> with
    /// <c>IsSessionBoundary == true</c>.
    /// </summary>
    private void AppendSessionGapIndicator(
        DateTimeOffset shutdownTime,
        TimeSpan offlineDuration,
        DateTimeOffset? startupTime = null,
        string? appVersion = null)
    {
        SquadDashTrace.Write(TraceCategory.UI, $"SessionGap: AppendSessionGapIndicator called shutdownTime={shutdownTime:O} offline={offlineDuration.TotalSeconds:F1}s");
        var resolvedStartupTime = startupTime?.ToLocalTime() ?? DateTimeOffset.Now;

        var tooltip = new ToolTip { Padding = new Thickness(8, 6, 8, 6) };
        tooltip.SetResourceReference(Control.BackgroundProperty, "PopupSurface");
        tooltip.SetResourceReference(Control.BorderBrushProperty, "ActivePanelBorder");
        tooltip.BorderThickness = new Thickness(1);

        // Rebuild the tooltip content fresh each time it opens so relative timestamps
        // ("3 hours ago", "yesterday") are always up-to-date.
        tooltip.Opened += (_, _) =>
        {
            var panel = new StackPanel { Margin = new Thickness(2) };
            var rows = new List<(string label, string value)>
            {
                ("Shutdown: ", StatusTimingPresentation.FormatRelativeTimestamp(shutdownTime)),
                ("Offline:  ", StatusTimingPresentation.FormatOfflineDuration(offlineDuration)),
                ("Startup:  ", StatusTimingPresentation.FormatRelativeTimestamp(resolvedStartupTime)),
            };
            if (!string.IsNullOrEmpty(appVersion))
                rows.Add(("Version:  ", $"SquadDash v{appVersion}"));
            foreach (var (label, value) in rows)
            {
                var tb = new TextBlock { TextWrapping = TextWrapping.NoWrap };
                tb.Inlines.Add(new System.Windows.Documents.Bold(new System.Windows.Documents.Run(label)));
                tb.Inlines.Add(new System.Windows.Documents.Run(value));
                tb.SetResourceReference(TextBlock.ForegroundProperty, "BodyText");
                panel.Children.Add(tb);
            }
            tooltip.Content = panel;
        };

        var stripeBrush = BuildSessionGapStripeBrush();

        var border = new Border
        {
            Height              = 10,
            Margin              = new Thickness(0, 10, 0, 10),
            HorizontalAlignment = HorizontalAlignment.Stretch,
            Background          = stripeBrush,
            ToolTip             = tooltip,
            Cursor              = Cursors.Arrow,
            Tag                 = "SessionGapStripe",
        };
        ToolTipService.SetInitialShowDelay(border, 200);

        var container = new BlockUIContainer(border) { Margin = new Thickness(0) };
        CoordinatorThread.Document.Blocks.Add(container);
        ScrollToEndIfAtBottom(CoordinatorThread);
    }

    /// <summary>Rebuilds the <see cref="DrawingBrush"/> for session-gap stripes using the current theme.</summary>
    private DrawingBrush BuildSessionGapStripeBrush()
    {
        // Derive stripe colors from the live (possibly tinted) TranscriptSurface resource
        // so the gap pattern always matches the current theme + tint stop.
        Color baseColor;
        if (Application.Current.Resources["TranscriptSurface"] is SolidColorBrush themeBrush)
            baseColor = themeBrush.Color;
        else if (string.Equals(_activeThemeName, "Light", StringComparison.OrdinalIgnoreCase))
            baseColor = Color.FromRgb(0xFF, 0xFC, 0xF8);
        else
            baseColor = Color.FromRgb(0x1E, 0x1C, 0x17);

        // Blend baseColor toward black (dark theme) or toward a mid-gray (light theme)
        // to produce two subtly-distinct stripe bands.
        bool isLight = string.Equals(_activeThemeName, "Light", StringComparison.OrdinalIgnoreCase);
        Color lightBandColor, darkBandColor;
        if (isLight)
        {
            darkBandColor  = Blend(baseColor, Colors.Black, 0.04);
            lightBandColor = Blend(baseColor, Colors.Black, 0.10);
        }
        else
        {
            darkBandColor  = Blend(baseColor, Colors.White, 0.06);
            lightBandColor = Blend(baseColor, Colors.White, 0.14);
        }

        var lightBand = new SolidColorBrush(lightBandColor);
        var darkBand  = new SolidColorBrush(darkBandColor);
        lightBand.Freeze();
        darkBand.Freeze();

        const double tileW = 16.0, tileH = 10.0;
        var drawingGroup = new DrawingGroup();
        drawingGroup.Children.Add(new GeometryDrawing(
            darkBand, null,
            new RectangleGeometry(new Rect(0, 0, tileW, tileH))));
        var lightFigure = new PathFigure(
            new Point(0, 0),
            new PathSegment[]
            {
                new LineSegment(new Point(tileW / 2, 0), true),
                new LineSegment(new Point(tileW,     tileH), true),
                new LineSegment(new Point(tileW / 2, tileH), true),
            },
            closed: true);
        drawingGroup.Children.Add(new GeometryDrawing(
            lightBand, null,
            new PathGeometry(new[] { lightFigure })));

        return new DrawingBrush
        {
            Drawing       = drawingGroup,
            TileMode      = TileMode.Tile,
            Viewport      = new Rect(0, 0, tileW, tileH),
            ViewportUnits = BrushMappingMode.Absolute,
        };
    }

    private static Color Blend(Color a, Color b, double t) => Color.FromArgb(
        a.A,
        (byte)(a.R + (b.R - a.R) * t),
        (byte)(a.G + (b.G - a.G) * t),
        (byte)(a.B + (b.B - a.B) * t));

    /// <summary>
    /// Re-paints all session-gap stripe borders in the coordinator transcript with fresh
    /// brushes for the current theme.  Called from <see cref="ApplyTheme"/> on every
    /// theme switch so that live dark↔light toggling works correctly.
    /// </summary>
    private void RefreshSessionGapStripes()
    {
        foreach (var block in CoordinatorThread.Document.Blocks)
        {
            if (block is BlockUIContainer { Child: Border border }
                && border.Tag is string tag && tag == "SessionGapStripe")
            {
                border.Background = BuildSessionGapStripeBrush();
            }
        }
    }


    private void AppendQrCode(string url)
    {
        try
        {
            var qrGenerator = new QRCoder.QRCodeGenerator();
            var qrData = qrGenerator.CreateQrCode(url, QRCoder.QRCodeGenerator.ECCLevel.Q);
            var qrCode = new QRCoder.BitmapByteQRCode(qrData);
            var bitmapBytes = qrCode.GetGraphic(4);

            using var ms = new System.IO.MemoryStream(bitmapBytes);
            var bitmapImage = new System.Windows.Media.Imaging.BitmapImage();
            bitmapImage.BeginInit();
            bitmapImage.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
            bitmapImage.StreamSource = ms;
            bitmapImage.EndInit();
            bitmapImage.Freeze();

            var image = new System.Windows.Controls.Image
            {
                Source = bitmapImage,
                Width = 160,
                Height = 160,
                Stretch = System.Windows.Media.Stretch.Uniform,
                Margin = new Thickness(0, 6, 0, 6),
                HorizontalAlignment = HorizontalAlignment.Left,
                SnapsToDevicePixels = true
            };
            RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.NearestNeighbor);

            var container = new BlockUIContainer(image) { Margin = new Thickness(0, 2, 0, 6) };
            CoordinatorThread.Document.Blocks.Add(container);
            ScrollToEndIfAtBottom(CoordinatorThread);
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("UI", $"QR code generation failed: {ex.Message}");
        }
    }

    private void AppendText(string text) =>
        AppendText(CoordinatorThread, text);

    private void AppendText(TranscriptThreadState thread, string text)
    {
        if (string.IsNullOrEmpty(text))
            return;

        CollapseCurrentTurnThoughts(thread);
        thread.CurrentTurn?.ResponseTextBuilder.Append(text);
        AppendResponseSegment(thread, text);
        ScrollToEndIfAtBottom(thread);
        _searchAdorner?.InvalidateHighlights();
    }

    private static void AppendParagraphText(
        Paragraph paragraph,
        string? text,
        Brush? color = null,
        bool startOnNewLine = false)
    {
        if (paragraph is null || string.IsNullOrEmpty(text))
            return;

        if (startOnNewLine && paragraph.Inlines.Count > 0)
            paragraph.Inlines.Add(new LineBreak());

        var normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
        var segments = normalized.Split('\n');

        for (var index = 0; index < segments.Length; index++)
        {
            if (index > 0)
                paragraph.Inlines.Add(new LineBreak());

            if (segments[index].Length == 0)
                continue;

            var run = new Run(segments[index]);
            if (color is not null)
                run.Foreground = color;

            paragraph.Inlines.Add(run);
        }
    }

    private void OutputTextBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        try
        {
            // During PTT voice recording, don't zoom — allow normal scroll
            if (_pttState == PttState.Active) return;
            var rtb = (sender as RichTextBox) ?? OutputTextBox;
            if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
            {
                FocusTranscriptForInactiveSelectionScroll(rtb);
                return;
            }

            // Capture text anchor under the mouse before zoom
            var mousePos = e.GetPosition(rtb);
            var anchor = rtb.GetPositionFromPoint(mousePos, snapToText: true);

            _transcriptFontSize = Math.Clamp(
                _transcriptFontSize + (e.Delta > 0 ? TranscriptFontSizeStep : -TranscriptFontSizeStep),
                TranscriptFontSizeMin,
                TranscriptFontSizeMax);
            ApplyTranscriptFontSize();
            _settingsSnapshot = _settingsStore.SaveTranscriptFontSize(_transcriptFontSize);

            // After layout, scroll so the anchor stays under the mouse
            if (anchor is not null)
            {
                _ = Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
                {
                    var sv = rtb.Template?.FindName("PART_ContentHost", rtb) as ScrollViewer;
                    if (sv is null)
                        return;
                    var newRect = anchor.GetCharacterRect(LogicalDirection.Forward);
                    sv.ScrollToVerticalOffset(sv.VerticalOffset + (newRect.Top - mousePos.Y));
                });
            }

            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OutputTextBox_PreviewMouseWheel), ex);
        }
    }

    private void FocusTranscriptForInactiveSelectionScroll(RichTextBox rtb)
    {
        // Never steal focus away from the search box — doing so interrupts active searches
        // and causes subsequent keystrokes to land in the RTB instead of SearchBox.
        if (SearchBox?.IsKeyboardFocusWithin == true)
            return;

        if (rtb.IsKeyboardFocusWithin || rtb.Selection.IsEmpty)
            return;

        _ = rtb.Focus();
        _ = Keyboard.Focus(rtb);
    }

    /// <summary>
    /// Window-level pre-processing for left mouse button presses.
    /// Releases stale RichTextBox mouse capture when the user presses the mouse
    /// outside the captured RTB's visual subtree.
    ///
    /// <para>
    /// When a RichTextBox starts a drag-select it captures the mouse.  Normally the
    /// capture is released on <c>MouseLeftButtonUp</c>.  In rare edge cases (rapid
    /// panel switches, focus changes mid-gesture) the capture can survive beyond the
    /// drag, silently redirecting the next click away from its intended target — for
    /// example, the floating scroll-to-bottom button in a different transcript panel.
    /// </para>
    /// <para>
    /// The check is intentionally narrow: only stale RichTextBox capture is released,
    /// and only when the physical click position is outside the captured RTB.
    /// Normal in-progress drag-selects (where the cursor is still over the RTB)
    /// are unaffected.
    /// </para>
    /// </summary>
    protected override void OnPreviewMouseLeftButtonDown(System.Windows.Input.MouseButtonEventArgs e)
    {
        try
        {
            // If a RichTextBox has mouse capture but the user is pressing the mouse
            // somewhere outside it, the capture is stale.  Release it so the click
            // reaches its intended target.
            if (Mouse.Captured is RichTextBox capturedRtb)
            {
                // Use visual hit-testing at the raw mouse position to determine the
                // actual element under the cursor, bypassing the capture redirect.
                var mousePos   = e.GetPosition(this);
                var hitResult  = VisualTreeHelper.HitTest(this, mousePos);
                var hitElement = hitResult?.VisualHit as DependencyObject;

                if (hitElement is not null && !IsVisualDescendantOf(capturedRtb, hitElement))
                {
                    capturedRtb.ReleaseMouseCapture();

                    // The current event was already routed to the captured RTB.
                    // Schedule a synthetic click on the intended target at Input priority
                    // so it fires after the current routing completes and the capture is
                    // fully released.  This gives the user a one-click experience rather
                    // than requiring a second press.
                    var hitButton = FindVisualAncestorOrSelf<Button>(hitElement);
                    if (hitButton is not null)
                    {
                        _ = Dispatcher.BeginInvoke(
                            System.Windows.Threading.DispatcherPriority.Input,
                            () =>
                            {
                                try
                                {
                                    if (hitButton.IsEnabled && hitButton.IsVisible)
                                        hitButton.RaiseEvent(
                                            new RoutedEventArgs(System.Windows.Controls.Primitives.ButtonBase.ClickEvent, hitButton));
                                }
                                catch (Exception ex)
                                {
                                    HandleUiCallbackException("StaleCaptureRelease.SyntheticClick", ex);
                                }
                            });
                    }
                }
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OnPreviewMouseLeftButtonDown), ex);
        }

        base.OnPreviewMouseLeftButtonDown(e);
    }

    /// <summary>Returns <c>true</c> if <paramref name="descendant"/> is the same as
    /// or is a visual descendant of <paramref name="ancestor"/>.</summary>
    private static bool IsVisualDescendantOf(DependencyObject ancestor, DependencyObject descendant)
    {
        var current = descendant;
        while (current is not null)
        {
            if (ReferenceEquals(current, ancestor)) return true;
            current = VisualTreeHelper.GetParent(current);
        }
        return false;
    }

    /// <summary>
    /// Walks the visual tree upward from <paramref name="element"/> (inclusive) and
    /// returns the first ancestor of type <typeparamref name="T"/>, or <c>null</c>.
    /// </summary>
    private static T? FindVisualAncestorOrSelf<T>(DependencyObject element)
        where T : DependencyObject
    {
        var current = element;
        while (current is not null)
        {
            if (current is T match) return match;
            current = VisualTreeHelper.GetParent(current);
        }
        return null;
    }

    /// <summary>
    /// Clicking anywhere inside the transcript RichTextBox dismisses the floating
    /// scroll-to-bottom button (the user has re-engaged with the transcript directly).
    /// Uses PreviewMouseDown so the event fires before the RichTextBox consumes it.
    /// Note: clicking the overlay Button itself does NOT tunnel through OutputTextBox
    /// because the Button is a sibling in the Grid, not a child of the RichTextBox.
    /// </summary>
    private void OutputTextBox_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        try
        {
            ActiveScrollController.DismissScrollButton();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OutputTextBox_PreviewMouseDown), ex);
        }
    }

    /// <summary>
    /// Clicking the floating scroll-to-bottom button jumps to the end of the transcript
    /// and re-enables auto-scroll.
    /// </summary>
    private void ScrollToBottomButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ActiveScrollController.ScrollToBottom();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ScrollToBottomButton_Click), ex);
        }
    }

    private static double ToolIconSizeForFontSize(double fontSize) => Math.Round(fontSize * 1.1);

    private void ApplyFontSizeScale()
    {
        var factor = FontScaleFactors[_fontScaleLevel];
        foreach (var (key, baseSize) in FontSizeBaseValues)
            Application.Current.Resources[key] = Math.Round(baseSize * factor, 1);
    }

    private void SetFontSizeScale(int levelIndex)
    {
        _fontScaleLevel = Math.Clamp(levelIndex, 0, FontScaleFactors.Length - 1);
        ApplyFontSizeScale();
        _settingsSnapshot = _settingsStore.SaveFontSizeScaleLevel(_fontScaleLevel);
    }

    private bool IsInExcludedScrollArea(DependencyObject element)
    {
        var current = element;
        while (current != null)
        {
            if (current is RichTextBox) return true;
            if (current == PromptTextBox) return true;
            current = current is Visual || current is System.Windows.Media.Media3D.Visual3D
                ? VisualTreeHelper.GetParent(current)
                : LogicalTreeHelper.GetParent(current);
        }
        return false;
    }

    // WM_NCMOUSEWHEEL is sent when the wheel is rotated over the non-client caption area,
    // which WindowChrome uses for the empty title-bar background. WPF's routed events never
    // fire there, so we hook the raw message and route it to SetFontSizeScale ourselves.
    private const int WM_NCMOUSEWHEEL = 0x02A3;
    private nint TitlebarNcMouseWheelHook(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
    {
        if (msg != WM_NCMOUSEWHEEL) return nint.Zero;
        if (!NativeMethods.IsCtrlPhysicallyDown()) return nint.Zero;
        // During PTT voice recording, let wheel events pass through as normal scroll
        if (_pttState == PttState.Active) return nint.Zero;
        var delta = (short)((uint)wParam >> 16);
        Dispatcher.BeginInvoke(() => SetFontSizeScale(_fontScaleLevel + (delta > 0 ? 1 : -1)));
        handled = true;
        return nint.Zero;
    }

    private void Window_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        try
        {
            // During PTT voice recording, let wheel events pass through as normal scroll
            if (_pttState == PttState.Active) return;
            if (Keyboard.Modifiers != ModifierKeys.Control) return;
            if (Mouse.DirectlyOver is DependencyObject hovered && IsInExcludedScrollArea(hovered))
                return;
            SetFontSizeScale(_fontScaleLevel + (e.Delta > 0 ? 1 : -1));
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(Window_PreviewMouseWheel), ex);
        }
    }

    private void ApplyTranscriptFontSize()
    {
        OutputTextBox.FontSize = _transcriptFontSize;
        foreach (var entry in _primaryAgentTranscriptHosts.Values)
            entry.TranscriptBox.FontSize = _transcriptFontSize;
        var iconSize = ToolIconSizeForFontSize(_transcriptFontSize);
        foreach (var img in _toolIconImages)
        {
            img.Width = iconSize;
            img.Height = iconSize;
        }
        foreach (var entry in _secondaryTranscripts)
            entry.TranscriptBox.FontSize = _transcriptFontSize;
        foreach (var thread in EnumerateTranscriptThreads())
            ApplyTranscriptFontSizeToDocument(thread.Document);

        // Adorner overlay text uses the RichTextBox FontSize — invalidate so it redraws
        // at the new size without waiting for a layout pass.
        _searchAdorner?.InvalidateHighlights();
    }

    private void ApplyTranscriptFontSizeToDocument(FlowDocument document)
    {
        // Skip the O(N) document walk if the font size hasn't changed since last apply.
        if (document.FontSize == _transcriptFontSize) return;

        document.FontSize = _transcriptFontSize;

        var codeBlockFontSize = _transcriptFontSize * 0.9;
        foreach (var block in document.Blocks.OfType<Section>())
        {
            foreach (var inner in block.Blocks.OfType<BlockUIContainer>())
            {
                // Code block is now: BlockUIContainer > StackPanel > [DockPanel header, TextBox]
                var codeBox = inner.Child switch
                {
                    TextBox tb when tb.Tag is "codeblock" => tb,
                    System.Windows.Controls.StackPanel sp =>
                        sp.Children.OfType<TextBox>().FirstOrDefault(tb => tb.Tag is "codeblock"),
                    _ => null
                };
                if (codeBox is not null)
                    codeBox.FontSize = codeBlockFontSize;
            }

            foreach (var para in block.Blocks.OfType<Paragraph>())
            {
                if (para.Tag is string headingTag)
                {
                    var level = headingTag switch { "h1" => 1, "h2" => 2, _ => 3 };
                    para.FontSize = MarkdownDocumentRenderer.HeadingFontSize(level, _transcriptFontSize);
                }
            }
        }
    }

    private ContextMenu CreateThinkingContextMenu(TranscriptTurnView view)
    {
        var copyMenuItem = MakeItem("Copy Thinking Block");
        copyMenuItem.Tag = view;
        copyMenuItem.Click += CopyThinkingMenuItem_Click;

        var menu = MakeMenu();
        menu.Items.Add(copyMenuItem);
        menu.Opened += ThinkingContextMenu_Opened;
        return menu;
    }

    private void ThinkingContextMenu_Opened(object? sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not ContextMenu { Items.Count: > 0 } menu)
                return;

            if (menu.Items[0] is not MenuItem { Tag: TranscriptTurnView view } copyMenuItem)
                return;

            copyMenuItem.IsEnabled = !string.IsNullOrWhiteSpace(BuildThinkingClipboardText(view));
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ThinkingContextMenu_Opened), ex);
        }
    }

    private void CopyThinkingMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: TranscriptTurnView view })
                return;

            var thinkingText = BuildThinkingClipboardText(view);

            if (string.IsNullOrWhiteSpace(thinkingText))
                return;

            Clipboard.SetText(thinkingText);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(CopyThinkingMenuItem_Click), ex);
        }
    }

    private string BuildThinkingClipboardText(TranscriptTurnView view)
    {
        var builder = new StringBuilder();
        foreach (var item in GetOrderedThinkingNarrativeItems(view))
        {
            switch (item)
            {
                case TranscriptThoughtEntry thought:
                    var thoughtText = FormatThinkingText(thought.RawTextBuilder.ToString());
                    if (!string.IsNullOrWhiteSpace(thoughtText))
                        builder.AppendLine($"{thought.Speaker}: {thoughtText}");
                    break;

                case TranscriptThinkingBlockView block:
                    builder.AppendLine("Tooling...");
                    foreach (var entry in block.ToolEntries.OrderBy(tool => tool.StartedAt))
                    {
                        var icon = entry.IconTextBlock.Text?.Trim();
                        var emoji = ToolTranscriptFormatter.GetToolEmoji(entry.Descriptor).Trim();
                        var message = ExtractInlineText(entry.MessageTextBlock.Inlines).Trim();
                        var combined = string.Join(" ", new[] { icon, emoji, message }.Where(part => !string.IsNullOrWhiteSpace(part)));
                        if (!string.IsNullOrWhiteSpace(combined))
                            builder.AppendLine("  " + combined);
                    }
                    break;
            }
        }

        return builder.ToString().TrimEnd();
    }

    private static IEnumerable<object> GetOrderedThinkingNarrativeItems(TranscriptTurnView view)
    {
        return view.ThoughtEntries
            .Cast<object>()
            .Concat(view.ThinkingBlocks)
            .OrderBy(item => item switch
            {
                TranscriptThoughtEntry thought => thought.Sequence,
                TranscriptThinkingBlockView block => block.Sequence,
                _ => int.MaxValue
            });
    }

    private static string ExtractInlineText(InlineCollection inlines) =>
        TranscriptCopyService.ExtractInlineText(inlines);

    private static void SetClipboardTextWithRetry(string text, int retries = 10)
    {
        Exception? lastEx = null;
        for (int i = 0; i < retries; i++)
        {
            try
            {
                Clipboard.SetDataObject(text, copy: true);
                return;
            }
            catch (System.Runtime.InteropServices.COMException ex)
            {
                lastEx = ex;
                System.Threading.Thread.Sleep(30 * (i + 1)); // 30, 60, 90... ms — linear backoff
            }
        }
        if (lastEx != null)
            System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(lastEx).Throw();
    }

    private void OutputTextBox_CopyExecuted(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
    {
        try
        {
            var text = TranscriptCopyService.BuildSelectionText((sender as RichTextBox) ?? OutputTextBox);
            if (!string.IsNullOrEmpty(text))
                SetClipboardTextWithRetry(text);
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OutputTextBox_CopyExecuted), ex);
        }
    }

    private void OutputTextBox_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        try
        {
            var menu = new ContextMenu();
            menu.SetResourceReference(ContextMenu.StyleProperty, "ThemedContextMenuStyle");

            var activeRtb = (sender as RichTextBox) ?? OutputTextBox;
            var hasSelection = !activeRtb.Selection.IsEmpty;

            if (hasSelection)
            {
                var followUpItem = new MenuItem { Header = "Add to chat" };
                followUpItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                followUpItem.Click += (_, _) => AttachTranscriptFollowUp(activeRtb);
                menu.Items.Add(followUpItem);

                var addToNotesItem = new MenuItem { Header = "Add to Notes" };
                addToNotesItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                addToNotesItem.Click += (_, _) => {
                    var text = TranscriptCopyService.BuildSelectionMarkdown(activeRtb);
                    if (!string.IsNullOrWhiteSpace(text))
                        AddNoteFromText(text);
                    activeRtb.Selection.Select(activeRtb.CaretPosition, activeRtb.CaretPosition);
                };
                menu.Items.Add(addToNotesItem);

                var sep = new Separator();
                sep.SetResourceReference(Separator.StyleProperty, "ThemedMenuSeparatorStyle");
                menu.Items.Add(sep);
            }

            // Check if click is on a table — if so, add "Copy Table" to the menu
            var clickSource = e.OriginalSource as DependencyObject;
            var tablePanel = FindVisualAncestor<StackPanel>(clickSource);
            string? tableMarkdown = tablePanel?.Tag as string;
            if (!string.IsNullOrWhiteSpace(tableMarkdown))
            {
                var copyTableItem = new MenuItem { Header = "Copy Table" };
                copyTableItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                var captured = tableMarkdown;
                copyTableItem.Click += (_, _) => SetClipboardTextWithRetry(captured);
                menu.Items.Insert(0, copyTableItem);
                if (menu.Items.Count > 1)
                {
                    var sep = new Separator();
                    sep.SetResourceReference(Separator.StyleProperty, "ThemedMenuSeparatorStyle");
                    menu.Items.Insert(1, sep);
                }
            }

            var copyItem = new MenuItem { Header = "_Copy" };
            copyItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
            copyItem.IsEnabled = hasSelection;
            copyItem.Click += (_, _) => {
                var text = TranscriptCopyService.BuildSelectionText(activeRtb);
                if (!string.IsNullOrEmpty(text))
                    SetClipboardTextWithRetry(text);
            };
            menu.Items.Add(copyItem);
            menu.PlacementTarget = activeRtb;
            menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OutputTextBox_PreviewMouseRightButtonDown), ex);
        }
    }

    private void OutputTextBox_CopyCanExecute(object sender, System.Windows.Input.CanExecuteRoutedEventArgs e)
    {
        try
        {
            e.CanExecute = !((sender as RichTextBox)?.Selection.IsEmpty ?? true);
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OutputTextBox_CopyCanExecute), ex);
        }
    }

    private void PromptTextBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        try
        {
            if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
                return;

            _promptFontSize = Math.Clamp(
                _promptFontSize + (e.Delta > 0 ? PromptFontSizeStep : -PromptFontSizeStep),
                PromptFontSizeMin,
                PromptFontSizeMax);

            ApplyPromptFontSize();
            _settingsSnapshot = _settingsStore.SavePromptFontSize(_promptFontSize);
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptTextBox_PreviewMouseWheel), ex);
        }
    }

    private void ApplyPromptFontSize()
    {
        PromptTextBox.FontSize = _promptFontSize;
    }

    private void ClearPromptTextBoxLogicalBuffer(string reason) =>
        SetPromptTextBoxLogicalBuffer(string.Empty, 0, 0, 0, reason);

    private void SetPromptTextBoxLogicalBuffer(
        string text,
        int caretIndex,
        int selectionStart = 0,
        int selectionLength = 0,
        string reason = "load")
    {
        text ??= string.Empty;
        var normalizedCaret = Math.Clamp(caretIndex, 0, text.Length);
        var normalizedSelectionStart = Math.Clamp(selectionStart, 0, text.Length);
        var normalizedSelectionLength = Math.Clamp(selectionLength, 0, text.Length - normalizedSelectionStart);

        PromptTextBox.Text = text;
        if (normalizedSelectionLength > 0)
            PromptTextBox.Select(normalizedSelectionStart, normalizedSelectionLength);
        else
            PromptTextBox.CaretIndex = normalizedCaret;

        var undoWasEnabled = PromptTextBox.IsUndoEnabled;
        if (undoWasEnabled)
        {
            PromptTextBox.IsUndoEnabled = false;
            PromptTextBox.IsUndoEnabled = true;
        }
        SquadDashTrace.Write(
            TraceCategory.UI,
            $"PROMPT_BUFFER_LOAD reason={reason} textLen={text.Length} caret={PromptTextBox.CaretIndex} selectionLen={PromptTextBox.SelectionLength} undoCleared={undoWasEnabled}");
    }

    private void PromptTextBox_PreviewTextInput(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        if (_suppressPromptNextTextInput)
        {
            _suppressPromptNextTextInput = false;
            e.Handled = true;
            return;
        }

        // Paren embedding (works on all keyboard layouts)
        if ((e.Text == "(" || e.Text == ")") && PromptTextBox.SelectionLength > 0
            && !PromptTextBox.SelectedText.Contains('\n'))
        {
            if (MarkdownEditorCommands.ApplyInlineParens(PromptTextBox, e.Text))
            {
                e.Handled = true;
                return;
            }
        }
    }

    private void PromptTextBox_ContextMenuOpening(object sender, ContextMenuEventArgs e)
    {
        // Build a themed context menu, appending Smooth Dictation when text is selected.
        var menu = new ContextMenu { Style = (Style)FindResource("ThemedContextMenuStyle") };

        var cut = new MenuItem
        {
            Header  = "Cu_t",
            Style   = (Style)FindResource("ThemedMenuItemStyle"),
            Command = ApplicationCommands.Cut,
            CommandTarget = PromptTextBox,
            IsEnabled = PromptTextBox.SelectionLength > 0
        };
        var copy = new MenuItem
        {
            Header  = "_Copy",
            Style   = (Style)FindResource("ThemedMenuItemStyle"),
            Command = ApplicationCommands.Copy,
            CommandTarget = PromptTextBox,
            IsEnabled = PromptTextBox.SelectionLength > 0
        };
        var paste = new MenuItem
        {
            Header  = "_Paste",
            Style   = (Style)FindResource("ThemedMenuItemStyle"),
            Command = ApplicationCommands.Paste,
            CommandTarget = PromptTextBox,
            IsEnabled = Clipboard.ContainsText()
        };
        menu.Items.Add(cut);
        menu.Items.Add(copy);
        menu.Items.Add(paste);

        if (PromptTextBox.SelectionLength > 0)
        {
            menu.Items.Add(new Separator { Style = (Style)FindResource("ThemedMenuSeparatorStyle") });

            var capturedStart  = PromptTextBox.SelectionStart;
            var capturedLength = PromptTextBox.SelectionLength;
            var smoothItem = new MenuItem
            {
                Header           = "✨ Smooth Dictation",
                InputGestureText = "Shift+Space",
                Style            = (Style)FindResource("ThemedMenuItemStyle")
            };
            smoothItem.Click += (_, _) => {
                PromptTextBox.Select(capturedStart, capturedLength);
                SmoothDictationHelper.ApplyToTextBox(PromptTextBox);
            };
            menu.Items.Add(smoothItem);
        }

        PromptTextBox.ContextMenu = menu;
    }

    private void ApplyDocSourceFontSize()
    {
        if (DocSourceTextBox is not null)
            DocSourceTextBox.FontSize = _docSourceFontSize;
    }

    private void DocSourceTextBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        try
        {
            if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
                return;

            _docSourceFontSize = Math.Clamp(
                _docSourceFontSize + (e.Delta > 0 ? DocSourceFontSizeStep : -DocSourceFontSizeStep),
                DocSourceFontSizeMin,
                DocSourceFontSizeMax);

            ApplyDocSourceFontSize();
            _settingsSnapshot = _settingsStore.SaveDocSourceFontSize(_docSourceFontSize);
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocSourceTextBox_PreviewMouseWheel), ex);
        }
    }

    private void DocSourceTextBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
    {
        // ── Ctrl+Shift+Z → Redo ───────────────────────────────────────────────────
        if (e.Key == System.Windows.Input.Key.Z
            && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
        {
            if (DocSourceTextBox.CanRedo)
                DocSourceTextBox.Redo();
            e.Handled = true;
            return;
        }

        // ── Shift+F3: cycle case of selected text ─────────────────────────────────
        if (e.Key == System.Windows.Input.Key.F3
            && Keyboard.Modifiers == ModifierKeys.Shift
            && DocSourceTextBox.GetSelectionLength() > 0)
        {
            var selStart     = DocSourceTextBox.GetSelectionStart();
            var selectedText = DocSourceTextBox.GetSelectedText();

            bool continuing = _docCycleVariants is not null
                && selStart == _docCycleSelStart
                && selectedText == _docCycleVariants[_docCycleIndex];

            if (!continuing)
            {
                _docCycleOriginal = selectedText;
                _docCycleVariants = TextCaseHelper.ComputeVariants(selectedText);
                _docCycleIndex    = TextCaseHelper.GetFirstVariantIndex(selectedText);
                _docCycleSelStart = selStart;

                var firstVariant = _docCycleVariants[_docCycleIndex];
                DocSourceTextBox.ReplaceSelection(firstVariant);
                DocSourceTextBox.SelectRange(selStart, firstVariant.Length);
            }
            else
            {
                _docCycleIndex = (_docCycleIndex + 1) % _docCycleVariants!.Count;
                var nextVariant = _docCycleVariants[_docCycleIndex];

                // Restore original (no undo), then apply next variant as a single undo entry.
                DocSourceTextBox.IsUndoEnabled = false;
                DocSourceTextBox.SelectRange(_docCycleSelStart, selectedText.Length);
                DocSourceTextBox.Selection.Text = _docCycleOriginal!;
                DocSourceTextBox.IsUndoEnabled  = true;
                DocSourceTextBox.SelectRange(_docCycleSelStart, _docCycleOriginal!.Length);
                DocSourceTextBox.ReplaceSelection(nextVariant);
                DocSourceTextBox.SelectRange(_docCycleSelStart, nextVariant.Length);
            }
            e.Handled = true;
            return;
        }

        // ── Smooth Dictation: Shift+Space on selection ────────────────────────────
        if (e.Key == System.Windows.Input.Key.Space
            && Keyboard.Modifiers == ModifierKeys.Shift
            && DocSourceTextBox.GetSelectionLength() > 0)
        {
            e.Handled = SmoothDictationHelper.ApplyToRichTextBox(DocSourceTextBox);
            return;
        }

        // ── Selection embedding: backtick wraps inline code or fence ─────────────
        if (e.Key == System.Windows.Input.Key.OemTilde
            && Keyboard.Modifiers == ModifierKeys.None
            && DocSourceTextBox.GetSelectionLength() > 0)
        {
            if (MarkdownEditorCommands.ApplyInlineCodeOrFence(DocSourceTextBox))
            {
                _suppressDocSourceNextTextInput = true;
                e.Handled = true;
                return;
            }
        }

        // ── Selection embedding: double-quote wraps inline quote ──────────────────
        if (e.Key == System.Windows.Input.Key.OemQuotes
            && Keyboard.Modifiers == ModifierKeys.Shift
            && DocSourceTextBox.GetSelectionLength() > 0)
        {
            if (MarkdownEditorCommands.ApplyInlineQuote(DocSourceTextBox))
            {
                _suppressDocSourceNextTextInput = true;
                e.Handled = true;
                return;
            }
        }

        // ── Shift+Enter: duplicate current line ───────────────────────────────────
        if (e.Key == System.Windows.Input.Key.Return
            && Keyboard.Modifiers == ModifierKeys.Shift)
        {
            MarkdownEditorCommands.DuplicateLine(DocSourceTextBox);
            e.Handled = true;
            return;
        }

        // ── List continuation: Enter at end of a bullet/numbered line ─────────────
        if (e.Key == System.Windows.Input.Key.Return
            && Keyboard.Modifiers == ModifierKeys.None)
        {
            if (MarkdownEditorCommands.ContinueListOnEnter(DocSourceTextBox))
                e.Handled = true;
            return;
        }

        if (e.Key == System.Windows.Input.Key.Tab)
        {
            e.Handled = true;
            var caret    = DocSourceTextBox.GetCaretOffset();
            var selLen   = DocSourceTextBox.GetSelectionLength();
            var selStart = DocSourceTextBox.GetSelectionStart();
            var text     = DocSourceTextBox.GetPlainText();
            if (selLen > 0)
            {
                DocSourceTextBox.SelectRange(selStart, selLen);
                DocSourceTextBox.ReplaceSelection("    ");
                DocSourceTextBox.SetCaretOffset(selStart + 4);
            }
            else
            {
                DocSourceTextBox.SetPlainText(text.Insert(caret, "    "));
                DocSourceTextBox.SetCaretOffset(caret + 4);
            }
        }
    }

    private void PromptTextBox_KeyDown(object sender, KeyEventArgs e)
    {
        var sw = Stopwatch.StartNew();
        try
        {
            var modifiers = Keyboard.Modifiers;

            if (modifiers == ModifierKeys.None
                && IsPrintableKey(e.Key)
                && !(e.Key == Key.OemTilde && PromptTextBox.SelectionLength > 0))
            {
                return;
            }

            // ── Ctrl+V / Shift+Insert clipboard-image intercept ───────────────────
            var isCtrlV = e.Key == Key.V && (modifiers & ModifierKeys.Control) == ModifierKeys.Control;
            var isShiftIns = e.Key == Key.Insert && (modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
            if (isCtrlV || isShiftIns)
            {
                if (Clipboard.ContainsImage())
                {
                    e.Handled = true;
                    var bitmap = Clipboard.GetImage();
                    if (bitmap is not null)
                        SquadDashTrace.Write("UI",
                            $"[ClipboardPaste] raw clipboard bitmap: " +
                            $"pixels={bitmap.PixelWidth}x{bitmap.PixelHeight} " +
                            $"dpi={bitmap.DpiX:F1}x{bitmap.DpiY:F1} " +
                            $"format={bitmap.Format}");
                    if (bitmap is not null && _currentWorkspace is not null)
                    {
                        var workspace = _currentWorkspace;
                        var editor = new ClipboardImageEditorWindow(this, bitmap, isPromptMode: true);
                        editor.ImageAccepted += edited =>
                        {
                            var path = _pastedImageStore.SaveImage(edited, workspace.FolderPath);
                            var att  = new FollowUpAttachment("", "Image", null, null, null, ImagePath: path);
                            var list = GetOrCreateFollowUpList(_activeTabId ?? "");
                            list.Add(att);
                            UpdateFollowUpStrip();
                            PersistDraftFollowUp();
                        };
                        OpenEditorModeless(editor);
                    }
                    return;
                }
                // If it's Shift+Insert and there's no image, fall through to let the
                // default text-paste handler deal with it (isShiftIns only — Ctrl+V is
                // handled by the textbox natively for text).
                if (isShiftIns)
                {
                    e.Handled = true;
                    var text = Clipboard.GetText();
                    if (!string.IsNullOrEmpty(text))
                    {
                        var tb = sender as System.Windows.Controls.TextBox;
                        tb?.Focus();
                        System.Windows.Input.ApplicationCommands.Paste.Execute(null, tb);
                    }
                    return;
                }
            }

            // ── Shift+F3: cycle case of selected text ─────────────────────────────
            if (e.Key == Key.F3 && modifiers == ModifierKeys.Shift && PromptTextBox.SelectionLength > 0)
            {
                var selStart     = PromptTextBox.SelectionStart;
                var selectedText = PromptTextBox.SelectedText;

                // Continue an existing cycle if the selection is still on the same range and
                // shows one of our computed variants.
                bool continuing = _promptCycleVariants is not null
                    && selStart == _promptCycleSelStart
                    && selectedText == _promptCycleVariants[_promptCycleIndex];

                if (!continuing)
                {
                    // New selection — initialize cycle state.
                    _promptCycleOriginal  = PromptTextBox.Text;
                    _promptCycleVariants  = TextCaseHelper.ComputeVariants(selectedText);
                    _promptCycleIndex     = TextCaseHelper.GetFirstVariantIndex(selectedText);
                    _promptCycleSelStart  = selStart;
                    _promptCycleSelLen    = PromptTextBox.SelectionLength;

                    // First press: normal replacement — creates one undo entry (original → variant).
                    PromptTextBox.SelectedText    = _promptCycleVariants[_promptCycleIndex];
                    PromptTextBox.SelectionStart  = _promptCycleSelStart;
                    PromptTextBox.SelectionLength = _promptCycleVariants[_promptCycleIndex].Length;
                }
                else
                {
                    // Subsequent press on same selection — advance to next variant.
                    _promptCycleIndex = (_promptCycleIndex + 1) % _promptCycleVariants!.Count;
                    var nextVariant = _promptCycleVariants[_promptCycleIndex];

                    // Restore original text without adding to the undo stack, then apply the
                    // new variant as a single replacement so Ctrl+Z always undoes back to original.
                    PromptTextBox.IsUndoEnabled   = false;
                    PromptTextBox.Text            = _promptCycleOriginal!;
                    PromptTextBox.IsUndoEnabled   = true;
                    PromptTextBox.SelectionStart  = _promptCycleSelStart;
                    PromptTextBox.SelectionLength = _promptCycleSelLen;
                    PromptTextBox.SelectedText    = nextVariant;
                    PromptTextBox.SelectionStart  = _promptCycleSelStart;
                    PromptTextBox.SelectionLength = nextVariant.Length;
                }
                e.Handled = true;
                return;
            }

            // ── Smooth Dictation: Shift+Space on selection ────────────────────────
            if (e.Key == Key.Space && modifiers == ModifierKeys.Shift && PromptTextBox.SelectionLength > 0)
            {
                e.Handled = SmoothDictationHelper.ApplyToTextBox(PromptTextBox);
                return;
            }

            // ── Selection embedding: backtick ─────────────────────────────────────────
            if (e.Key == Key.OemTilde && modifiers == ModifierKeys.None
                && PromptTextBox.SelectionLength > 0)
            {
                if (MarkdownEditorCommands.ApplyInlineCodeOrFence(PromptTextBox))
                {
                    _suppressPromptNextTextInput = true;
                    e.Handled = true;
                    return;
                }
            }

            // ── Selection embedding: double-quote ─────────────────────────────────────
            if (e.Key == Key.OemQuotes && modifiers == ModifierKeys.Shift
                && PromptTextBox.SelectionLength > 0)
            {
                if (MarkdownEditorCommands.ApplyInlineQuote(PromptTextBox))
                {
                    _suppressPromptNextTextInput = true;
                    e.Handled = true;
                    return;
                }
            }

            // Record Shift+Enter before dispatching so the hint hides even though WPF
            // handles the newline insertion itself (action resolves to None for Shift+Enter).
            if ((e.Key is Key.Return or Key.Enter) && modifiers == ModifierKeys.Shift)
                RecordHintFeatureUsed(PromptHintFeature.ShiftEnterNewline);

            var action = PromptInputBehavior.ResolveAction(
                MapPromptInputKey(e.Key),
                ctrlPressed: modifiers.HasFlag(ModifierKeys.Control),
                shiftPressed: modifiers.HasFlag(ModifierKeys.Shift),
                runButtonEnabled: RunButton.IsEnabled,
                isMultiLinePrompt: false,
                isIntelliSenseOpen: _intelliSenseState is not null);

            switch (action)
            {
                case PromptInputAction.PrioritizeInQueue:
                    PrioritizeActiveTabToFront();
                    e.Handled = true;
                    break;

                case PromptInputAction.SubmitPrompt:
                    RecordHintFeatureUsed(PromptHintFeature.EnterSend);
                    SquadDashTrace.Write("UI", "Prompt sent: Enter key");
                    RunButton_Click(sender, new RoutedEventArgs());
                    e.Handled = true;
                    break;

                case PromptInputAction.NavigateHistoryPrevious:
                    RecordHintFeatureUsed(PromptHintFeature.CtrlArrowHistory);
                    _conversationManager.NavigateHistory(-1);
                    e.Handled = true;
                    break;

                case PromptInputAction.NavigateHistoryNext:
                    RecordHintFeatureUsed(PromptHintFeature.CtrlArrowHistory);
                    _conversationManager.NavigateHistory(1);
                    e.Handled = true;
                    break;

                case PromptInputAction.IntelliSenseUp:
                    _intelliSenseState = IntelliSenseController.MoveSelection(_intelliSenseState!, -1);
                    UpdateIntelliSensePopup();
                    e.Handled = true;
                    break;

                case PromptInputAction.IntelliSenseDown:
                    _intelliSenseState = IntelliSenseController.MoveSelection(_intelliSenseState!, +1);
                    UpdateIntelliSensePopup();
                    e.Handled = true;
                    break;

                case PromptInputAction.IntelliSenseAccept:
                    ApplyIntelliSenseAccept(andSubmit: e.Key == Key.Return || e.Key == Key.Enter || e.Key == Key.Tab);
                    e.Handled = true;
                    break;

                case PromptInputAction.IntelliSenseDismiss:
                    _intelliSenseState = null;
                    _intelliSenseOwnerBox = null;
                    UpdateIntelliSensePopup();
                    e.Handled = true;
                    break;

                case PromptInputAction.AddQueueSlot:
                    AddEmptyQueueSlot();
                    e.Handled = true;
                    break;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptTextBox_KeyDown), ex);
        }
        finally
        {
            sw.Stop();
            RecordPromptInputTiming(isTextChanged: false, sw.ElapsedMilliseconds);
            if (sw.ElapsedMilliseconds >= 20)
            {
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"PROMPT_KEYDOWN elapsed={sw.ElapsedMilliseconds}ms key={e.Key} repeat={e.IsRepeat} " +
                    $"handled={e.Handled} textLen={PromptTextBox.Text.Length} caret={PromptTextBox.CaretIndex}");
            }
        }
    }

    private static bool IsCtrlKey(Key key) =>
        key is Key.LeftCtrl or Key.RightCtrl;

    private static bool IsShiftKey(Key key) =>
        key is Key.LeftShift or Key.RightShift;

    private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        try
        {
            _idleDetectionService?.RecordActivity();
            ShowMaintenanceBannerIfPending();
            if (TryRecoverPromptInputFromStaleModifiers(e))
                return;

            // ── Ctrl+Shift+Break: abort loop (more-specific, checked first) ──────────
            if (e.Key == Key.Cancel
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) != 0
                && AbortLoopButton.IsEnabled)
            {
                AbortLoopButton_Click(this, new RoutedEventArgs());
                e.Handled = true;
                return;
            }

            // ── Ctrl+Break: abort running prompt (from anywhere in the window) ─────
            if (e.Key == Key.Cancel
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) == 0
                && AbortButton.IsEnabled)
            {
                AbortButton_Click(this, new RoutedEventArgs());
                e.Handled = true;
                return;
            }

            // ── Ctrl+Q: add a new queue slot at the rightmost (front of dispatch) ──
            if (e.Key == Key.Q
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) == 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)   == 0)
            {
                DismissHintPermanently(PromptHintFeature.CtrlQAddQueue);
                AddEmptyQueueSlot();
                e.Handled = true;
                return;
            }

            // ── Ctrl+Shift+Q: add a new queue slot at the leftmost (tail) ────────
            if (e.Key == Key.Q
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   != 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)     == 0)
            {
                DismissHintPermanently(PromptHintFeature.CtrlQAddQueue);
                AddEmptyQueueSlotAtEnd();
                e.Handled = true;
                return;
            }

            // ── Ctrl+Delete: delete the active queue tab ──────────────────────────
            if (e.Key == Key.Delete
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) == 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)   == 0
                && _activeTabId is not null)
            {
                DeleteActiveQueueTabWithKeyboard();
                e.Handled = true;
                return;
            }

            // ── Ctrl+Shift+Z: Redo — works in any focused text control ─────────────
            if (e.Key == Key.Z
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                if (Keyboard.FocusedElement is RichTextBox rtb && rtb.CanRedo)
                {
                    rtb.Redo();
                    e.Handled = true;
                    return;
                }
                if (Keyboard.FocusedElement is TextBox txb && txb.CanUndo)
                {
                    ApplicationCommands.Redo.Execute(null, txb);
                    e.Handled = true;
                    return;
                }
            }

            // ── Ctrl+Shift+A: Revise with AI — fires from ANY focused text box with a selection ──
            if (e.Key == Key.A
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                if (TryShowRevisePopupForFocusedTextBox())
                {
                    e.Handled = true;
                    return;
                }
            }

            // ── Ctrl+Shift+C: Quick AI Cleanup — directly revises selection with the configured cleanup prompt ──
            if (e.Key == Key.C
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                if (TryDirectReviseForFocusedTextBox(_settingsSnapshot.CleanupPrompt))
                {
                    e.Handled = true;
                    return;
                }
            }

            // ── Ctrl+Shift+V: attach clipboard text as a follow-up to the active prompt/tab ──
            if (e.Key == Key.V
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   != 0)
            {
                var clipText = Clipboard.GetText();
                if (!string.IsNullOrEmpty(clipText))
                    AttachContextFollowUp(BuildClipboardAttachDescription(clipText), BuildTypedAttachmentBlock("clipboard", null, clipText));
                e.Handled = true;
                return;
            }

            // ── Ctrl+Alt+Shift+PageUp: move prompt panel above the transcript ─────────
            if (e.Key == Key.PageUp
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)     != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   != 0)
            {
                SetPromptPanelOnTop(true);
                e.Handled = true;
                return;
            }

            // ── Ctrl+Alt+Shift+PageDown: return prompt panel to the bottom ─────────────
            if (e.Key == Key.PageDown
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)     != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   != 0)
            {
                SetPromptPanelOnTop(false);
                e.Handled = true;
                return;
            }

            // ── Ctrl+Alt+Plus / Ctrl+Alt+Add: next tint stop ─────────────────────
            if ((e.Key == Key.OemPlus || e.Key == Key.Add)
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)     != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   == 0)
            {
                SetWorkspaceTintStop((_activeTintStop + 1) % 8);
                e.Handled = true;
                return;
            }

            // ── Ctrl+Alt+Minus / Ctrl+Alt+Subtract: previous tint stop ───────────
            if ((e.Key == Key.OemMinus || e.Key == Key.Subtract)
                && (Keyboard.Modifiers & ModifierKeys.Control) != 0
                && (Keyboard.Modifiers & ModifierKeys.Alt)     != 0
                && (Keyboard.Modifiers & ModifierKeys.Shift)   == 0)
            {
                SetWorkspaceTintStop((_activeTintStop + 7) % 8);
                e.Handled = true;
                return;
            }

            // ── Escape: dismiss doc find bar from any focus position (incl. WebBrowser preview) ──
            if (e.Key == Key.Escape && _docSourceFindBar is not null)
            {
                HideDocSourceFindBar();
                e.Handled = true;
                return;
            }

            // ── Feature 1: Guard against rerouting input when DocSourceTextBox has focus ──
            if (DocSourceTextBox?.IsFocused == true)
            {
                // Allow Ctrl+F to trigger find-in-source instead of transcript search
                if (e.Key == Key.F && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
                {
                    ShowDocSourceFindBar();
                    e.Handled = true;
                    return;
                }
                // Ctrl+B: wrap selection (or insert empty pair) in markdown bold
                if (e.Key == Key.B && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
                {
                    DocSourceTextBox_ApplyBold();
                    e.Handled = true;
                    return;
                }
                // Ctrl+I: wrap selection in markdown italic
                if (e.Key == Key.I && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
                {
                    DocSourceTextBox_ApplyItalic();
                    e.Handled = true;
                    return;
                }
                // Let bare Ctrl key events fall through so the double-tap PTT state machine
                // can track them. All other keys are eaten here.
                if (!IsCtrlKey(e.Key))
                    return;
            }

            // Also guard the transcript search box and the doc find text box
            if (SearchBox?.IsFocused == true) return;
            if (_docSourceFindTextBox?.IsFocused == true) return;

            if (_pttState == PttState.Idle
                && PromptTextBox?.IsKeyboardFocusWithin == true
                && Keyboard.Modifiers == ModifierKeys.None
                && IsPrintableKey(e.Key))
            {
                return;
            }

            // ── Fullscreen transcript: any printable key (no Ctrl/Alt) peeks prompt without exiting fullscreen ──
            // Only intercept on the FIRST key (when prompt is hidden); once visible let the TextBox handle input normally.
            if (_transcriptFullScreenEnabled
                && !_fullScreenPromptVisible
                && (Keyboard.Modifiers & (ModifierKeys.Control | ModifierKeys.Alt)) == ModifierKeys.None
                && IsPrintableKey(e.Key))
            {
                ShowFullScreenPrompt();
                var ch = KeyToChar(e.Key, (Keyboard.Modifiers & ModifierKeys.Shift) != 0);
                if (PromptTextBox is not { } promptBox)
                    return;
                if (ch.HasValue)
                    promptBox.AppendText(ch.Value.ToString());
                promptBox.CaretIndex = promptBox.Text.Length;
                promptBox.Focus();
                e.Handled = true;
                return;
            }

            // ── Search shortcuts ─────────────────────────────────────────────────
            if (e.Key == Key.F && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
            {
                // If docs panel is open, Ctrl+F searches the doc — even if focus is on
                // a toolbar button, the topics tree, or the rendered preview.
                if (DocsPanel.Visibility == Visibility.Visible && DocSourceTextBox != null)
                {
                    ShowDocSourceFindBar();
                    e.Handled = true;
                    return;
                }
                SearchBox?.Focus();
                SearchBox?.SelectAll();
                e.Handled = true;
                return;
            }

            if (e.Key == Key.F3)
            {
                // When the prompt box has a selection, Shift+F3 should cycle case there
                // (handled by PromptTextBox_KeyDown), not navigate search results.
                if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0
                    && PromptTextBox?.IsKeyboardFocusWithin == true
                    && PromptTextBox.SelectionLength > 0)
                    return;

                if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
                    _ = NavigateToMatchAsync(_searchMatchCursor - 1);
                else
                    _ = NavigateToMatchAsync(_searchMatchCursor + 1);
                e.Handled = true;
                return;
            }

            if (e.Key == Key.F11 && (Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                ToggleAgentsPanelFocusMode();
                e.Handled = true;
                return;
            }

            if (e.Key == Key.F11)
            {
                SetTranscriptFullScreen(!_transcriptFullScreenEnabled);
                e.Handled = true;
                return;
            }

            if (e.Key == Key.Escape && _transcriptFullScreenEnabled && _pttState != PttState.Active)
            {
                e.Handled = true;
                if (_fullScreenPromptVisible)
                    HideFullScreenPrompt();
                else
                    SetTranscriptFullScreen(false);
                return;
            }

            // ── Screenshot shortcut (Ctrl+Shift+C) ──────────────────────────────
            if (e.Key == Key.C &&
                (Keyboard.Modifiers & ModifierKeys.Control) != 0 &&
                (Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                ShowScreenshotOverlay();
                e.Handled = true;
                return;
            }

            // ── Fullscreen: paste clipboard text to prompt (Ctrl+V or Shift+Insert) ──
            if (_transcriptFullScreenEnabled &&
                ((e.Key == Key.V && (Keyboard.Modifiers & ModifierKeys.Control) != 0) ||
                 (e.Key == Key.Insert && (Keyboard.Modifiers & ModifierKeys.Shift) != 0)) &&
                Clipboard.ContainsText())
            {
                var text = Clipboard.GetText();
                if (!_fullScreenPromptVisible)
                    ShowFullScreenPrompt();
                if (PromptTextBox is not { } promptBox)
                    return;
                promptBox.AppendText(text);
                promptBox.CaretIndex = promptBox.Text.Length;
                promptBox.Focus();
                e.Handled = true;
                return;
            }

            // ── Prompt text box: Ctrl+B for markdown bold ─────────────────────
            if (e.Key == Key.B &&
                (Keyboard.Modifiers & ModifierKeys.Control) != 0 &&
                PromptTextBox?.IsFocused == true)
            {
                PromptTextBox_ApplyBold();
                e.Handled = true;
                return;
            }

            // ── Ctrl+Tab / Ctrl+Shift+Tab: cycle through queue tabs ──────────────
            if (e.Key == Key.Tab &&
                (Keyboard.Modifiers & ModifierKeys.Control) != 0 &&
                _promptQueue.Items.Count > 0)
            {
                bool reverse = (Keyboard.Modifiers & ModifierKeys.Shift) != 0;
                DismissHintPermanently(PromptHintFeature.CtrlTabNavigation);
                CycleQueueTab(reverse);
                e.Handled = true;
                return;
            }

            // ── Ctrl+Page Up / Ctrl+Page Down: navigate between prompts ─────────
            if (e.Key == Key.PageUp && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
            {
                PromptNavUpButton_Click(this, new RoutedEventArgs());
                e.Handled = true;
                return;
            }
            if (e.Key == Key.PageDown && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
            {
                PromptNavDownButton_Click(this, new RoutedEventArgs());
                e.Handled = true;
                return;
            }

            // ── Page Up / Page Down: scroll active transcript ────────────────
            // Intercept plain PageUp/PageDown unconditionally (even from the prompt
            // box) so the transcript is always reachable by keyboard, but leave the
            // key alone when a document editor or its find bar has focus so those
            // controls can scroll their own content normally.
            if (e.Key == Key.PageUp
                && Keyboard.Modifiers == ModifierKeys.None
                && DocSourceTextBox?.IsKeyboardFocusWithin != true
                && _docSourceFindTextBox?.IsFocused != true)
            {
                ActiveScrollController.ScrollPageUp();
                e.Handled = true;
                return;
            }
            if (e.Key == Key.PageDown
                && Keyboard.Modifiers == ModifierKeys.None
                && DocSourceTextBox?.IsKeyboardFocusWithin != true
                && _docSourceFindTextBox?.IsFocused != true)
            {
                ActiveScrollController.ScrollPageDown();
                e.Handled = true;
                return;
            }

            // ── Ctrl+End: scroll main transcript to bottom ────────────────────
            if (e.Key == Key.End &&
                (Keyboard.Modifiers & ModifierKeys.Control) != 0 &&
                PromptTextBox?.IsFocused != true)
            {
                ActiveScrollController.ScrollToBottom();
                e.Handled = true;
                return;
            }

            switch (_pttState)
            {
                case PttState.Idle:
                    if (IsCtrlKey(e.Key) && !e.IsRepeat)
                    {
                        _ctrlFirstDownTime = DateTime.UtcNow;
                        _pttState = PttState.TapDown;
                    }
                    break;

                case PttState.TapDown:
                    if (IsCtrlKey(e.Key))
                    {
                        // Still holding first Ctrl — check if held too long
                        if (e.IsRepeat && (DateTime.UtcNow - _ctrlFirstDownTime).TotalMilliseconds > PttMaxTapHoldMs)
                            _pttState = PttState.Idle;
                    }
                    else
                    {
                        // Any other key invalidates the sequence
                        _pttState = PttState.Idle;
                    }
                    break;

                case PttState.TapReleased:
                    if (IsCtrlKey(e.Key) && !e.IsRepeat)
                    {
                        var gapMs = (DateTime.UtcNow - _ctrlFirstReleaseTime).TotalMilliseconds;
                        if (gapMs <= PttDoubleClickTime)
                        {
                            TryStartPushToTalkFromFocusedTarget(gapMs);
                        }
                        else
                        {
                            // Too slow — treat as fresh first tap
                            _ctrlFirstDownTime = DateTime.UtcNow;
                            _pttState = PttState.TapDown;
                        }
                    }
                    else if (!IsCtrlKey(e.Key))
                    {
                        _pttState = PttState.Idle;
                    }
                    break;

                case PttState.Active:
                    if (IsCtrlKey(e.Key) && e.IsRepeat)
                    {
                        // Still holding Ctrl — keep recording
                    }
                    else if (e.Key == Key.Escape)
                    {
                        e.Handled = true;
                        _ = StopPushToTalkAsync(send: false);
                    }
                    else if (IsShiftKey(e.Key))
                    {
                        // Shift held during recording — flag immediately so Ctrl release suppresses send
                        // even if the KeyUp is swallowed by an IME or third-party keyboard hook.
                        _pttShiftTappedDuringRecording = true;
                        _pttWindow?.MarkShiftSuppressed();
                    }
                    else if (!IsCtrlKey(e.Key))
                    {
                        // Any other key disengages PTT (no send)
                        _ = StopPushToTalkAsync(send: false);
                    }
                    break;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(Window_PreviewKeyDown), ex);
        }
    }

    /// <summary>Resets the PTT double-tap state machine to Idle (called when an owned window closes).</summary>
    internal void ResetPttState()
    {
        _pttState = PttState.Idle;
        _ctrlFirstDownTime = default;
        _ctrlFirstReleaseTime = default;
    }

    private void TryStartPushToTalkFromFocusedTarget(double secondTapGapMs)
    {
        // Resolve the target at activation time — prefer a focused RichTextBox
        // (e.g. DocSourceTextBox) over the fallback TextBox path.
        var focusedTextBox     = Keyboard.FocusedElement as TextBox;
        var focusedRichTextBox = Keyboard.FocusedElement as RichTextBox;

        _pttTargetRichTextBox = null;
        if (focusedRichTextBox != null)
        {
            _pttTargetRichTextBox = focusedRichTextBox;
            _pttTargetTextBox = null;
        }
        else
        {
            _pttTargetTextBox = focusedTextBox != null && focusedTextBox != PromptTextBox
                ? focusedTextBox
                : PromptTextBox;
        }

        var pttTargetExists = _pttTargetRichTextBox != null || _pttTargetTextBox != null;
        if (!pttTargetExists)
        {
            SquadDashTrace.Write("UI", "PTT pending cancelled: no target textbox");
            _pttState = PttState.Idle;
            return;
        }

        // Capture caret/selection before the PTT panel becomes visible (layout shifts can reset it).
        if (_pttTargetRichTextBox != null)
        {
            _sessionCaretIndex      = _pttTargetRichTextBox.GetSelectionStart();
            _sessionSelectionLength = _pttTargetRichTextBox.GetSelectionLength();
        }
        else
        {
            _sessionCaretIndex      = _pttTargetTextBox!.SelectionStart;
            _sessionSelectionLength = _pttTargetTextBox!.SelectionLength;
        }

        // Queue whenever the target is the prompt box AND auto-send is enabled.
        // EnqueueCurrentPrompt works whether or not a prompt is currently running,
        // so we no longer need to gate on !_isPromptRunning.
        // Guard _activeTabId == null: when the user is editing a queued tab via Ctrl+Up/Down,
        // the prompt box may be empty but we must not treat that as a new draft auto-send.
        _voiceStartedWithSendEnabled = _activeTabId == null
                                       && _settingsSnapshot.PttAutoSend
                                       && _pttTargetRichTextBox == null
                                       && _pttTargetTextBox == PromptTextBox
                                       && string.IsNullOrEmpty(PromptTextBox?.Text);
        SquadDashTrace.Write("UI",
            $"PTT started: secondTapGapMs={secondTapGapMs:0} voiceSendEnabled={_voiceStartedWithSendEnabled} " +
            $"targetIsPrompt={_pttTargetTextBox == PromptTextBox} promptHasText={!string.IsNullOrEmpty(PromptTextBox?.Text)}");

        // Re-evaluate on each PTT activation: true only when quick reply
        // buttons are visible, the prompt box was empty at start time,
        // and buttons appeared at least 400ms ago (so user could see them).
        // Guard _activeTabId == null: Ctrl+Up/Down navigation to a queued tab leaves
        // the prompt box empty, which must not be mistaken for an empty active draft.
        _dictationStartedForQuickReply = _activeTabId == null
                                         && _lastQuickReplyEntry?.AllowQuickReplies == true
                                         && _currentQuickReplyOptions.Length > 0
                                         && string.IsNullOrEmpty(PromptTextBox?.Text)
                                         && (DateTime.UtcNow - _quickRepliesShownAt).TotalMilliseconds >= QuickReplyReadWindowMs;
        _pttState = PttState.Active;
        _ = StartPushToTalkAsync();
    }

    /// <summary>
    /// Called when another application takes focus away from SquadDash.
    /// WPF does not deliver KeyUp events while the window is inactive, so if Ctrl is
    /// released in the other app we would never see it. Instead of stopping PTT
    /// immediately (which would discard in-progress dictation), we:
    ///   1. Flag that focus was lost — this suppresses auto-send when PTT eventually stops.
    ///   2. Start a 100 ms polling timer that uses GetAsyncKeyState to watch for Ctrl release.
    ///      When both Ctrl keys are up, PTT stops (no send).
    /// </summary>
    private void Window_Deactivated(object sender, EventArgs e)
    {
        try
        {
            switch (_pttState)
            {
                case PttState.Active:
                    // Mark so that whenever PTT stops (via poll or KeyUp after refocus), send is suppressed.
                    _pttLostFocusDuringRecording = true;
                    _pttWindow?.MarkShiftSuppressed(); // update the visual hint
                    StartPttCtrlPollTimer();
                    break;

                case PttState.TapDown:
                case PttState.TapReleased:
                    // Reset stale tap sequence — cannot complete the double-tap while inactive.
                    _pttState = PttState.Idle;
                    _ctrlFirstDownTime = default;
                    _ctrlFirstReleaseTime = default;
                    break;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(Window_Deactivated), ex);
        }
    }

    /// <summary>
    /// Called when SquadDash regains focus. If the user kept Ctrl held while switching back,
    /// cancel the poll timer and return to normal key-event handling. The
    /// <c>_pttLostFocusDuringRecording</c> flag remains set so that when they finally release
    /// Ctrl the send is still suppressed — they may not have seen what was dictated.
    /// If Ctrl is already up when we regain focus, the poll timer will fire within 100 ms
    /// and stop PTT cleanly.
    /// </summary>
    private void Window_Activated(object sender, EventArgs e)
    {
        try
        {
            if (_pttState == PttState.Active && _pttCtrlPollTimer is not null)
            {
                // Still Active — check immediately whether Ctrl is already released.
                if (!NativeMethods.IsCtrlPhysicallyDown())
                {
                    StopPttCtrlPollTimer();
                    _ = StopPushToTalkAsync(send: false); // _pttLostFocusDuringRecording already set
                }
                else
                {
                    // Ctrl still held — normal PreviewKeyUp will handle release; cancel poll.
                    StopPttCtrlPollTimer();
                }
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(Window_Activated), ex);
        }
    }

    private void StartPttCtrlPollTimer()
    {
        if (_pttCtrlPollTimer is not null)
            return; // already running

        _pttCtrlPollTimer = new DispatcherTimer(DispatcherPriority.Normal, Dispatcher)
        {
            Interval = TimeSpan.FromMilliseconds(100)
        };
        _pttCtrlPollTimer.Tick += (_, _) =>
        {
            if (_pttState != PttState.Active || NativeMethods.IsCtrlPhysicallyDown())
                return; // Ctrl still down or PTT already stopped — keep waiting

            // Ctrl has been released while inactive — stop PTT without sending.
            StopPttCtrlPollTimer();
            _ = StopPushToTalkAsync(send: false);
        };
        _pttCtrlPollTimer.Start();
    }

    private void StopPttCtrlPollTimer()
    {
        if (_pttCtrlPollTimer is null)
            return;
        _pttCtrlPollTimer.Stop();
        _pttCtrlPollTimer = null;
    }

    private bool TryRecoverPromptInputFromStaleModifiers(KeyEventArgs e)
    {
        if (PromptTextBox?.IsKeyboardFocusWithin != true || !IsPrintableKey(e.Key))
            return false;

        var logicalModifiers = Keyboard.Modifiers;
        var physicalModifiers = NativeMethods.GetPhysicalModifierKeys();
        const ModifierKeys shortcutModifiers = ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Windows;

        if ((logicalModifiers & shortcutModifiers) == ModifierKeys.None ||
            (physicalModifiers & shortcutModifiers) != ModifierKeys.None)
            return false;

        var ch = KeyToChar(e.Key, (physicalModifiers & ModifierKeys.Shift) != 0);
        if (ch is null)
            return false;

        var insertionStart = PromptTextBox.SelectionStart;
        PromptTextBox.SelectedText = ch.Value.ToString();
        PromptTextBox.CaretIndex = insertionStart + 1;
        e.Handled = true;
        SquadDashTrace.Write(
            TraceCategory.UI,
            $"PROMPT_STALE_MODIFIER_RECOVERY key={e.Key} logical={logicalModifiers} physical={physicalModifiers} textLen={PromptTextBox.Text.Length}");
        ResetPttState();
        return true;
    }

    private void Window_PreviewKeyUp(object sender, KeyEventArgs e)
    {
        try
        {
            switch (_pttState)
            {
                case PttState.TapDown:
                    if (IsCtrlKey(e.Key))
                    {
                        var heldMs = (DateTime.UtcNow - _ctrlFirstDownTime).TotalMilliseconds;
                        if (heldMs <= PttMaxTapHoldMs)
                        {
                            _ctrlFirstReleaseTime = DateTime.UtcNow;
                            _pttState = PttState.TapReleased;
                        }
                        else
                        {
                            _pttState = PttState.Idle;
                        }
                    }
                    break;

                case PttState.Active:
                    if (IsShiftKey(e.Key))
                    {
                        _pttShiftTappedDuringRecording = true;
                        _pttWindow?.MarkShiftSuppressed();
                    }
                    else if (IsCtrlKey(e.Key))
                    {
                        // Check shift via multiple paths: ModifierKeys (VK_SHIFT), and the
                        // left/right-specific device state (VK_LSHIFT/VK_RSHIFT). Some IMEs
                        // and keyboard utilities update the sided VKs but not the combined flag,
                        // or vice versa — querying all three makes suppression reliable.
                        var shiftHeld = (e.KeyboardDevice.Modifiers & ModifierKeys.Shift) != 0
                                        || e.KeyboardDevice.IsKeyDown(Key.LeftShift)
                                        || e.KeyboardDevice.IsKeyDown(Key.RightShift);
                        var suppress = shiftHeld || _pttShiftTappedDuringRecording || _pttHadPreexistingText
                                                 || _pttLostFocusDuringRecording;
                        StopPttCtrlPollTimer();
                        var shouldSend = _voiceStartedWithSendEnabled && !suppress;
                        // When the dictation session started with quick replies visible and an
                        // empty box, route directly as a quick reply response instead of queuing.
                        var sendAsQuickReply = shouldSend && _dictationStartedForQuickReply;
                        _ = StopPushToTalkAsync(send: shouldSend && !sendAsQuickReply, sendAsQuickReply: sendAsQuickReply);
                    }
                    break;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(Window_PreviewKeyUp), ex);
        }
    }

    private async Task StartPushToTalkAsync()
    {
        var targetIsPrompt = _pttTargetRichTextBox == null && (_pttTargetTextBox == null || _pttTargetTextBox == PromptTextBox);
        var targetHasText  = _pttTargetRichTextBox != null
            ? !string.IsNullOrEmpty(_pttTargetRichTextBox.GetPlainText())
            : !string.IsNullOrEmpty((_pttTargetTextBox ?? PromptTextBox).Text);
        _pttHadPreexistingText = targetHasText;
        _promptHasVoiceInput = false;
        RecordHintFeatureUsed(PromptHintFeature.PushToTalk);

        // In fullscreen transcript mode, peek the prompt so the user can see dictated text.
        if (_transcriptFullScreenEnabled && !_fullScreenPromptVisible)
            ShowFullScreenPrompt();
        _pttShiftTappedDuringRecording = false;
        _pttLostFocusDuringRecording = false;
        string key, region;
        if (_settingsSnapshot.SpeechProvider == SpeechProvider.OpenAI)
        {
            key = _settingsSnapshot.OpenAiSpeechApiKey ?? string.Empty;
            region = string.Empty;
        }
        else
        {
            key = Environment.GetEnvironmentVariable("SQUAD_SPEECH_KEY", EnvironmentVariableTarget.User) ?? string.Empty;
            region = _settingsSnapshot.SpeechRegion ?? string.Empty;
        }

        if (string.IsNullOrWhiteSpace(key) ||
            (_settingsSnapshot.SpeechProvider == SpeechProvider.Azure && string.IsNullOrWhiteSpace(region)))
        {
            SquadDashTrace.Write(
                TraceCategory.UI,
                $"PTT_START skipped missingConfig provider={_settingsSnapshot.SpeechProvider} hasKey={!string.IsNullOrWhiteSpace(key)} hasRegion={!string.IsNullOrWhiteSpace(region)}");
            _pttState = PttState.Idle;
            return;
        }

        SquadDashTrace.Write(
            TraceCategory.UI,
            $"PTT_START provider={_settingsSnapshot.SpeechProvider} targetIsPrompt={targetIsPrompt} targetHadText={_pttHadPreexistingText}");

        // Only show the "release to send" hint when targeting an empty prompt AND auto-send is enabled.
        _pttWindow = new PushToTalkWindow(this, showHint: targetIsPrompt && !_pttHadPreexistingText && _voiceStartedWithSendEnabled);
        PositionPttWindow();
        _pttWindow.Show();
        _pttWindow.VolumeBar.Height = 0;

        _speechService = _settingsSnapshot.SpeechProvider == SpeechProvider.OpenAI
            ? new WhisperSpeechRecognitionService()
            : new AzureSpeechRecognitionService();

        _speechService.PhraseRecognized += (_, text) =>
            Dispatcher.BeginInvoke(DispatcherPriority.Input, () => AppendSpeechToPrompt(text));

        _speechService.VolumeChanged += (_, level) => QueuePttVolumeUpdate(level);

        _speechService.RecognitionError += (_, msg) =>
            Dispatcher.BeginInvoke(DispatcherPriority.Input, () =>
            {
                _ = StopPushToTalkAsync(send: false);
                AppendLine("[voice error] " + msg, System.Windows.Media.Brushes.Red);
            });

        try
        {
            var phraseHints = BuildSpeechPhraseHints();
            await _speechService.StartAsync(key, region, phraseHints).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Dispatcher.Invoke(() =>
            {
                _pttState = PttState.Idle;
                ClosePttWindow();
                _speechService?.Dispose();
                _speechService = null;
                SquadDashTrace.Write(TraceCategory.UI, $"PTT_START failed provider={_settingsSnapshot.SpeechProvider}: {ex.Message}");
                AppendLine("[voice error] " + ex.Message, System.Windows.Media.Brushes.Red);
            });
        }
    }

    /// <summary>
    /// Builds the phrase list registered with Azure Speech to improve recognition
    /// of unusual team member names (e.g. "Lyra Morn", "Vesper Knox", "Jae Min Kade").
    /// Emits both full names and individual name tokens so partial references ("ask Lyra") work.
    /// </summary>
    private IReadOnlyList<string> BuildSpeechPhraseHints()
    {
        if (_currentWorkspace is null)
            return [];

        try
        {
            var members = _teamRosterLoader.Load(_currentWorkspace.FolderPath);
            var phrases = new List<string>(members.Count * 3);
            foreach (var member in members)
            {
                if (string.IsNullOrWhiteSpace(member.Name))
                    continue;

                phrases.Add(member.Name);

                // Also add each individual token so partial references like "ask Lyra" resolve
                foreach (var token in member.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries))
                    if (token.Length > 2)
                        phrases.Add(token);
            }
            return phrases;
        }
        catch
        {
            return [];
        }
    }

    private void PositionPttWindow()
    {
        if (_pttWindow is null)
            return;

        System.Windows.Point physicalPoint;
        FrameworkElement dpiSource;
        if (_pttTargetRichTextBox != null)
        {
            dpiSource = _pttTargetRichTextBox;
            try
            {
                var caretRect = _pttTargetRichTextBox.GetRectFromOffset(_sessionCaretIndex);
                physicalPoint = _pttTargetRichTextBox.PointToScreen(
                    new System.Windows.Point(caretRect.Left, caretRect.Bottom));
            }
            catch
            {
                physicalPoint = _pttTargetRichTextBox.PointToScreen(
                    new System.Windows.Point(0, _pttTargetRichTextBox.ActualHeight + 4));
            }
        }
        else
        {
            var target = _pttTargetTextBox ?? PromptTextBox;
            dpiSource = target;
            try
            {
                var caretRect = target.GetRectFromCharacterIndex(_sessionCaretIndex);
                physicalPoint = target.PointToScreen(new System.Windows.Point(caretRect.Left, caretRect.Bottom));
            }
            catch
            {
                physicalPoint = target.PointToScreen(new System.Windows.Point(0, target.ActualHeight + 4));
            }
        }

        // Get the work area (physical px) for whichever monitor the caret is on.
        var physWa = NativeMethods.GetWorkAreaForPhysicalPoint((int)physicalPoint.X, (int)physicalPoint.Y);

        // Convert everything to WPF logical DIPs.
        var logicalPoint = DpiHelper.PhysicalToLogical(dpiSource, physicalPoint);
        var logicalWaOrigin = DpiHelper.PhysicalToLogical(dpiSource, new System.Windows.Point(physWa.Left, physWa.Top));
        var logicalWaCorner = DpiHelper.PhysicalToLogical(dpiSource, new System.Windows.Point(physWa.Right, physWa.Bottom));
        var logicalWorkArea = new System.Windows.Rect(logicalWaOrigin, logicalWaCorner);

        _pttWindow.PositionUnderCaret(logicalPoint, logicalWorkArea);
    }

    private void ClosePttWindow()
    {
        _pttWindow?.Close();
        _pttWindow = null;
    }

    private void QueuePttVolumeUpdate(double level)
    {
        Interlocked.Increment(ref _pttVolumeEventTraceCount);
        lock (_pttVolumeUpdateGate)
        {
            _pendingPttVolumeHeight = Math.Max(2, level * 36);
            if (_pttVolumeUpdateQueued)
                return;

            _pttVolumeUpdateQueued = true;
        }

        // Volume callbacks can arrive very quickly. Keep them below keyboard input
        // priority and coalesce to one queued UI update so the meter cannot starve typing.
        try
        {
            _ = Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(FlushPttVolumeUpdate));
        }
        catch (ObjectDisposedException)
        {
            lock (_pttVolumeUpdateGate)
                _pttVolumeUpdateQueued = false;
        }
        catch (InvalidOperationException)
        {
            lock (_pttVolumeUpdateGate)
                _pttVolumeUpdateQueued = false;
        }
    }

    private void FlushPttVolumeUpdate()
    {
        double height;
        lock (_pttVolumeUpdateGate)
        {
            height = _pendingPttVolumeHeight;
            _pttVolumeUpdateQueued = false;
        }

        if (_pttWindow is not null)
            _pttWindow.VolumeBar.Height = height;

        Interlocked.Increment(ref _pttVolumeUiUpdateTraceCount);
        TracePttVolumeSummaryIfNeeded();
    }

    private void TracePttVolumeSummaryIfNeeded()
    {
        if (_pttVolumeTraceStopwatch.ElapsedMilliseconds < PttVolumeTraceIntervalMs)
            return;

        var eventCount = Interlocked.Exchange(ref _pttVolumeEventTraceCount, 0);
        var uiUpdateCount = Interlocked.Exchange(ref _pttVolumeUiUpdateTraceCount, 0);
        SquadDashTrace.Write(
            TraceCategory.UI,
            $"PTT_VOLUME batchMs={_pttVolumeTraceStopwatch.ElapsedMilliseconds} events={eventCount} uiUpdates={uiUpdateCount} " +
            $"state={_pttState} queued={_pttVolumeUpdateQueued}");
        _pttVolumeTraceStopwatch.Restart();
    }

    private async Task StopPushToTalkAsync(bool send, bool sendAsQuickReply = false)
    {
        SquadDashTrace.Write("UI", $"PTT_STOP begin send={send} sendAsQuickReply={sendAsQuickReply} state={_pttState}");
        _pttState = PttState.Idle;
        StopPttCtrlPollTimer();
        _pttLostFocusDuringRecording = false;
        var wasTargetingPrompt = _pttTargetRichTextBox == null
            && (_pttTargetTextBox is null || _pttTargetTextBox == PromptTextBox);
        // Do NOT null _pttTargetTextBox here — pending AppendSpeechToPrompt BeginInvoke
        // callbacks in the dispatcher queue still need it to route text to the correct target.
        // It is cleared inside the Background-priority dispatcher callback below, after all
        // Normal-priority phrase callbacks have drained.

        var service = _speechService;
        _speechService = null;

        // Set _pttDraining BEFORE ClosePttWindow so that HandleRestartRequestChanged and
        // MainWindow_Closing cannot slip through the gap between _pttState going Idle and
        // this flag being raised.  ClosePttWindow fires WPF window-lifecycle events that may
        // re-enter the dispatcher pump, so the flag must already be set when that happens.
        if (service != null)
            _pttDraining = true;

        ClosePttWindow();

        if (service != null)
        {
            // _pttDraining prevents HandleRestartRequestChanged and Window_Closing from
            // initiating a restart while Azure is flushing the final phrase recognition.
            // _pttState is already Idle at this point so without this flag the build watcher
            // could fire, see Idle state, and Close() before the last phrase arrives.
            try { await service.StopAsync().ConfigureAwait(false); }
            catch { }
            service.Dispose();
        }

        // Yield to the dispatcher at Background priority. All queued Normal-priority
        // PhraseRecognized → AppendSpeechToPrompt callbacks run first, writing the final
        // dictated text into PromptTextBox.Text. Then our Background item runs on the
        // dispatcher thread — we clear _pttDraining and trigger the deferred close if needed.
        await Dispatcher.InvokeAsync(() =>
        {
            _pttTargetTextBox = null;  // Clear after all Normal-priority phrase callbacks have run.
            _pttTargetRichTextBox = null;
            _pttDraining = false;
            TryCompletePendingRestart("ptt-restart-after-drain", emergencySaveBeforeClose: true);
        }, System.Windows.Threading.DispatcherPriority.Background);

        if (_restartPending && !_isPromptRunning)
            return; // Close() was initiated inside the dispatcher callback above.

        if (wasTargetingPrompt && (send || sendAsQuickReply) && !_promptHasVoiceInput)
        {
            SquadDashTrace.Write("UI", "PTT send suppressed: no recognized voice input.");
            send = false;
            sendAsQuickReply = false;
        }

        if (sendAsQuickReply && wasTargetingPrompt)
        {
            SquadDashTrace.Write("UI", "Prompt sent: PTT quick-reply send");
            // Dictation started with quick replies visible and an empty prompt box.
            // Send the dictated text directly as a quick reply response (bypass the queue).
            await Task.Delay(220).ConfigureAwait(false);
            Dispatcher.Invoke(() =>
            {
                var text = PromptTextBox.Text.Trim();
                var quickReplyEntry = _lastQuickReplyEntry;
                if (!string.IsNullOrWhiteSpace(text) && quickReplyEntry?.AllowQuickReplies == true)
                {
                    if (_activeTabId != null)
                    {
                        var idToRemove = _activeTabId;
                        _activeTabId = null;
                        SetPromptTextBoxLogicalBuffer(_queuePreEditDraft ?? string.Empty, 0, 0);
                        _queuePreEditDraft = null;
                        _promptQueue.Remove(idToRemove);
                        SyncQueuePanel();
                    }
                    PromptTextBox.Clear();
                    _pec.DisableQuickReplies(quickReplyEntry);
                    ClearActiveQuickReplyState(quickReplyEntry);
                    ResetQueuePausedState();
                    // ApplyFollowUpHeader consumes any pending attachment for the active draft
                    // ("" tabId) and injects it as a header. Skipping it here would orphan the
                    // attachment — the strip would not clear and the image would not be sent.
                    _ = _pec.ExecutePromptAsync(ApplyFollowUpHeader(text, ""), addToHistory: true, clearPromptBox: false);
                }
                else if (!string.IsNullOrWhiteSpace(text))
                {
                    // No quick reply entry to attach to — fall back to queue.
                    EnqueueCurrentPrompt();
                    _ = DrainQueueIfNeededAsync();
                }
            });
        }
        else if (send && wasTargetingPrompt)
        {
            SquadDashTrace.Write("UI", "Prompt sent: PTT auto-send (voice released Ctrl)");
            await Task.Delay(220).ConfigureAwait(false);
            Dispatcher.Invoke(() =>
            {
                if (_activeTabId is not null)
                {
                    // PTT completed while the user was editing a queued tab — dispatch
                    // that tab directly rather than enqueuing a duplicate new item.
                    _ = DispatchQueuedTabAsync(_activeTabId);
                }
                else if (!string.IsNullOrWhiteSpace(PromptTextBox.Text))
                {
                    // TODO: verify follow-up attachments are captured in PTT send path
                    EnqueueCurrentPrompt();
                    _ = DrainQueueIfNeededAsync();
                }
            });
        }
        else
        {
            SquadDashTrace.Write("UI", "PTT stopped: no send (Escape/focus-lost/other-key)");
        }
    }

    private void AppendSpeechToPrompt(string text)
    {
        _promptHasVoiceInput = true;

        if (_pttTargetRichTextBox != null)
        {
            AppendSpeechToRichTextBox(_pttTargetRichTextBox, text);
            return;
        }

        var target = _pttTargetTextBox ?? PromptTextBox;
        var current = target.Text;
        // Clamp in case text was externally modified since session start.
        var caretIndex = Math.Min(_sessionCaretIndex, current.Length);
        // If there was a selection when PTT started, replace it on the first insert.
        var selLength = _sessionSelectionLength;
        _sessionSelectionLength = 0; // consume once; subsequent dictation appends
        var selEndIndex = Math.Min(caretIndex + selLength, current.Length);
        var leftContext = current[..caretIndex];
        var rightContext = current[selEndIndex..];
        var precedingChar = caretIndex > 0 ? current[caretIndex - 1] : '\0';
        // Suppress the auto-inserted leading space when the caret sits immediately after
        // an opening double-quote that is itself preceded by a space — e.g. `like "`.
        // Inserting a space before the dictation would produce `like " word` (stray space
        // inside the quote). Straight and Unicode left-double-quote are both matched.
        var isQuoteAfterSpace = (precedingChar == '"' || precedingChar == '\u201C')
                                && caretIndex >= 2 && current[caretIndex - 2] == ' ';
        var prefix = precedingChar != '\0' && precedingChar != ' ' && precedingChar != '(' &&
                     precedingChar != '\n' && precedingChar != '\r' && !isQuoteAfterSpace ? " " : string.Empty;
        var processed = VoiceInsertionHeuristics.Apply(leftContext, text, rightContext);
        var insert = prefix + processed;
        var rules = _settingsSnapshot.VoiceReplacementRules;
        var replaced = rules.Count > 0
            ? prefix + VoiceInsertionHeuristics.ApplyReplacementRules(processed, rules)
            : insert;

        // Step 1: insert conditioned text
        target.Text = leftContext + insert + rightContext;
        target.CaretIndex = caretIndex + insert.Length;
        _sessionCaretIndex = caretIndex + insert.Length;

        // Step 2: apply replacements as a separate undo entry
        if (!string.Equals(replaced, insert, StringComparison.Ordinal))
        {
            target.Text = leftContext + replaced + rightContext;
            target.CaretIndex = caretIndex + replaced.Length;
            _sessionCaretIndex = caretIndex + replaced.Length;
        }
    }

    private void AppendSpeechToRichTextBox(RichTextBox rtb, string text)
    {
        var current    = rtb.GetPlainText();
        var caretIndex = Math.Min(_sessionCaretIndex, current.Length);
        var selLength  = _sessionSelectionLength;
        _sessionSelectionLength = 0; // consume once; subsequent dictation appends
        var selEndIndex   = Math.Min(caretIndex + selLength, current.Length);
        var leftContext   = current[..caretIndex];
        var rightContext  = current[selEndIndex..];
        var precedingChar = caretIndex > 0 ? current[caretIndex - 1] : '\0';
        // Suppress the auto-inserted leading space when the caret sits immediately after
        // an opening double-quote that is itself preceded by a space — e.g. `like "`.
        var isQuoteAfterSpace = (precedingChar == '"' || precedingChar == '\u201C')
                                && caretIndex >= 2 && current[caretIndex - 2] == ' ';
        var prefix = precedingChar != '\0' && precedingChar != ' ' && precedingChar != '('
                     && precedingChar != '\n' && precedingChar != '\r' && !isQuoteAfterSpace
                     ? " " : string.Empty;
        var processed = VoiceInsertionHeuristics.Apply(leftContext, text, rightContext);
        var insert    = prefix + processed;
        var rules = _settingsSnapshot.VoiceReplacementRules;
        var replaced = rules.Count > 0
            ? prefix + VoiceInsertionHeuristics.ApplyReplacementRules(processed, rules)
            : insert;

        // Step 1: insert conditioned text
        // Use SelectRange + ReplaceSelection — undo-safe, preserves scroll position and formatting.
        rtb.SelectRange(caretIndex, selEndIndex - caretIndex);
        rtb.ReplaceSelection(insert);
        rtb.SelectRange(caretIndex + insert.Length, 0);
        _sessionCaretIndex = caretIndex + insert.Length;

        // Step 2: apply replacements as a separate undo entry
        if (!string.Equals(replaced, insert, StringComparison.Ordinal))
        {
            rtb.SelectRange(caretIndex, insert.Length);
            rtb.ReplaceSelection(replaced);
            rtb.SelectRange(caretIndex + replaced.Length, 0);
            _sessionCaretIndex = caretIndex + replaced.Length;
        }
    }

    private void UpdateVoiceHintVisibility()
    {
        var hasSpeech = IsSpeechConfigured();
        VoiceHintText.Visibility = hasSpeech ? Visibility.Collapsed : Visibility.Visible;
        BuildShortcutsHint();
    }

    private bool IsSpeechConfigured()
    {
        if (_settingsSnapshot.SpeechProvider == SpeechProvider.OpenAI)
            return !string.IsNullOrWhiteSpace(_settingsSnapshot.OpenAiSpeechApiKey);
        var hasKey = !string.IsNullOrWhiteSpace(
            Environment.GetEnvironmentVariable("SQUAD_SPEECH_KEY", EnvironmentVariableTarget.User));
        return hasKey && !string.IsNullOrWhiteSpace(_settingsSnapshot.SpeechRegion);
    }

    private static Run Bold(string text) => new Run(text) { FontWeight = FontWeights.Bold };
    private static Run Normal(string text) => new Run(text);
    private static Run Gap() => new Run("  ");

    /// <summary>
    /// Rebuilds the shortcuts hint line below the prompt box. Each sentence is only included
    /// when its feature has not been used in the last <see cref="HintCooldown"/>. The
    /// Ctrl+Enter hint is additionally gated on there being queued items that precede the
    /// active prompt in the dispatch order. In QRB blocking state the hint area is taken
    /// over entirely to guide the user through responding to AI input.
    /// </summary>
    private void BuildShortcutsHint()
    {
        var inlines = PromptShortcutsHintTextBlock.Inlines;
        inlines.Clear();

        // QRB blocking state: replace all normal hints with the QRB guidance message.
        if (_queuePausedNotificationFired)
        {
            inlines.Add(Normal("AI needs your input. Press "));
            inlines.Add(Bold("Enter"));
            inlines.Add(Normal(" or click "));
            inlines.Add(Bold("Send"));
            inlines.Add(Normal(" to send the active prompt immediately (or click one of the Quick Reply Buttons above). To create a new response draft, press "));
            inlines.Add(Bold("Ctrl"));
            inlines.Add(Normal("+"));
            inlines.Add(Bold("Q"));
            inlines.Add(Normal("."));
            return;
        }

        var includePtt = IsSpeechConfigured();

        bool any = false;
        void AddGap() { if (any) inlines.Add(Gap()); }

        if (IsHintVisible(PromptHintFeature.EnterSend))
        {
            AddGap();
            inlines.Add(Bold("Enter")); inlines.Add(Normal(" sends."));
            any = true;
        }

        if (IsHintVisible(PromptHintFeature.ShiftEnterNewline))
        {
            AddGap();
            inlines.Add(Bold("Shift")); inlines.Add(Normal("+"));
            inlines.Add(Bold("Enter")); inlines.Add(Normal(" adds a new line."));
            any = true;
        }

        if (IsHintVisible(PromptHintFeature.CtrlArrowHistory))
        {
            AddGap();
            inlines.Add(Bold("Ctrl")); inlines.Add(Normal("+"));
            inlines.Add(Bold("Up")); inlines.Add(Normal("/"));
            inlines.Add(Bold("Down")); inlines.Add(Normal(" reviews prompt history."));
            any = true;
        }

        if (includePtt && IsHintVisible(PromptHintFeature.PushToTalk))
        {
            AddGap();
            inlines.Add(Bold("Double-tap")); inlines.Add(Normal(" "));
            inlines.Add(Bold("Ctrl")); inlines.Add(Normal(" (and "));
            inlines.Add(Bold("hold")); inlines.Add(Normal(") for push to talk."));
            any = true;
        }

        if (IsHintVisible(PromptHintFeature.CtrlEnterPrioritize) && IsCtrlEnterPrioritizeApplicable())
        {
            AddGap();
            inlines.Add(Bold("Ctrl")); inlines.Add(Normal("+"));
            inlines.Add(Bold("Enter")); inlines.Add(Normal(" moves this to the front of the queue."));
        }

        if (IsHintVisible(PromptHintFeature.CtrlQAddQueue))
        {
            AddGap();
            inlines.Add(Bold("Ctrl")); inlines.Add(Normal("+"));
            inlines.Add(Bold("Q")); inlines.Add(Normal(" to add a new queued prompt."));
            any = true;
        }

        if (IsHintVisible(PromptHintFeature.CtrlTabNavigation) && _promptQueue.Items.Count > 0)
        {
            AddGap();
            inlines.Add(Bold("Ctrl+Tab")); inlines.Add(Normal(" / "));
            inlines.Add(Bold("Ctrl+Shift+Tab")); inlines.Add(Normal(" to move forward/backwards through the prompt queue."));
        }
    }

    private bool IsHintVisible(PromptHintFeature feature) =>
        !_promptHintPermanentlyDismissed.Contains(feature) &&
        (!_promptHintLastUsed.TryGetValue(feature, out var last) ||
        DateTime.UtcNow - last >= HintCooldown);

    private void DismissHintPermanently(PromptHintFeature feature)
    {
        _promptHintPermanentlyDismissed.Add(feature);
        BuildShortcutsHint();
    }

    /// <summary>
    /// Returns true when Ctrl+Enter would have a meaningful effect — i.e., there are queue
    /// items that would be dispatched before the current prompt (either on the Active Draft
    /// tab with a non-empty queue, or on a queued tab that isn't already first).
    /// </summary>
    private bool IsCtrlEnterPrioritizeApplicable()
    {
        if (_activeTabId is null)
            return _promptQueue.Count > 0;
        var items = _promptQueue.Items;
        for (int i = 0; i < items.Count; i++)
            if (items[i].Id == _activeTabId) return i > 0;
        return false;
    }

    private void RecordHintFeatureUsed(PromptHintFeature feature)
    {
        _promptHintLastUsed[feature] = DateTime.UtcNow;
        BuildShortcutsHint();
    }

    private void VoiceHintLink_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            PreferencesMenuItem_Click(sender, e);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(VoiceHintLink_Click), ex);
        }
    }

    private void PromptTextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        var sw = Stopwatch.StartNew();
        var phaseStart = Stopwatch.GetTimestamp();
        void MarkPhase(PromptTextChangedPhase phase)
        {
            var elapsedMs = ElapsedMillisecondsSince(phaseStart);
            RecordPromptTextChangedPhaseTiming(phase, elapsedMs);
            phaseStart = Stopwatch.GetTimestamp();
        }

        try
        {
            if (_conversationManager.IsApplyingHistoryEntry)
                return;

            var text = PromptTextBox.Text;
            MarkPhase(PromptTextChangedPhase.ReadText);
            if (string.IsNullOrEmpty(text))
            {
                _promptHasVoiceInput = false;
                // In fullscreen, hide the peeked prompt when text is cleared
                if (_transcriptFullScreenEnabled && _fullScreenPromptVisible)
                    HideFullScreenPrompt();
            }
            MarkPhase(PromptTextChangedPhase.EmptyUi);

            _conversationManager.HistoryIndex = null;
            _conversationManager.HistoryDraft = text;
            MarkPhase(PromptTextChangedPhase.HistoryDraft);
            QueuePromptDraftStateRefresh();
            MarkPhase(PromptTextChangedPhase.QueueDraftRefresh);
            if (_isPromptRunning)
                UpdateInteractiveControlState(promptTextOnly: true);
            MarkPhase(PromptTextChangedPhase.InteractiveState);
            TryUpdateIntelliSense(text, PromptTextBox.CaretIndex);
            MarkPhase(PromptTextChangedPhase.IntelliSense);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptTextBox_TextChanged), ex);
        }
        finally
        {
            sw.Stop();
            if (sw.ElapsedMilliseconds >= PromptTextChangedSevereTraceMs)
            {
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"PROMPT_TEXT_CHANGED elapsed={sw.ElapsedMilliseconds}ms textLen={PromptTextBox.Text.Length} " +
                    $"caret={PromptTextBox.CaretIndex} primaryHosts={_primaryAgentTranscriptHosts.Count} " +
                    $"phaseMaxMs={FormatPromptTextChangedPhaseMaxes()}");
            }
            RecordPromptInputTiming(isTextChanged: true, sw.ElapsedMilliseconds);
        }
    }

    private void QueuePromptDraftStateRefresh()
    {
        if (_promptDraftStateRefreshPending)
            return;

        _promptDraftStateRefreshPending = true;
        _ = Dispatcher.BeginInvoke(DispatcherPriority.Background, () =>
        {
            try
            {
                _promptDraftStateRefreshPending = false;
                if (!_isClosing)
                    _conversationManager.UpdatePromptDraftState();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException(nameof(QueuePromptDraftStateRefresh), ex);
            }
        });
    }

    private void AttachDispatcherDiagnostics()
    {
        if (_dispatcherDiagnosticsAttached)
            return;

        _dispatcherDiagnosticsAttached = true;
        Dispatcher.Hooks.OperationStarted += DispatcherHooks_OperationStarted;
        Dispatcher.Hooks.OperationCompleted += DispatcherHooks_OperationCompleted;
        Dispatcher.Hooks.OperationAborted += DispatcherHooks_OperationAborted;
    }

    private void DetachDispatcherDiagnostics()
    {
        if (!_dispatcherDiagnosticsAttached)
            return;

        _dispatcherDiagnosticsAttached = false;
        Dispatcher.Hooks.OperationStarted -= DispatcherHooks_OperationStarted;
        Dispatcher.Hooks.OperationCompleted -= DispatcherHooks_OperationCompleted;
        Dispatcher.Hooks.OperationAborted -= DispatcherHooks_OperationAborted;
        CompositionTarget.Rendering -= PromptCompositionTarget_Rendering;
        _promptRenderLatencyPending = false;
        _dispatcherOperationStarts.Clear();
    }

    private void DispatcherHooks_OperationStarted(object? sender, DispatcherHookEventArgs e)
    {
        try
        {
            if (_dispatcherOperationStarts.Count > 1024)
                _dispatcherOperationStarts.Clear();

            _dispatcherOperationStarts[e.Operation] = (Stopwatch.GetTimestamp(), e.Operation.Priority);
        }
        catch
        {
            // Diagnostics must never perturb UI dispatch.
        }
    }

    private void DispatcherHooks_OperationCompleted(object? sender, DispatcherHookEventArgs e) =>
        CompleteDispatcherOperationTrace(e.Operation, "completed");

    private void DispatcherHooks_OperationAborted(object? sender, DispatcherHookEventArgs e) =>
        CompleteDispatcherOperationTrace(e.Operation, "aborted");

    private void CompleteDispatcherOperationTrace(DispatcherOperation operation, string status)
    {
        try
        {
            if (!_dispatcherOperationStarts.Remove(operation, out var started))
                return;

            var elapsedMs = ElapsedMillisecondsSince(started.StartedAt);
            if (elapsedMs < DispatcherLongOperationTraceMs)
                return;

            _lastLongDispatcherOperationSummary = $"{status}:{started.Priority}:{elapsedMs}ms";
            SquadDashTrace.Write(
                TraceCategory.Performance,
                $"DISPATCHER_OP_LONG status={status} priority={started.Priority} elapsedMs={elapsedMs} " +
                $"promptFocused={PromptTextBox?.IsKeyboardFocusWithin == true} textLen={PromptTextBox?.Text.Length ?? 0} " +
                $"postedPending={_postedUiActionTracker.PendingCount} uiNativeThreadId={NativeMethods.GetCurrentNativeThreadId()} " +
                BuildTranscriptRenderState());
        }
        catch
        {
            // Diagnostics must never perturb UI dispatch.
        }
    }

    private void MarkPromptInputAwaitingRender()
    {
        _promptRenderLatencyStartedAt = Stopwatch.GetTimestamp();
        _promptRenderLatencyTextLen = PromptTextBox?.Text.Length ?? 0;

        if (_promptRenderLatencyPending)
            return;

        _promptRenderLatencyPending = true;
        CompositionTarget.Rendering += PromptCompositionTarget_Rendering;
    }

    private void PromptCompositionTarget_Rendering(object? sender, EventArgs e)
    {
        try
        {
            CompositionTarget.Rendering -= PromptCompositionTarget_Rendering;
            _promptRenderLatencyPending = false;

            var elapsedMs = ElapsedMillisecondsSince(_promptRenderLatencyStartedAt);
            if (elapsedMs < PromptRenderLatencyTraceMs)
                return;

            SquadDashTrace.Write(
                TraceCategory.Performance,
                $"PROMPT_RENDER_LATENCY elapsedMs={elapsedMs} textLenAtInput={_promptRenderLatencyTextLen} " +
                $"textLenNow={PromptTextBox?.Text.Length ?? 0} caret={PromptTextBox?.CaretIndex ?? 0} " +
                $"selectionLen={PromptTextBox?.SelectionLength ?? 0} uiNativeThreadId={NativeMethods.GetCurrentNativeThreadId()} " +
                $"lastLongDispatcherOp={_lastLongDispatcherOperationSummary} " +
                BuildTranscriptRenderState());
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptCompositionTarget_Rendering), ex);
        }
    }

    private long GetPromptRenderPendingMs() =>
        _promptRenderLatencyPending ? ElapsedMillisecondsSince(_promptRenderLatencyStartedAt) : 0;

    private void RecordPromptInputTiming(bool isTextChanged, long elapsedMs)
    {
        MarkPromptInputAwaitingRender();

        if (isTextChanged)
        {
            _promptTextChangedTraceCount++;
            _promptTextChangedTraceElapsedMs += elapsedMs;
            _promptTextChangedTraceMaxMs = Math.Max(_promptTextChangedTraceMaxMs, elapsedMs);
        }
        else
        {
            _promptKeyDownTraceCount++;
            _promptKeyDownTraceElapsedMs += elapsedMs;
            _promptKeyDownTraceMaxMs = Math.Max(_promptKeyDownTraceMaxMs, elapsedMs);
        }

        if (_promptInputTraceStopwatch.ElapsedMilliseconds < PromptInputTraceIntervalMs)
            return;

        var textChangedCount = _promptTextChangedTraceCount;
        var keyDownCount = _promptKeyDownTraceCount;
        if (textChangedCount == 0 && keyDownCount == 0)
        {
            _promptInputTraceStopwatch.Restart();
            return;
        }

        SquadDashTrace.Write(
            TraceCategory.UI,
            $"PROMPT_INPUT batchMs={_promptInputTraceStopwatch.ElapsedMilliseconds} " +
            $"textChangedCount={textChangedCount} textChangedAvgMs={FormatAverageMs(_promptTextChangedTraceElapsedMs, textChangedCount)} textChangedMaxMs={_promptTextChangedTraceMaxMs} " +
            $"textChangedPhaseAvgMs={FormatPromptTextChangedPhaseAverages(textChangedCount)} textChangedPhaseMaxMs={FormatPromptTextChangedPhaseMaxes()} " +
            $"keyDownCount={keyDownCount} keyDownAvgMs={FormatAverageMs(_promptKeyDownTraceElapsedMs, keyDownCount)} keyDownMaxMs={_promptKeyDownTraceMaxMs} " +
            $"textLen={PromptTextBox.Text.Length} caret={PromptTextBox.CaretIndex} selectionLen={PromptTextBox.SelectionLength} " +
            $"logicalModifiers={Keyboard.Modifiers} physicalModifiers={NativeMethods.GetPhysicalModifierKeys()}");

        _promptInputTraceStopwatch.Restart();
        _promptTextChangedTraceCount = 0;
        _promptTextChangedTraceElapsedMs = 0;
        _promptTextChangedTraceMaxMs = 0;
        Array.Clear(_promptTextChangedPhaseElapsedMs);
        Array.Clear(_promptTextChangedPhaseMaxMs);
        _promptKeyDownTraceCount = 0;
        _promptKeyDownTraceElapsedMs = 0;
        _promptKeyDownTraceMaxMs = 0;
    }

    private static string FormatAverageMs(long elapsedMs, int count) =>
        count <= 0 ? "n/a" : ((double)elapsedMs / count).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);

    private void RecordPromptTextChangedPhaseTiming(PromptTextChangedPhase phase, long elapsedMs)
    {
        var index = (int)phase;
        _promptTextChangedPhaseElapsedMs[index] += elapsedMs;
        _promptTextChangedPhaseMaxMs[index] = Math.Max(_promptTextChangedPhaseMaxMs[index], elapsedMs);
    }

    private string FormatPromptTextChangedPhaseAverages(int textChangedCount)
    {
        if (textChangedCount <= 0)
            return "n/a";

        return string.Join(
            ',',
            Enumerable.Range(0, (int)PromptTextChangedPhase.Count)
                .Select(i => $"{PromptTextChangedPhaseNames[i]}={FormatAverageMs(_promptTextChangedPhaseElapsedMs[i], textChangedCount)}"));
    }

    private string FormatPromptTextChangedPhaseMaxes() =>
        string.Join(
            ',',
            Enumerable.Range(0, (int)PromptTextChangedPhase.Count)
                .Select(i => $"{PromptTextChangedPhaseNames[i]}={_promptTextChangedPhaseMaxMs[i]}"));

    private void BuildAgentSuggestions()
    {
        if (_currentWorkspace is null)
        {
            _agentDisplayNames = [];
            _agentHandleByDisplayName.Clear();
            return;
        }
        var members = _teamRosterLoader.Load(_currentWorkspace.FolderPath);
        var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var member in members)
        {
            if (member.IsUtilityAgent) continue;
            if (string.Equals(member.Status, "Retired", StringComparison.OrdinalIgnoreCase)) continue;
            var handle = member.FolderPath is not null
                ? Path.GetFileName(member.FolderPath)
                : member.Name.ToLowerInvariant().Replace(" ", "-");
            if (!string.IsNullOrEmpty(handle))
                dict[member.Name] = handle;
        }
        _agentHandleByDisplayName = dict;
        _agentDisplayNames = [.. dict.Keys.OrderBy(k => k)];
    }

    /// <summary>
    /// Rebuilds <see cref="_tasksAgentSuggestions"/> from the owners that appear in
    /// <paramref name="result"/>. Adds "me" first if any task is user-owned, then adds
    /// the display names (alphabetically) of agents that own at least one task and whose
    /// display name is in <see cref="_agentHandleByDisplayName"/>.
    /// </summary>
    private void BuildTasksAgentSuggestions(TaskParseResult result)
    {
        var seen   = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var names  = new List<string>();
        bool hasMe = false;

        foreach (var group in result.OpenGroups)
        {
            foreach (var item in group.Items)
            {
                if (item.IsUserOwned)
                    hasMe = true;

                if (item.Owner is not null &&
                    _agentHandleByDisplayName.ContainsKey(item.Owner) &&
                    seen.Add(item.Owner))
                {
                    names.Add(item.Owner);
                }
            }
        }

        names.Sort(StringComparer.OrdinalIgnoreCase);
        if (hasMe) names.Insert(0, "me");
        _tasksAgentSuggestions = [.. names];
    }

    /// <summary>
    /// Searches backward from <paramref name="caret"/> for an '@' that is preceded by
    /// start-of-text or whitespace (i.e. not embedded in a word like an email address).
    /// Returns the index of '@', or -1 if not found.
    /// </summary>
    private const int MaxAtTriggerScanChars = 96;

    private static int FindAtTriggerPosition(string text, int caret)
    {
        if (caret <= 0) return -1;
        var scanStart = Math.Max(0, caret - MaxAtTriggerScanChars);
        int i = caret - 1;
        while (i >= scanStart && !char.IsWhiteSpace(text[i]))
        {
            if (text[i] == '@')
                return (i == 0 || char.IsWhiteSpace(text[i - 1])) ? i : -1;
            i--;
        }
        return -1;
    }

    private void TryUpdateTasksIntelliSense()
    {
        if (_isApplyingIntelliSenseAccept) return;

        var text  = TasksFilterBox.Text;
        var caret = TasksFilterBox.CaretIndex;

        if (_intelliSenseState is not null && _intelliSenseOwnerBox == TasksFilterBox)
        {
            _intelliSenseState = IntelliSenseController.UpdateFromText(_intelliSenseState, text, caret);
            UpdateIntelliSensePopup();
            return;
        }

        // If IntelliSense is owned by another box, leave it alone.
        if (_intelliSenseState is not null) return;

        var atPos = FindAtTriggerPosition(text, caret);
        if (atPos >= 0 && _tasksAgentSuggestions.Length > 0)
        {
            var activated = IntelliSenseController.TryActivate('@', atPos, _tasksAgentSuggestions);
            if (activated is not null)
            {
                _intelliSenseOwnerBox = TasksFilterBox;
                _intelliSenseState = IntelliSenseController.UpdateFromText(activated, text, caret);
                UpdateIntelliSensePopup();
            }
        }
    }

    private void TryUpdateIntelliSense() =>
        TryUpdateIntelliSense(PromptTextBox.Text, PromptTextBox.CaretIndex);

    private void TryUpdateIntelliSense(string text, int caret)
    {
        if (_conversationManager.IsApplyingHistoryEntry || _isApplyingIntelliSenseAccept)
            return;

        if (_intelliSenseState is not null)
        {
            // If IntelliSense belongs to another box, typing in PromptTextBox clears it.
            if (_intelliSenseOwnerBox is not null)
            {
                _intelliSenseState = null;
                _intelliSenseOwnerBox = null;
                UpdateIntelliSensePopup();
                // Fall through to check for a new trigger.
            }
            else
            {
                _intelliSenseState = IntelliSenseController.UpdateFromText(_intelliSenseState, text, caret);
                UpdateIntelliSensePopup();
                return;
            }
        }

        // Check for [ trigger — only when [ is the first char (prompt is otherwise empty)
        if (caret == 1 && text[0] == '[')
        {
            var options = GetCurrentQuickReplyOptions();
            if (options.Length > 0)
            {
                _intelliSenseState = IntelliSenseController.TryActivate('[', caret - 1, options);
                UpdateIntelliSensePopup();
                return;
            }
        }

        // / trigger for slash commands — only when / is the first char, no spaces, and the
        // text itself contains no newline.
        //
        // Bug 3 fix: check !text.Contains('\n') (full text) rather than !text[..caret].Contains('\n').
        // When Enter inserts a newline WPF fires TextChanged before updating CaretIndex, so the
        // stale caret (pointing before the '\n') caused text[..caret] to pass the check even
        // though the text already contained a newline.  Checking the full text is caret-lag-proof.
        //
        // Bug 2 fix: pass text.Length (not caret) to UpdateFromText on re-activation.
        // WPF CaretIndex can lag by one position when TextChanged fires immediately after a
        // Backspace key event, yielding caret=1 for text "/t".  UpdateFromText("/t", 1) builds
        // filter="/" and shows ALL commands instead of the filtered subset.  Using text.Length
        // always places the filter at the end of the typed text, which is where the user's
        // logical cursor is during re-activation.
        if (caret > 0 && text[0] == '/' && !text.Contains(' ') && !text.Contains('\n'))
        {
            var activated = IntelliSenseController.TryActivate('/', 0, SlashCommands, filterIncludesTrigger: true);
            _intelliSenseState = activated is not null
                ? IntelliSenseController.UpdateFromText(activated, text, text.Length)
                : null;
            UpdateIntelliSensePopup();
            return;
        }

        // @ trigger for agent names — works anywhere in the text (not just at position 0).
        var atPos = FindAtTriggerPosition(text, caret);
        if (atPos >= 0 && _agentDisplayNames.Length > 0)
        {
            var activated = IntelliSenseController.TryActivate('@', atPos, _agentDisplayNames);
            if (activated is not null)
            {
                _intelliSenseOwnerBox = null; // PromptTextBox owns this
                _intelliSenseState = IntelliSenseController.UpdateFromText(activated, text, caret);
                UpdateIntelliSensePopup();
            }
        }
    }

    private string[] GetCurrentQuickReplyOptions()
    {
        var latestResponse = CoordinatorThread?.LatestResponse;
        if (string.IsNullOrEmpty(latestResponse))
            return _currentQuickReplyOptions;
        return TryExtractQuickReplyOptions(latestResponse, out _, out var options)
            ? options
            : _currentQuickReplyOptions;
    }

    private void UpdateIntelliSensePopup()
    {
        if (_intelliSenseState is null || _intelliSenseState.FilteredSuggestions.Count == 0)
        {
            IntelliSensePopup.IsOpen = false;
            return;
        }

        IntelliSenseList.Items.Clear();
        foreach (var suggestion in _intelliSenseState.FilteredSuggestions)
            IntelliSenseList.Items.Add(suggestion);

        IntelliSenseList.SelectedIndex = _intelliSenseState.SelectedIndex;
        if (IntelliSenseList.SelectedItem is not null)
            IntelliSenseList.ScrollIntoView(IntelliSenseList.SelectedItem);

        var ownerBox = _intelliSenseOwnerBox ?? PromptTextBox;
        IntelliSensePopup.PlacementTarget = ownerBox;
        IntelliSensePopup.CustomPopupPlacementCallback = (popupSize, targetSize, offset) =>
        {
            var caretRect = ownerBox.GetRectFromCharacterIndex(ownerBox.CaretIndex);
            return new[] {
                new CustomPopupPlacement(
                    new Point(caretRect.Left, caretRect.Bottom),
                    PopupPrimaryAxis.Vertical)
            };
        };
        IntelliSensePopup.IsOpen = true;
    }

    private void ApplyIntelliSenseAccept(bool andSubmit)
    {
        if (_intelliSenseState is null) return;

        var targetBox = _intelliSenseOwnerBox ?? PromptTextBox;
        var (newText, newCaret) = IntelliSenseController.Accept(
            _intelliSenseState, targetBox.Text, targetBox.CaretIndex);

        // For @ trigger: replace accepted display name with @handle + trailing space.
        // Never submit on @ accept — the user is mentioning an agent mid-prompt.
        if (_intelliSenseState.TriggerChar == '@')
        {
            andSubmit = false;
            var displayName = _intelliSenseState.FilteredSuggestions[_intelliSenseState.SelectedIndex];
            // "me" is a special suggestion that maps to itself as the filter handle.
            var handle = string.Equals(displayName, "me", StringComparison.OrdinalIgnoreCase)
                ? "me"
                : (_agentHandleByDisplayName.TryGetValue(displayName, out var h) ? h : null);
            if (handle is not null)
            {
                var before = targetBox.Text[.._intelliSenseState.TriggerPosition];
                var after  = targetBox.CaretIndex < targetBox.Text.Length
                    ? targetBox.Text[targetBox.CaretIndex..]
                    : string.Empty;
                newText  = before + "@" + handle + " " + after;
                newCaret = _intelliSenseState.TriggerPosition + 1 + handle.Length + 1;
            }
        }

        // If Tab-completing a slash command that requires a parameter, insert a trailing
        // space and keep focus in the prompt so the user can type the argument.
        if (andSubmit && _intelliSenseState.TriggerChar == '/' && SlashCommandParameterPolicy.RequiresParameter(newText.Trim()))
        {
            newText = newText.TrimEnd() + " ";
            newCaret = newText.Length;
            andSubmit = false;
        }

        var ownerBox = _intelliSenseOwnerBox;
        _isApplyingIntelliSenseAccept = true;
        try
        {
            _intelliSenseState = null;
            _intelliSenseOwnerBox = null;
            targetBox.Text = newText;
            targetBox.CaretIndex = newCaret;
        }
        finally
        {
            _isApplyingIntelliSenseAccept = false;
        }
        UpdateIntelliSensePopup();
        if (andSubmit && ownerBox is null)
            RunButton_Click(this, new RoutedEventArgs());
    }

    private void AgentCardBorder_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard agentCard })
                return;
            if (e.OriginalSource is DependencyObject source && FindVisualAncestor<Button>(source) is not null)
                return;

            bool shiftHeld = (Keyboard.Modifiers & ModifierKeys.Shift) != 0;
            if (shiftHeld)
            {
                var visualSw = Stopwatch.StartNew();
                SyncSelectionControllerWithUiState("AgentCardBorder_MouseLeftButtonUp.Shift");
                // If one of this agent's threads is the current main transcript selection and there
                // are no secondary panels, shift-click should dismiss it and revert to the
                // coordinator rather than opening a redundant secondary panel.
                bool isCurrentMain = _mainTranscriptVisible &&
                                     agentCard.Threads.Any(t => ReferenceEquals(t, _selectedTranscriptThread)) &&
                                     _secondaryTranscripts.Count == 0;
                if (isCurrentMain)
                {
                    SelectTranscriptThread(CoordinatorThread);
                    SyncTranscriptTargetIndicators();
                }
                else
                {
                    _selectionController.HandleCardClick(agentCard, shiftHeld: true);
                    SyncImmediatePanelToggleVisuals(agentCard, "card-shift-click", visualSw);
                }
            }
            else
            {
                ShowSingleTranscript(agentCard);
            }
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentCardBorder_MouseLeftButtonUp), ex);
        }
    }

    private void AgentCardBorder_MouseEnter(object sender, MouseEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard agentCard })
                return;

            // Parse accent color
            var accentColor = (System.Windows.Media.Color)ColorConverter.ConvertFromString(agentCard.AccentColorHex);

            // If this is the lead/coordinator agent, apply glow to main transcript border
            if (agentCard.IsLeadAgent)
            {
                if (_mainTranscriptVisible && MainTranscriptBorder is not null)
                {
                    var glow = new System.Windows.Media.Effects.DropShadowEffect
                    {
                        Color = accentColor,
                        BlurRadius = 20,
                        ShadowDepth = 0,
                        Opacity = 0.4
                    };
                    MainTranscriptBorder.Effect = glow;

                    var opacityAnim = new System.Windows.Media.Animation.DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(2000));
                    glow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, opacityAnim);
                }
                return;
            }

            // Find open secondary transcript panel for this agent
            var entry = _secondaryTranscripts.FirstOrDefault(t => ReferenceEquals(t.Agent, agentCard));
            if (entry is null)
                return;

            // Apply pulsing glow effect to secondary panel
            var secondaryGlow = new System.Windows.Media.Effects.DropShadowEffect
            {
                Color = accentColor,
                BlurRadius = 20,
                ShadowDepth = 0,
                Opacity = 0.4
            };
            entry.PanelBorder.Effect = secondaryGlow;

            var secondaryOpacityAnim = new System.Windows.Media.Animation.DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(2000));
            secondaryGlow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, secondaryOpacityAnim);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentCardBorder_MouseEnter), ex);
        }
    }

    private void AgentCardBorder_MouseLeave(object sender, MouseEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard agentCard })
                return;

            // If this is the lead/coordinator agent, remove glow from main transcript border
            if (agentCard.IsLeadAgent)
            {
                if (MainTranscriptBorder is not null && MainTranscriptBorder.Effect is System.Windows.Media.Effects.DropShadowEffect mainGlow)
                {
                    mainGlow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, null);
                    MainTranscriptBorder.Effect = null;
                }
                return;
            }

            // Find open secondary transcript panel for this agent
            var entry = _secondaryTranscripts.FirstOrDefault(t => ReferenceEquals(t.Agent, agentCard));
            if (entry is null)
                return;

            // Remove glow effect from secondary panel
            if (entry.PanelBorder.Effect is System.Windows.Media.Effects.DropShadowEffect glow)
            {
                glow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, null);
                entry.PanelBorder.Effect = null;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentCardBorder_MouseLeave), ex);
        }
    }

    // ── Context-menu helpers ───────────────────────────────────────────────────

    private static ContextMenu MakeMenu()
    {
        var m = new ContextMenu();
        m.SetResourceReference(ContextMenu.StyleProperty, "ThemedContextMenuStyle");
        return m;
    }

    private static MenuItem MakeItem(string header)
    {
        var i = new MenuItem { Header = header };
        i.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
        return i;
    }

    private static Separator MakeSep()
    {
        var s = new Separator();
        s.SetResourceReference(Separator.StyleProperty, "ThemedMenuSeparatorStyle");
        return s;
    }

    private static T? FindVisualAncestor<T>(DependencyObject? source)
        where T : DependencyObject
    {
        while (source is not null)
        {
            if (source is T target)
                return target;

            source = source switch
            {
                Visual or System.Windows.Media.Media3D.Visual3D => System.Windows.Media.VisualTreeHelper.GetParent(source),
                FrameworkContentElement contentElement => contentElement.Parent,
                _ => LogicalTreeHelper.GetParent(source)
            };
        }

        return null;
    }

    private void AgentThreadChipButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: TranscriptThreadState thread })
                return;

            var card = FindAgentCardForThread(thread);
            if (card is null) return;

            bool shiftHeld = (Keyboard.Modifiers & ModifierKeys.Shift) != 0;
            if (shiftHeld)
            {
                var visualSw = Stopwatch.StartNew();
                SyncSelectionControllerWithUiState("AgentThreadChipButton_Click.Shift");
                // If this thread is the current main transcript selection and there are no secondary
                // panels, shift-click should dismiss it and revert to the coordinator rather than
                // opening a redundant secondary panel.
                bool isCurrentMain = _mainTranscriptVisible &&
                                     ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, thread);
                if (isCurrentMain && _secondaryTranscripts.Count == 0)
                {
                    SelectTranscriptThread(CoordinatorThread);
                    SyncTranscriptTargetIndicators();
                }
                else
                {
                    _selectionController.HandleChipClick(card, thread, shiftHeld: true);
                    SyncImmediatePanelToggleVisuals(card, "chip-shift-click", visualSw);
                }
            }
            else
            {
                ShowThreadInMainTranscript(card, thread);
            }
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentThreadChipButton_Click), ex);
        }
    }

    private void OverflowChipBorder_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard card })
                return;

            // Overflow threads are those assigned a sequence number beyond the visible limit (3)
            // but still tracked in card.Threads. ChipVisibility is Collapsed for these.
            var overflowThreads = card.Threads
                .Where(t => t.SequenceNumber > 3)
                .OrderBy(t => t.SequenceNumber)
                .ToList();

            if (overflowThreads.Count == 0)
                return;

            var menu = MakeMenu();
            foreach (var thread in overflowThreads)
            {
                var label = $"#{thread.SequenceNumber}";
                var time = FormatRelativeTime(thread.StartedAt);
                var item = MakeItem($"{label}  —  {time}");
                item.ToolTip = BuildThreadChipToolTip(thread);
                item.Tag = thread;
                item.Click += OverflowMenuThreadItem_Click;
                menu.Items.Add(item);
            }

            var fe = (FrameworkElement)sender;
            fe.ContextMenu = menu;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OverflowChipBorder_MouseLeftButtonUp), ex);
        }
    }

    private void OverflowMenuThreadItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: TranscriptThreadState thread })
                return;

            var card = FindAgentCardForThread(thread);
            if (card is not null)
                ShowThreadInMainTranscript(card, thread);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OverflowMenuThreadItem_Click), ex);
        }
    }

    private void AgentNameButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard agent })
                return;
            if (!agent.NameIsClickable)
                return;

            var targetPath = agent.IsLeadAgent && _currentWorkspace is not null
                ? Path.Combine(_currentWorkspace.SquadFolderPath, "team.md")
                : agent.CharterPath;

            OpenMarkdownFile(targetPath, agent.IsLeadAgent ? "Squad Team" : $"{agent.Name} Charter");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentNameButton_Click), ex);
        }
    }

    private void AgentDocumentsButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { DataContext: AgentStatusCard agent })
                return;

            var documents = new List<MarkdownDocumentSpec>(2);
            if (!string.IsNullOrWhiteSpace(agent.CharterPath) && File.Exists(agent.CharterPath))
                documents.Add(new MarkdownDocumentSpec("charter", agent.CharterPath));
            if (!string.IsNullOrWhiteSpace(agent.HistoryPath) && File.Exists(agent.HistoryPath))
                documents.Add(new MarkdownDocumentSpec("history", agent.HistoryPath));

            if (documents.Count == 0)
                return;

            OpenMarkdownFiles(documents, $"{agent.Name} Charter & History");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentDocumentsButton_Click), ex);
        }
    }

    private void AgentAccentBorder_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
    {
        try
        {
            if (sender is not Border { DataContext: AgentStatusCard agentCard } border)
                return;

            var menu = CreateAgentAccentContextMenu(agentCard);
            menu.PlacementTarget = border;
            border.ContextMenu = menu;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentAccentBorder_PreviewMouseRightButtonUp), ex);
        }
    }

    private ContextMenu CreateAgentAccentContextMenu(AgentStatusCard agentCard)
    {
        var menu = MakeMenu();
        var primaryThread = GetPrimaryThread(agentCard);
        var now = DateTimeOffset.Now;

        var agentInfoItem = MakeItem("Agent Info");
        agentInfoItem.Tag = agentCard;
        agentInfoItem.Click += AgentInfoMenuItem_Click;
        menu.Items.Add(agentInfoItem);

        var openCharterItem = MakeItem("Open Charter");
        openCharterItem.Tag = agentCard;
        openCharterItem.Click += AgentOpenCharterMenuItem_Click;
        var hasCharter = (!string.IsNullOrWhiteSpace(agentCard.CharterPath) && File.Exists(agentCard.CharterPath))
                      || (!string.IsNullOrWhiteSpace(agentCard.HistoryPath) && File.Exists(agentCard.HistoryPath));
        if (hasCharter)
            menu.Items.Add(openCharterItem);

        if (primaryThread is not null && _backgroundTaskPresenter.IsThreadStalledForDisplay(primaryThread, now))
        {
            var abortTarget = _backgroundTaskPresenter.TryResolveAbortTarget(primaryThread, allowSingleFallback: false);
            if (abortTarget is not null)
            {
                var abortItem = MakeItem("Stop All Running Work...");
                abortItem.Tag = abortTarget;
                abortItem.Click += AgentAbortCurrentRunMenuItem_Click;
                menu.Items.Add(abortItem);
            }

            var copyDiagnosticsItem = MakeItem("Copy Stall Diagnostics");
            copyDiagnosticsItem.Tag = BuildAgentStallDiagnostics(agentCard, primaryThread, now);
            copyDiagnosticsItem.Click += AgentCopyStallDiagnosticsMenuItem_Click;
            menu.Items.Add(copyDiagnosticsItem);

            menu.Items.Add(MakeSep());
        }
        else
        {
            menu.Items.Add(MakeSep());
        }

        // Accent Color submenu
        var accentSubmenu = MakeItem("Accent Color");

        for(var index = 0; index < AgentAccentPalette.Length; index++)
        {
            if (index == 8)
                accentSubmenu.Items.Add(MakeSep());

            var paletteOption = AgentAccentPalette[index];
            var swatchBrush = ColorUtilities.AccentBrush(paletteOption.Hex);
            var swatch = new Border
            {
                Width = 56,
                Height = 18,
                Background = swatchBrush,
                BorderBrush = string.Equals(
                        agentCard.AccentColorHex,
                        paletteOption.Hex,
                        StringComparison.OrdinalIgnoreCase)
                    ? Brushes.White
                    : Brushes.Transparent,
                BorderThickness = new Thickness(2),
                CornerRadius = new CornerRadius(5)
            };

            var menuItem = MakeItem(string.Empty);
            menuItem.Header = swatch;
            menuItem.Tag = new AgentAccentSelection(agentCard, paletteOption.Hex);
            menuItem.StaysOpenOnClick = false;
            menuItem.ToolTip = paletteOption.Hex;
            menuItem.Click += AgentAccentColorMenuItem_Click;
            accentSubmenu.Items.Add(menuItem);
        }
        menu.Items.Add(accentSubmenu);

        menu.Items.Add(MakeSep());

        // Choose Image...
        var chooseImageItem = MakeItem("Choose Image...");
        chooseImageItem.Tag = agentCard;
        chooseImageItem.Click += AgentChooseImageMenuItem_Click;
        menu.Items.Add(chooseImageItem);

        // Remove Custom Image (only shown if user has set a custom image)
        if (_currentWorkspace is not null &&
            _settingsSnapshot.AgentImagePathsByWorkspace.TryGetValue(_currentWorkspace.FolderPath, out var imgs) &&
            imgs.ContainsKey(agentCard.AccentStorageKey))
        {
            var removeImageItem = MakeItem("Remove Custom Image");
            removeImageItem.Tag = agentCard;
            removeImageItem.Click += AgentRemoveImageMenuItem_Click;
            menu.Items.Add(removeImageItem);
        }

        return menu;
    }

    private async void AgentAbortCurrentRunMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (sender is not MenuItem { Tag: BackgroundAbortTarget abortTarget })
            return;

        try
        {
            SquadDashTrace.Write(
                "UI",
                $"Agent context menu stop-all requested from taskKind={abortTarget.TaskKind} taskId={abortTarget.TaskId} idSource={abortTarget.TaskIdSource} agentId={abortTarget.AgentId ?? "(none)"} toolCallId={abortTarget.ToolCallId ?? "(none)"} label={abortTarget.DisplayLabel}");
            var targets = BuildAbortConfirmationTargets();
            if (targets.Count == 0)
                return;

            var dialog = new AbortAgentsConfirmationWindow(targets, BuildAbortConfirmationTargets)
            {
                Owner = this,
                WindowStartupLocation = WindowStartupLocation.CenterOwner
            };

            if (dialog.ShowDialog() == true && dialog.ConfirmedTargets.Count > 0)
                await StopConfirmedRunningWorkAsync(dialog.ConfirmedTargets).ConfigureAwait(true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException("AgentStopAllRunningWork", ex);
        }
    }

    private void AgentCopyStallDiagnosticsMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: string diagnostics } || string.IsNullOrWhiteSpace(diagnostics))
                return;

            Clipboard.SetText(diagnostics);
            SquadDashTrace.Write("UI", "Copied stalled-agent diagnostics to clipboard.");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentCopyStallDiagnosticsMenuItem_Click), ex);
        }
    }

    private string BuildAgentStallDiagnostics(AgentStatusCard agentCard, TranscriptThreadState thread, DateTimeOffset now)
    {
        var lastActivityAt = AgentThreadRegistry.GetThreadLastActivityAt(thread);
        var quietFor = now - lastActivityAt;
        return string.Join(Environment.NewLine, [
            $"Agent: {agentCard.Name}",
            $"ThreadId: {thread.ThreadId}",
            $"ToolCallId: {thread.ToolCallId ?? "(none)"}",
            $"Status: {thread.StatusText}",
            $"Started: {thread.StartedAt:O}",
            $"LastActivity: {lastActivityAt:O}",
            $"QuietFor: {StatusTimingPresentation.FormatDuration(quietFor)}",
            $"CurrentRun: {thread.IsCurrentBackgroundRun}",
            $"SessionId: {_conversationManager.CurrentSessionId ?? "(none)"}",
            $"PromptRunning: {_isPromptRunning}",
            $"PromptState: {_currentSessionState ?? "(unknown)"}"
        ]);
    }

    private void AgentInfoMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: AgentStatusCard agentCard })
                return;
            AgentInfoWindow.Show(this, agentCard, _currentWorkspace?.FolderPath, _workspacePaths.AgentImageAssetsDirectory);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentInfoMenuItem_Click), ex);
        }
    }

    private void AgentOpenCharterMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: AgentStatusCard agent })
                return;

            var documents = new List<MarkdownDocumentSpec>(2);
            if (!string.IsNullOrWhiteSpace(agent.CharterPath) && File.Exists(agent.CharterPath))
                documents.Add(new MarkdownDocumentSpec("charter", agent.CharterPath));
            if (!string.IsNullOrWhiteSpace(agent.HistoryPath) && File.Exists(agent.HistoryPath))
                documents.Add(new MarkdownDocumentSpec("history", agent.HistoryPath));

            if (documents.Count == 0)
                return;

            OpenMarkdownFiles(documents, $"{agent.Name} Charter & History");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentOpenCharterMenuItem_Click), ex);
        }
    }

    private void AgentAccentColorMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: AgentAccentSelection selection })
                return;

            ApplyAgentAccent(selection.AgentCard, selection.AccentHex, persist: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentAccentColorMenuItem_Click), ex);
        }
    }

    private void AgentChooseImageMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: AgentStatusCard agentCard })
                return;

            var agentsDir = _workspacePaths.AgentImageAssetsDirectory;
            var initialDir = _lastAgentImageFolder
                               ?? (Directory.Exists(agentsDir) ? agentsDir : null);
            var dialog = new Microsoft.Win32.OpenFileDialog
            {
                Title = $"Choose image for {agentCard.Name}",
                Filter = "Image files (*.png;*.jpg;*.jpeg;*.jfif;*.bmp;*.gif)|*.png;*.jpg;*.jpeg;*.jfif;*.bmp;*.gif|All files (*.*)|*.*",
                Multiselect = false,
                InitialDirectory = initialDir
            };

            if (dialog.ShowDialog(this) != true)
                return;

            _lastAgentImageFolder = Path.GetDirectoryName(dialog.FileName);
            ApplyAgentImage(agentCard, dialog.FileName, persist: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentChooseImageMenuItem_Click), ex);
        }
    }

    private void AgentRemoveImageMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: AgentStatusCard agentCard })
                return;

            var fallbackPath = AgentImagePathResolver.ResolveBundledPath(agentCard, _workspacePaths.AgentImageAssetsDirectory);
            ApplyAgentImage(agentCard, fallbackPath, persist: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AgentRemoveImageMenuItem_Click), ex);
        }
    }

    private void OpenSquadFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentInstallationState is null || !Directory.Exists(_currentInstallationState.SquadFolderPath))
                return;

            _squadCliAdapter.OpenFolderInExplorer(_currentInstallationState.SquadFolderPath, "Open .squad Folder");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenSquadFolderMenuItem_Click), ex);
        }
    }

    private void SquadCliMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            string workingDir = _currentWorkspace?.FolderPath ?? _workspacePaths.ApplicationRoot;
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
            {
                FileName = "powershell.exe",
                Arguments = @"-NoExit -Command ""npx squad""",
                WorkingDirectory = workingDir,
                UseShellExecute = true,
            });
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(SquadCliMenuItem_Click), ex);
        }
    }

    private void PowerShellMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            string workingDir = _currentWorkspace?.FolderPath ?? _workspacePaths.ApplicationRoot;
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
            {
                FileName = "powershell.exe",
                Arguments = "-NoExit",
                WorkingDirectory = workingDir,
                UseShellExecute = true,
            });
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PowerShellMenuItem_Click), ex);
        }
    }

    private async void RemoteAccessMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null)
                return;

            if (_remoteAccessActive)
            {
                await _bridge.StopRemoteAsync().ConfigureAwait(false);
            }
            else
            {
                var repo = System.IO.Path.GetFileName(_currentWorkspace.FolderPath);
                var machine = System.Environment.MachineName;
                await _bridge.StartRemoteAsync(
                    repo: repo,
                    branch: "main",
                    machine: machine,
                    squadDir: _currentWorkspace.SquadFolderPath,
                    cwd: _currentWorkspace.FolderPath,
                    sessionId: _conversationManager.CurrentSessionId,
                    tunnelMode: _settingsSnapshot.TunnelMode,
                    tunnelToken: _settingsSnapshot.TunnelToken,
                    rcToken: _settingsSnapshot.RcPersistentToken).ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RemoteAccessMenuItem_Click), ex);
        }
    }

    private async void InstallSquadButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null)
                return;

            _installSquadButtonPressed = true;

            if (IsDeveloperSimulationActive())
            {
                SetInstallStatus("Developer simulation is active. Install Squad is disabled while previewing issue states.");
                ShowTextWindow(
                    "Developer Simulation",
                    "Install Squad is disabled while a developer issue simulation is active.\n\nClear the simulation in Preferences to run the real install flow.");
                return;
            }

            SetInstallUiState(isInstalling: true, "Checking prerequisites...");
            var progress = new Progress<string>(text => SetInstallStatus(text));

            var result = await _installerService
                .InstallAsync(_currentWorkspace.FolderPath, progress);

            RefreshInstallationState();

            if (result.Success && _currentInstallationState?.IsSquadInstalledForActiveDirectory == true)
            {
                SetInstallUiState(isInstalling: false, "Squad installed successfully. Starting the first Squad turn...");
                RefreshAgentCards();
                RefreshSidebar();
                MaybePromptForUniverseSelection();
                MaybePublishMissingUtilityAgentNotice();
                return;
            }

            var failureMessage = result.Success
                ? "Squad setup completed, but the local Squad command is still unavailable."
                : result.Message;
            var activeDirectory = _currentWorkspace.FolderPath;

            SetInstallUiState(isInstalling: false, failureMessage);

            ShowTextWindow(
                "Squad Install Diagnostics",
                BuildInstallDiagnostics(result, activeDirectory));
        }
        catch (Exception ex)
        {
            SetInstallUiState(isInstalling: false, "Squad install failed.");
            HandleUiCallbackException("Install Squad", ex);
        }
    }

    private async void RunDoctorButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null)
                return;

            if (IsDeveloperSimulationActive())
            {
                SetInstallStatus("Developer simulation is active. Cleanup > Run Squad Doctor is disabled while previewing issue states.");
                ShowTextWindow(
                    "Developer Simulation",
                    "Cleanup > Run Squad Doctor is disabled while a developer issue simulation is active.\n\nClear the simulation in Preferences to run the real doctor flow.");
                return;
            }

            SetInstallUiState(isInstalling: true, "Running Squad doctor...");
            var progress = new Progress<string>(text => SetInstallStatus(text));

            var result = await _installerService
                .RunDoctorAsync(_currentWorkspace.FolderPath, progress);

            SetInstallUiState(isInstalling: false, result.Message);

            ShowTextWindow(
                "Squad Doctor",
                BuildInstallDiagnostics(result, _currentWorkspace.FolderPath));
        }
        catch (Exception ex)
        {
            SetInstallUiState(isInstalling: false, "Squad doctor failed.");
            HandleUiCallbackException("Run Doctor", ex);
        }
    }

    private void RunDoctorMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            RunDoctorButton_Click(sender, e);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RunDoctorMenuItem_Click), ex);
        }
    }

    private UiRevealOverlay? _uiRevealOverlay;

    private void UiRevealMenuItem_Click(object sender, RoutedEventArgs e) =>
        ToggleUiReveal(this);

    /// <summary>
    /// Activates or deactivates UI Reveal on the given window.
    /// Called from both the menu item and the global Ctrl+Alt+Shift+F12 shortcut.
    /// </summary>
    private void ToggleUiReveal(Window targetWindow)
    {
        _uiRevealOverlay ??= new UiRevealOverlay();
        if (_uiRevealOverlay.IsActive)
            _uiRevealOverlay.Deactivate();
        else
            _uiRevealOverlay.Activate(targetWindow);
    }

    /// <summary>
    /// Global input handler registered on <see cref="InputManager.PreProcessInput"/>.
    /// Fires for every WPF window on this thread before dispatch, so Ctrl+Alt+Shift+F12
    /// works even when a floating window (hint overlay, doc window, etc.) has keyboard focus.
    /// </summary>
    private void OnGlobalPreProcessInput(object sender, PreProcessInputEventArgs e)
    {
        try
        {
            if (e.StagingItem.Input is not KeyEventArgs keyArgs) return;
            if (keyArgs.RoutedEvent != Keyboard.PreviewKeyDownEvent) return;
            if (keyArgs.Key != Key.F12) return;
            if ((Keyboard.Modifiers & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Shift))
                != (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Shift))
                return;

            // Find the WPF window that currently has focus; fall back to MainWindow.
            var target = Application.Current.Windows
                             .OfType<Window>()
                             .FirstOrDefault(w => w.IsActive)
                         ?? this;

            ToggleUiReveal(target);
            keyArgs.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OnGlobalPreProcessInput), ex);
        }
    }

    // ── Developer > Simulation menu ───────────────────────────────────────────

    private void SimulateBridgeDisposed_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            // AppendLine only renders quick-reply buttons when there is an active
            // CurrentTurn (it uses AppendResponseSegment → RenderResponseEntry path).
            // Create a synthetic turn so the injected text goes through that path.
            BeginTranscriptTurn("[debug interrupt]");
            AppendLine(
                $"[interrupted] The Squad bridge was disposed before the prompt completed.\n\n" +
                $"[{PromptExecutionController.ResendLastPromptQuickReply}] [{PromptExecutionController.CheckGitDiffQuickReply}]",
                ThemeBrush("SystemInfoText"));
            SquadDashTrace.Write("Persistence", "Coordinator CurrentTurn cleared reason=debug-interrupt");
            CoordinatorThread.CurrentTurn = null;
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(SimulateBridgeDisposed_Click), ex); }
    }

    private void SimulationMenuItem_SubmenuOpened(object sender, RoutedEventArgs e)
    {
        try { SyncSimulationMenuCheckmarks(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(SimulationMenuItem_SubmenuOpened), ex); }
    }

    private void SimulateStartupIssue_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: string tag }) return;
            var value = Enum.TryParse<DeveloperStartupIssueSimulation>(tag, out var parsed)
                ? parsed : DeveloperStartupIssueSimulation.None;
            _settingsSnapshot = _settingsStore.SaveDeveloperIssueSimulation(
                _currentWorkspace!.FolderPath,
                value,
                _settingsSnapshot.GetRuntimeIssueSimulation(_currentWorkspace.FolderPath));
            SyncSimulationMenuCheckmarks();
            RefreshInstallationState();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(SimulateStartupIssue_Click), ex); }
    }

    private void SimulateRuntimeIssue_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: string tag }) return;
            var value = Enum.TryParse<DeveloperRuntimeIssueSimulation>(tag, out var parsed)
                ? parsed : DeveloperRuntimeIssueSimulation.None;
            _settingsSnapshot = _settingsStore.SaveDeveloperIssueSimulation(
                _currentWorkspace!.FolderPath,
                _settingsSnapshot.GetStartupIssueSimulation(_currentWorkspace.FolderPath),
                value);
            SyncSimulationMenuCheckmarks();
            RefreshDeveloperRuntimeIssuePreview();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(SimulateRuntimeIssue_Click), ex); }
    }

    private void SyncSimulationMenuCheckmarks()
    {
        var folder = _currentWorkspace?.FolderPath ?? "";
        var startup = _settingsSnapshot.GetStartupIssueSimulation(folder);
        var runtime = _settingsSnapshot.GetRuntimeIssueSimulation(folder);

        void SyncStartup(MenuItem item, DeveloperStartupIssueSimulation val) =>
            item.IsChecked = startup == val;
        void SyncRuntime(MenuItem item, DeveloperRuntimeIssueSimulation val) =>
            item.IsChecked = runtime == val;

        SyncStartup(SimStartupNone,               DeveloperStartupIssueSimulation.None);
        SyncStartup(SimStartupMissingNode,         DeveloperStartupIssueSimulation.MissingNodeTooling);
        SyncStartup(SimStartupSquadNotInstalled,   DeveloperStartupIssueSimulation.SquadNotInstalled);
        SyncStartup(SimStartupPartialSquadInstall, DeveloperStartupIssueSimulation.PartialSquadInstall);

        SyncRuntime(SimRuntimeNone,           DeveloperRuntimeIssueSimulation.None);
        SyncRuntime(SimRuntimeCopilotAuth,    DeveloperRuntimeIssueSimulation.CopilotAuthRequired);
        SyncRuntime(SimRuntimeBundledSdk,     DeveloperRuntimeIssueSimulation.BundledSdkRepair);
        SyncRuntime(SimRuntimeBuildTempFiles, DeveloperRuntimeIssueSimulation.BuildTempFiles);
        SyncRuntime(SimRuntimeGenericFailure, DeveloperRuntimeIssueSimulation.GenericRuntimeFailure);
    }


    private void ToolIconGalleryMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ToolIconPreviewWindow.Show(this);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ToolIconGalleryMenuItem_Click), ex);
        }
    }

    private async void OpenFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var dialog = new OpenFolderDialog
            {
                Multiselect = false
            };

            if (_currentWorkspace is not null)
            {
                dialog.InitialDirectory = _currentWorkspace.FolderPath;
            }

            if (dialog.ShowDialog(this) == true)
            {
                await OpenWorkspace(dialog.FolderName, rememberFolder: true);
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenFolderMenuItem_Click), ex);
        }
    }

    private void PreferencesMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_preferencesWindow is { IsVisible: true })
            {
                _preferencesWindow.Activate();
                return;
            }

            var showDevOptions = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
            _preferencesWindow = PreferencesWindow.Open(
                CanShowOwnedWindow() ? this : null,
                _settingsStore,
                _settingsSnapshot,
                _pushNotificationService,
                showDevOptions,
                snapshot =>
                {
                    _settingsSnapshot = snapshot;
                    _pushNotificationService.ReloadProvider();
                    UpdateVoiceHintVisibility();
                    RefreshInstallationState();
                    RefreshDeveloperRuntimeIssuePreview();
                    var previousByokSettings = _bridge.ByokProviderSettings;
                    var currentByokSettings = BuildByokSettings(snapshot);
                    _bridge.ByokProviderSettings = currentByokSettings;
                    if (!Equals(previousByokSettings, currentByokSettings))
                        RestartBridgeForSettingsWhenIdle("preferences-byok-changed");
                    else
                        SquadDashTrace.Write("Bridge", "Preferences saved; BYOK bridge settings unchanged, no bridge restart needed.");
                },
                startPtt: (tb) => {
                    _pttTargetTextBox = tb;
                    _sessionCaretIndex = tb.SelectionStart;
                    _sessionSelectionLength = tb.SelectionLength;
                    _voiceStartedWithSendEnabled = false;
                    _pttState = PttState.Active;
                    _ = StartPushToTalkAsync();
                },
                stopPtt: () => _ = StopPushToTalkAsync(send: false));
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PreferencesMenuItem_Click), ex);
        }
    }

    private void ViewMenuItem_SubmenuOpened(object sender, RoutedEventArgs e)
    {
        var shiftDown = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
        if (ToolIconGalleryMenuItem is not null)
            ToolIconGalleryMenuItem.Visibility = shiftDown ? Visibility.Visible : Visibility.Collapsed;
        if (ToolIconGallerySeparator is not null)
            ToolIconGallerySeparator.Visibility = shiftDown ? Visibility.Visible : Visibility.Collapsed;
        if (ViewLoopPanelMenuItem is not null)
            ViewLoopPanelMenuItem.IsChecked = _loopPanelVisible;
        if (ViewTasksMenuItem is not null)
            ViewTasksMenuItem.IsChecked = _tasksPanelVisible;
        if (ViewCommitApprovalsMenuItem is not null)
            ViewCommitApprovalsMenuItem.IsChecked = _approvalPanelVisible;
    }

    private async void RecentFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not MenuItem { Tag: string folderPath })
                return;

            await OpenWorkspace(folderPath, rememberFolder: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RecentFolderMenuItem_Click), ex);
        }
    }

    private void NormalViewMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SetTranscriptFullScreen(false);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(NormalViewMenuItem_Click), ex);
        }
    }

    private void FullScreenTranscriptMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SetTranscriptFullScreen(true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(FullScreenTranscriptMenuItem_Click), ex);
        }
    }

    private void RemoveTemporaryAgentsMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            RemoveTemporaryAgents();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RemoveTemporaryAgentsMenuItem_Click), ex);
        }
    }

    private void ClearPastedImagesMenuItem_Click(object sender, RoutedEventArgs e)
    {
        var folder = _currentWorkspace?.FolderPath;
        if (folder is null) return;

        var freed = _pastedImageStore.DeleteAll(folder);
        var mb    = freed / (1024.0 * 1024.0);
        MessageBox.Show(
            freed == 0
                ? "No pasted images to clear."
                : $"Cleared {mb:F1} MB of pasted images.",
            "Clear Pasted Images",
            MessageBoxButton.OK,
            MessageBoxImage.Information);
    }

    private void SetTranscriptFullScreen(bool enabled)
    {
        if (_transcriptFullScreenEnabled == enabled)
            return;

        // Entering full-screen: silently cancel focus mode first so that
        // _preFullScreenBounds saves the original pre-focus-mode geometry.
        if (enabled && _agentsPanelFocusModeEnabled)
        {
            _agentsPanelFocusModeEnabled = false;
            if (_preFocusModeWindowState == WindowState.Normal && _preFocusModeHeight > 100)
            {
                Top    = _preFocusModeTop;
                Height = _preFocusModeHeight;
            }
        }

        _transcriptFullScreenEnabled = enabled;
        _fullScreenPromptVisible = false; // reset peek state on any fullscreen transition

        if (enabled)
        {
            _preFullScreenWindowState = WindowState;
            // When already maximized, Left/Top/Width/Height are unreliable — use RestoreBounds.
            _preFullScreenBounds = WindowState == WindowState.Maximized
                ? RestoreBounds
                : new Rect(Left, Top, Width, Height);
            if (WindowState != WindowState.Maximized)
                WindowState = WindowState.Maximized;
        }
        else
        {
            if (_preFullScreenWindowState != WindowState.Maximized)
            {
                WindowState = _preFullScreenWindowState;
                // Restore exact bounds if returning to Normal, but only if they look valid.
                if (_preFullScreenWindowState == WindowState.Normal
                    && _preFullScreenBounds.Width > 100
                    && _preFullScreenBounds.Height > 100)
                {
                    Left = _preFullScreenBounds.X;
                    Top = _preFullScreenBounds.Y;
                    Width = _preFullScreenBounds.Width;
                    Height = _preFullScreenBounds.Height;

                    // If the restored position is off-screen, snap to the primary work area.
                    if (!IsPlacementOnScreen(new WorkspaceWindowPlacement(Left, Top, Width, Height, false)))
                    {
                        Left = SystemParameters.WorkArea.Left;
                        Top = SystemParameters.WorkArea.Top;
                    }
                }
                else if (_preFullScreenWindowState == WindowState.Normal)
                {
                    // Degenerate bounds — fall back to maximized so the window is visible.
                    WindowState = WindowState.Maximized;
                }
            }
            // If the window was already maximized before entering fullscreen, leave it maximized.
        }

        // Capture before ApplyViewMode changes the layout (and possibly the scroll extent).
        bool wasAtBottom = !ActiveScrollController.IsUserScrolledAway;

        ApplyViewMode();

        // When exiting fullscreen while at the bottom, re-anchor after layout settles so
        // the newly-visible status panel / prompt area doesn't leave a gap at the bottom.
        if (!enabled && wasAtBottom)
            Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
                (Action)(() => ActiveScrollController.ScrollToBottom()));

        // Persist fullscreen state per workspace.
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { FullScreenTranscript = enabled };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void FocusModeMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ToggleAgentsPanelFocusMode();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(FocusModeMenuItem_Click), ex);
        }
    }

    private void ToggleAgentsPanelFocusMode()
    {
        // Per spec: if in full-screen, exit it first, then toggle focus mode.
        if (_transcriptFullScreenEnabled)
            SetTranscriptFullScreen(false);
        SetAgentsPanelFocusMode(!_agentsPanelFocusModeEnabled);
    }

    /// <summary>
    /// Hides the agent-cards / inline-panels strip (Shift+F11 toggle).
    /// The prompt stays visible. When the window is in the Normal state the window
    /// is resized to fill the work area height of the current monitor (width unchanged).
    /// Pressing Shift+F11 a second time restores the panels and the original geometry.
    /// </summary>
    private void SetAgentsPanelFocusMode(bool enabled)
    {
        if (_agentsPanelFocusModeEnabled == enabled)
            return;

        _agentsPanelFocusModeEnabled = enabled;

        if (enabled)
        {
            _preFocusModeWindowState = WindowState;
            if (WindowState == WindowState.Normal)
            {
                _preFocusModeHeight = Height;
                _preFocusModeTop    = Top;

                var hwnd   = new System.Windows.Interop.WindowInteropHelper(this).Handle;
                var physWa = NativeMethods.GetWorkAreaForWindow(hwnd);
                var dpi    = System.Windows.Media.VisualTreeHelper.GetDpi(this);

                // Skip height/top change when the window is in any kind of constrained
                // layout (Windows 11 Snap zone, top/bottom split, 4-way grid, etc.).
                // We use actual physical height from GetWindowRect rather than relying
                // on WS_MAXIMIZE, which is unreliable for WindowStyle=None + WindowChrome
                // windows and is cleared when the user drags the divider between snapped
                // windows even though the window remains partially sized.
                if (!NativeMethods.IsHeightConstrained(hwnd, physWa))
                {
                    Top    = physWa.Top    / dpi.DpiScaleY;
                    Height = physWa.Height / dpi.DpiScaleY;
                }
            }
        }
        else
        {
            if (_preFocusModeWindowState == WindowState.Normal && _preFocusModeHeight > 100)
            {
                Top    = _preFocusModeTop;
                Height = _preFocusModeHeight;
            }
        }

        bool wasAtBottom = !ActiveScrollController.IsUserScrolledAway;
        ApplyViewMode();

        // Re-anchor transcript after layout settles so revealing / hiding the
        // status panel doesn't leave a gap when the user was already at the bottom.
        if (wasAtBottom)
            Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
                (Action)(() => ActiveScrollController.ScrollToBottom()));
    }

    private void ShowFullScreenPrompt()
    {
        _fullScreenPromptVisible = true;
        if (PromptBorder is not null)
            PromptBorder.Visibility = Visibility.Visible;
        // Focus after layout so the caret appears inside the text box.
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () =>
        {
            PromptTextBox.Focus();
        });
    }

    private void HideFullScreenPrompt()
    {
        _fullScreenPromptVisible = false;
        if (PromptBorder is not null)
            PromptBorder.Visibility = Visibility.Collapsed;
        ClearPromptTextBoxLogicalBuffer("hide-fullscreen-prompt");
    }

    private void SetPromptPanelOnTop(bool onTop)
    {
        if (_promptPanelOnTop == onTop) return;
        _promptPanelOnTop = onTop;

        // Row 3 is the transcript (Height="*"), Row 4 is the prompt (Height="Auto").
        // Swap Grid.Row assignments and also swap the RowDefinition heights so that
        // whichever panel is in the star-height slot stretches to fill available space.
        if (TranscriptPanelsGrid is null || PromptBorder is null || MainGrid is null) return;

        var transcriptRow = MainGrid.RowDefinitions[3];
        var promptRow     = MainGrid.RowDefinitions[4];

        if (onTop)
        {
            Grid.SetRow(PromptBorder,        3);
            Grid.SetRow(TranscriptPanelsGrid, 4);
            transcriptRow.Height = new GridLength(1, GridUnitType.Auto);
            promptRow.Height     = new GridLength(1, GridUnitType.Star);
            PromptBorder.Margin        = new Thickness(0, 0, 0, 14);
            TranscriptPanelsGrid.Margin = new Thickness(0, 0, 0, 14);
        }
        else
        {
            Grid.SetRow(PromptBorder,        4);
            Grid.SetRow(TranscriptPanelsGrid, 3);
            transcriptRow.Height = new GridLength(1, GridUnitType.Star);
            promptRow.Height     = new GridLength(1, GridUnitType.Auto);
            PromptBorder.Margin        = new Thickness(0);
            TranscriptPanelsGrid.Margin = new Thickness(0, 14, 0, 14);
        }

        // Persist per workspace.
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { PromptPanelOnTop = onTop };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void ViewDocumentationMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SetDocumentationMode(!_documentationModeEnabled);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ViewDocumentationMenuItem_Click), ex);
        }
    }

    private void ViewTasksMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _tasksPanelVisible = !_tasksPanelVisible;
            SyncTasksPanel();
            if (ViewTasksMenuItem is not null)
                ViewTasksMenuItem.IsChecked = _tasksPanelVisible;
            PersistTasksPanelVisible();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ViewTasksMenuItem_Click), ex);
        }
    }

    private void LoopPanelCloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _loopPanelVisible = false;
            SyncLoopPanel();
            if (ViewLoopPanelMenuItem is not null)
                ViewLoopPanelMenuItem.IsChecked = false;
            PersistLoopPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelCloseButton_Click), ex); }
    }

    private void LoopPanelEditLoopMdMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null) return;
            var loopMdPath = GetEffectiveLoopMdPath();
            OpenOrCreateLoopMd(loopMdPath);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelEditLoopMdMenuItem_Click), ex); }
    }

    private void LoopPanelLoopSettingsMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null) return;
            var loopMdPath = GetEffectiveLoopMdPath();
            var config = LoopMdParser.Parse(loopMdPath);
            OpenLoopConfigFlyout(loopMdPath, LoopConfigFlyoutMode.Edit, existingConfig: config);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelLoopSettingsMenuItem_Click), ex); }
    }

    private void LoopPanelShowInFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_currentWorkspace is null) return;
            var loopMdPath = GetEffectiveLoopMdPath();
            if (File.Exists(loopMdPath))
                System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{loopMdPath}\"");
            else
            {
                var dir = Path.GetDirectoryName(loopMdPath);
                if (dir is not null && Directory.Exists(dir))
                    System.Diagnostics.Process.Start("explorer.exe", dir);
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelShowInFolderMenuItem_Click), ex); }
    }

    private void LoopPanelCopyFilePathMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var loopMdPath = GetEffectiveLoopMdPath();
            if (!string.IsNullOrEmpty(loopMdPath))
                Clipboard.SetText(loopMdPath);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelCopyFilePathMenuItem_Click), ex); }
    }

    private void LoopPanelShowMergedMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_loopMergedViewWindow is not null)
            {
                _loopMergedViewWindow.Close();
                // _loopMergedViewWindow set to null in Closed handler (see below)
                return;
            }
            _loopMergedViewWindow = new LoopMergedViewWindow();
            _loopMergedViewWindow.Closed += (_, _) => {
                _loopMergedViewWindow = null;
                LoopPanelShowMergedMenuItem.Header = "Preview merged loop";
            };
            _loopMergedViewWindow.Owner = CanShowOwnedWindow() ? this : null;
            LoopPanelShowMergedMenuItem.Header = "Close preview";
            RefreshLoopMergedView();
            WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(_loopMergedViewWindow, this);
            _loopMergedViewWindow.Show();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(LoopPanelShowMergedMenuItem_Click), ex); }
    }

    private void ScheduleLoopPreviewRefreshFromFilter()
    {
        if (_loopMergedViewWindow is null) return;
        if (_loopPreviewFilterDebounce is null)
        {
            _loopPreviewFilterDebounce = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
            _loopPreviewFilterDebounce.Tick += (_, _) =>
            {
                _loopPreviewFilterDebounce.Stop();
                RefreshLoopMergedView();
            };
        }
        _loopPreviewFilterDebounce.Stop();
        _loopPreviewFilterDebounce.Start();
    }

    private void RefreshLoopMergedView()
    {
        if (_loopMergedViewWindow is null) return;
        if (_selectedLoopMdPath is null) { _loopMergedViewWindow.UpdateContent(""); return; }

        try
        {
            var tasksFilter = TasksFilterBox?.Text?.Trim() ?? "";
            var config = LoopMdParser.Parse(_selectedLoopMdPath);

            var extraSubs = new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["iteration"]       = "1",
                ["copilot_trailer"] = "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
                // build_command intentionally omitted — AI resolves build tooling automatically
                ["[**FILTER**]"]    = LoopMdParser.BuildFilterInstruction(tasksFilter),
            };

            var isCliMode = _settingsSnapshot.LoopMode == LoopMode.SquadCli;
            string text;
            if (config is not null)
            {
                text = LoopMdParser.BuildMergedBody(config, extraSubs);
                // For Squad CLI mode, prepend a clean frontmatter block
                if (isCliMode)
                    text = LoopMdParser.BuildMergedFull(config, text);
            }
            else if (File.Exists(_selectedLoopMdPath))
            {
                text = LoopMdParser.StripFrontmatter(File.ReadAllText(_selectedLoopMdPath));
            }
            else
            {
                text = "";
            }

            _loopMergedViewWindow.UpdateContent(text);
        }
        catch
        {
            _loopMergedViewWindow?.UpdateContent("");
        }
    }

    private void TasksPanelCloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _tasksPanelVisible = false;
            SyncTasksPanel();
            if (ViewTasksMenuItem is not null)
                ViewTasksMenuItem.IsChecked = false;
            PersistTasksPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(TasksPanelCloseButton_Click), ex); }
    }

    private void EditTasksMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var workspace = _currentWorkspace;
            if (workspace is null) return;

            var tasksPath = Path.Combine(workspace.SquadFolderPath, "tasks.md");
            if (!File.Exists(tasksPath)) return;

            MarkdownDocumentWindow.Show(
                CanShowOwnedWindow() ? this : null,
                "Tasks",
                tasksPath,
                showSource: true,
                BuildMarkdownCaptureContext());
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(EditTasksMenuItem_Click), ex); }
    }

    private void ViewLoopPanelMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _loopPanelVisible = !_loopPanelVisible;
            SyncLoopPanel();
            if (ViewLoopPanelMenuItem is not null)
                ViewLoopPanelMenuItem.IsChecked = _loopPanelVisible;
            PersistLoopPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ViewLoopPanelMenuItem_Click), ex); }
    }

    private void ViewCommitApprovalsMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ShowApprovalPanel();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ViewCommitApprovalsMenuItem_Click), ex); }
    }

    private void ViewNotesMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _notesPanelVisible = !_notesPanelVisible;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = _notesPanelVisible;
            PersistNotesPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ViewNotesMenuItem_Click), ex); }
    }

    private void NotesPanelCloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _notesPanelVisible = false;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = false;
            PersistNotesPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(NotesPanelCloseButton_Click), ex); }
    }

    private void ViewMaintenanceMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _maintenancePanelVisible = !_maintenancePanelVisible;
            SyncMaintenancePanel();
            if (ViewMaintenanceMenuItem is not null)
                ViewMaintenanceMenuItem.IsChecked = _maintenancePanelVisible;
            if (_maintenancePanelVisible) DismissMaintenanceBadge();
            PersistMaintenancePanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ViewMaintenanceMenuItem_Click), ex); }
    }

    private void MaintenancePanelCloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _maintenancePanelVisible = false;
            SyncMaintenancePanel();
            if (ViewMaintenanceMenuItem is not null)
                ViewMaintenanceMenuItem.IsChecked = false;
            PersistMaintenancePanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(MaintenancePanelCloseButton_Click), ex); }
    }

    private void MaintenanceRunNowButton_Click(object sender, RoutedEventArgs e)
    {
        try { _ = StartMaintenanceCycleAsync(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(MaintenanceRunNowButton_Click), ex); }
    }

    private void MaintenanceBannerDismissButton_Click(object sender, RoutedEventArgs e)
    {
        try { DismissMaintenanceBanner(); }
        catch (Exception ex) { HandleUiCallbackException(nameof(MaintenanceBannerDismissButton_Click), ex); }
    }

    private void MaintenanceBannerViewButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            DismissMaintenanceBanner();
            if (_pendingMaintenanceBannerReport is not null && _maintenanceReportWriter is not null)
            {
                var paths = _maintenanceReportWriter.GetReportPaths();
                if (paths.Count > 0 && File.Exists(paths[0]))
                    MarkdownDocumentWindow.Show(
                        CanShowOwnedWindow() ? this : null,
                        "Maintenance Report",
                        paths[0],
                        showSource: false,
                        BuildMarkdownCaptureContext(),
                        autoSave: false);
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(MaintenanceBannerViewButton_Click), ex); }
    }

    private void SetDocumentationMode(bool enabled, bool persistChange = true)
    {
        if (_documentationModeEnabled == enabled)
            return;

        _documentationModeEnabled = enabled;
        _pec.DocumentationModeActive = enabled;
        _pec.DocsRootFolder = enabled
            ? DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath)
            : null;
        ApplyViewMode();

        if (enabled)
        {
            PopulateDocumentationTopics();

            if (persistChange)
            {
                var workspaceFolder = _currentWorkspace?.FolderPath;
                var existingState = _docsPanelState ?? _settingsStore.GetDocsPanelState(workspaceFolder);
                _docsPanelState = existingState is not null
                    ? existingState with { Open = true }
                    : new WorkspaceDocsPanelState { Open = true };
                _settingsSnapshot = _settingsStore.SaveDocsPanelState(workspaceFolder, _docsPanelState);
            }
        }
        else if (persistChange)
        {
            var nodes = DocTopicsTreeView is not null
                ? CollectExpandedDocNodes(DocTopicsTreeView.Items)
                : null;
            var topic = (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Tag as string;

            // Capture splitter widths (only if panel is currently visible/expanded)
            double? docsPanelWidth = null;
            double? docsPanelWidthFraction = null;
            if (DocsPanelColumn is not null && DocsPanelColumn.ActualWidth > 0)
            {
                docsPanelWidth = DocsPanelColumn.ActualWidth;
                if (MainGrid is not null && MainGrid.ActualWidth > 0)
                    docsPanelWidthFraction = DocsPanelColumn.ActualWidth / MainGrid.ActualWidth;
            }
            bool? docsSourceOpen = IsDocSourceVisible() ? true : (bool?)null;
            double? docsSourceWidth = IsDocSourceVisible() ? GetDocSourceSize() : (double?)null;

            var workspaceFolder = _currentWorkspace?.FolderPath;
            _docsPanelState = new WorkspaceDocsPanelState
            {
                Open = false,
                ExpandedNodes = nodes,
                SelectedTopic = topic,
                PanelWidth = docsPanelWidth,
                PanelWidthFraction = docsPanelWidthFraction,
                SourceOpen = docsSourceOpen,
                SourceWidth = docsSourceWidth,
                SourceLayoutTopBottom = _docSourceLayoutTopBottom ? true : null,
            };
            _settingsSnapshot = _settingsStore.SaveDocsPanelState(workspaceFolder, _docsPanelState);
        }
    }

    private void PopulateDocumentationTopics()
    {
        if (DocTopicsTreeView is null)
            return;

        var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);
        _docStatusStore = !string.IsNullOrEmpty(docsRoot) ? DocStatusStore.Load(docsRoot) : null;
        DocTopicsLoader.LoadTopics(DocTopicsTreeView, out var firstItemToSelect, _currentWorkspace?.FolderPath, _docStatusStore);

        // Wire up selection handler
        DocTopicsTreeView.SelectedItemChanged -= DocTopicsTreeView_SelectedItemChanged;
        DocTopicsTreeView.SelectedItemChanged += DocTopicsTreeView_SelectedItemChanged;

        // Wire up WebBrowser navigation handler for clickable links
        if (DocMarkdownViewer is not null)
        {
            DocMarkdownViewer.Navigating -= DocMarkdownViewer_Navigating;
            DocMarkdownViewer.Navigating += DocMarkdownViewer_Navigating;
            DocMarkdownViewer.LoadCompleted -= DocMarkdownViewer_LoadCompleted_InjectHover;
            DocMarkdownViewer.LoadCompleted += DocMarkdownViewer_LoadCompleted_InjectHover;
            DocMarkdownViewer.ObjectForScripting = new DocViewerScriptingBridge(this);
        }

        // ── Expansion ─────────────────────────────────────────────────────────────
        var savedExpandedNodes = (_docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath)).ExpandedNodes;
        if (savedExpandedNodes is not null)
            ApplyDocNodeExpansion(DocTopicsTreeView.Items,
                new HashSet<string>(savedExpandedNodes, StringComparer.OrdinalIgnoreCase));
        else
            ExpandAllDocNodes(DocTopicsTreeView.Items);  // default: all expanded

        // ── Selection ─────────────────────────────────────────────────────────────
        var savedTopic = (_docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath)).SelectedTopic;
        if (!string.IsNullOrEmpty(savedTopic))
        {
            var savedItem = FindDocNodeByTag(DocTopicsTreeView.Items, savedTopic);
            if (savedItem is not null)
            {
                savedItem.IsSelected = true;
                ConfigureDocsWatcher();
                return;
            }
        }

        // Fallback: select first item (default behaviour)
        if (firstItemToSelect is not null)
            firstItemToSelect.IsSelected = true;
        else if (DocTopicsTreeView.Items.Count > 0)
            RenderDocumentationWelcome();
        else
            DocMarkdownViewer?.Navigate("about:blank");

        // Configure FileSystemWatcher to auto-refresh on .md file changes
        ConfigureDocsWatcher();
    }

    // ── Doc-tree helpers ─────────────────────────────────────────────────────────

    /// <summary>Recursively expands every node in the docs tree.</summary>
    private static void ExpandAllDocNodes(ItemCollection items)
    {
        foreach (var item in items.OfType<TreeViewItem>())
        {
            item.IsExpanded = true;
            ExpandAllDocNodes(item.Items);
        }
    }

    /// <summary>
    /// Recursively sets <see cref="TreeViewItem.IsExpanded"/> based on whether the
    /// node's Tag (file path) or Header string is in <paramref name="keys"/>.
    /// </summary>
    private static void ApplyDocNodeExpansion(ItemCollection items, IReadOnlySet<string> keys)
    {
        foreach (var item in items.OfType<TreeViewItem>())
        {
            var tagKey = item.Tag as string;
            var headerKey = item.Header?.ToString();
            item.IsExpanded = (!string.IsNullOrEmpty(tagKey) && keys.Contains(tagKey))
                           || (!string.IsNullOrEmpty(headerKey) && keys.Contains(headerKey));
            ApplyDocNodeExpansion(item.Items, keys);
        }
    }

    /// <summary>
    /// Recursively finds the first <see cref="TreeViewItem"/> whose Tag matches
    /// <paramref name="tag"/> (case-insensitive file-path comparison).
    /// </summary>
    private static TreeViewItem? FindDocNodeByTag(ItemCollection items, string tag)
    {
        foreach (var item in items.OfType<TreeViewItem>())
        {
            if (string.Equals(item.Tag as string, tag, StringComparison.OrdinalIgnoreCase))
                return item;
            var found = FindDocNodeByTag(item.Items, tag);
            if (found is not null)
                return found;
        }
        return null;
    }

    /// <summary>
    /// Recursively collects the key (Tag path, or Header string) of every expanded
    /// node in the docs tree.
    /// </summary>
    private static List<string> CollectExpandedDocNodes(ItemCollection items)
    {
        var result = new List<string>();
        foreach (var item in items.OfType<TreeViewItem>())
        {
            if (item.IsExpanded)
            {
                var key = item.Tag as string ?? item.Header?.ToString();
                if (!string.IsNullOrEmpty(key))
                    result.Add(key);
            }
            result.AddRange(CollectExpandedDocNodes(item.Items));
        }
        return result;
    }

    private void DocTopicsTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
    {
        if (DocMarkdownViewer is null || e.NewValue is not TreeViewItem item)
            return;

        var filePath = item.Tag as string;
        if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
        {
            UpdateApproveDocButton(null);
            return;
        }

        // If the same topic is re-selected (e.g. by a watcher-triggered tree reload)
        // and the source panel has unsaved edits, don't overwrite the user's work.
        bool isSameTopic = string.Equals(filePath, _currentDocPath, StringComparison.OrdinalIgnoreCase);
        bool sourceVisible = DocSourcePanel?.Visibility == Visibility.Visible;

        // Flush any pending source edit to disk before switching topics (not when re-selecting same)
        if (sourceVisible && !isSameTopic)
        {
            _docSourceSaveTimer?.Stop();
            _docPreviewRefreshTimer?.Stop();
            SaveDocSourceToDisk();
        }

        try
        {
            _currentDocPath = filePath;  // store for link navigation
            // Keep BOTH in-memory stores current so DocsWatcher-triggered reloads (which read
            // _docsPanelState) restore the NEW topic rather than reverting to the old one.
            _settingsSnapshot = _settingsSnapshot with { DocsSelectedTopic = filePath };
            _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with { SelectedTopic = filePath };
            _pec.ActiveDocumentPath = filePath;
            UpdateApproveDocButton(filePath);

            if (isSameTopic && sourceVisible)
                return;  // preview and source are already showing this topic with live edits — leave them alone

            var rawMarkdown = File.ReadAllText(filePath);
            var markdown    = StripDocFrontMatter(rawMarkdown, out var frontMatter);
            _currentDocFrontMatter = frontMatter;
            var title = item.Header?.ToString() ?? "Documentation";
            var html = MarkdownHtmlBuilder.Build(markdown, title,
                filePath: filePath, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);

            // Refresh source editor if it's open
            if (sourceVisible)
                PopulateDocSourceEditor();
        }
        catch (Exception ex)
        {
            _currentDocPath = null;
            // Show error in viewer
            var errorMarkdown = $"# Error Loading Document\n\nFailed to load `{Path.GetFileName(filePath)}`:\n\n```\n{ex.Message}\n```";
            var html = MarkdownHtmlBuilder.Build(errorMarkdown, "Error",
                filePath: null, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);
        }
    }

    private void DocMarkdownViewer_Navigating(object sender, NavigatingCancelEventArgs e)
    {
        var uri = e.Uri;
        // NavigateToString fires Navigating with null URI — let it through
        if (uri == null) return;

        var uriString = uri.ToString();

        // about:blank is the initial load — let it through
        if (uriString == "about:blank" || uriString.StartsWith("about:")) return;

        // We handle all real URI navigation ourselves
        e.Cancel = true;

        // External URLs: open in system browser
        if (uriString.StartsWith("http://") || uriString.StartsWith("https://") ||
            uriString.StartsWith("chrome://") || uriString.StartsWith("edge://"))
        {
            try { _squadCliAdapter.OpenExternalLink(uriString); }
            catch { /* ignore */ }
            return;
        }

        // Internal doc links: resolve relative path and navigate to that doc
        try
        {
            string? resolvedPath = null;

            // Try as a file URI
            if (uri.IsFile)
            {
                resolvedPath = uri.LocalPath;
            }
            else
            {
                // Try resolving relative to current doc directory
                var currentDocPath = _currentDocPath;
                if (!string.IsNullOrEmpty(currentDocPath))
                {
                    var currentDir = System.IO.Path.GetDirectoryName(currentDocPath);
                    if (!string.IsNullOrEmpty(currentDir))
                    {
                        var relativePart = Uri.UnescapeDataString(uriString);
                        resolvedPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(currentDir, relativePart));
                    }
                }
            }

            if (resolvedPath != null && resolvedPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
                && System.IO.File.Exists(resolvedPath))
            {
                // Find the TreeViewItem with this path as its Tag and select it
                NavigateToDocByPath(resolvedPath);
                return;
            }
        }
        catch { /* ignore bad paths */ }

        // Anything else (anchors, javascript, etc.) — already cancelled, just return
    }

    private void NavigateToDocByPath(string path)
    {
        if (DocTopicsTreeView is null) return;

        var item = FindDocNodeByTag(DocTopicsTreeView.Items, path);
        if (item is not null)
        {
            item.IsSelected = true;
            item.BringIntoView();
        }
    }

    private void RenderDocumentationWelcome()
    {
        if (DocMarkdownViewer is null)
            return;

        const string welcomeMarkdown = """
            # Documentation

            Select a topic from the tree on the left to start reading.

            ## Adding docs to your repo

            The documentation panel reads from a `docs/` folder in your workspace root.
            If no `docs/` folder exists, the topic tree will be empty.

            To get started:

            1. Create a `docs/` folder in your repository root
            2. Add Markdown (`.md`) files — one file per topic
            3. Optionally add a `SUMMARY.md` in GitBook format to control the order and hierarchy of topics
            4. Click **Add Document** above to scaffold a new topic

            ## Folder structure

            ```
            your-repo/
            └── docs/
                ├── SUMMARY.md          ← optional: controls tree order
                ├── README.md           ← home page
                ├── getting-started/
                │   └── installation.md
                └── reference/
                    └── configuration.md
            ```

            ## SUMMARY.md format

            ```markdown
            * [Home](README.md)

            ## Getting Started

            * [Getting Started](getting-started/README.md)
              * [Installation](getting-started/installation.md)
            ```

            Without a `SUMMARY.md`, folders and files are listed alphabetically.
            """;

        try
        {
            var html = MarkdownHtmlBuilder.Build(welcomeMarkdown, "Documentation",
                filePath: null, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);
        }
        catch
        {
            // WebBrowser may not be ready; ignore
        }
    }

    private void AddDocumentButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);
            if (string.IsNullOrEmpty(docsRoot))
            {
                System.Windows.MessageBox.Show("No docs/ folder found in the current workspace.", "Add Document", MessageBoxButton.OK, MessageBoxImage.Warning);
                return;
            }

            var selectedItem = DocTopicsTreeView?.SelectedItem as TreeViewItem;
            var selectedTag = selectedItem?.Tag as string;

            // Determine target folder and anchor info for SUMMARY.md
            string targetFolder;
            string? anchorFilePath = null;   // sibling anchor (doc leaf)
            TreeViewItem? parentGroupItem = null; // group header item (child placement)

            if (selectedItem is null)
            {
                targetFolder = docsRoot;
            }
            else if (!string.IsNullOrEmpty(selectedTag))
            {
                // Selected item is a document leaf → place as sibling
                targetFolder = Path.GetDirectoryName(selectedTag)!;
                anchorFilePath = selectedTag;
            }
            else
            {
                // Selected item is a group/folder header → place as child
                parentGroupItem = selectedItem;
                // Infer folder from first child that has a file tag
                string? childFolder = null;
                foreach (var child in selectedItem.Items.OfType<TreeViewItem>())
                {
                    if (child.Tag is string ct && !string.IsNullOrEmpty(ct))
                    {
                        childFolder = Path.GetDirectoryName(ct);
                        break;
                    }
                }
                targetFolder = childFolder ?? docsRoot;
            }

            // Generate unique filename
            var newFilePath = Path.Combine(targetFolder, "new-document.md");
            int counter = 2;
            while (File.Exists(newFilePath))
                newFilePath = Path.Combine(targetFolder, $"new-document-{counter++}.md");

            var newFileName = Path.GetFileNameWithoutExtension(newFilePath);
            const string newTitle = "New Document";

            if (_docsWatcher != null) _docsWatcher.EnableRaisingEvents = false;
            try
            {
                File.WriteAllText(newFilePath, $"# {newTitle}\n\nWrite your content here.\n");

                var summaryPath = Path.Combine(docsRoot, "SUMMARY.md");
                if (File.Exists(summaryPath))
                {
                    var newRelPath = Path.GetRelativePath(docsRoot, newFilePath).Replace('\\', '/');
                    var newLine = $"  * [{newTitle}]({newRelPath})";

                    var summaryLines = File.ReadAllLines(summaryPath).ToList();

                    if (anchorFilePath is not null)
                    {
                        // Insert as child: find anchor line and insert after it, indented one level deeper.
                        // When a leaf is selected we make the new doc a child of it so the selected
                        // node becomes the parent (matching the "add child" intent of the + button).
                        var anchorRelPath = Path.GetRelativePath(docsRoot, anchorFilePath).Replace('\\', '/');
                        int insertAfter = -1;
                        for (int i = 0; i < summaryLines.Count; i++)
                        {
                            if (summaryLines[i].Contains($"({anchorRelPath})"))
                            {
                                insertAfter = i;
                                break;
                            }
                        }
                        // Add two extra spaces beyond the anchor's indentation → child level
                        if (insertAfter >= 0)
                        {
                            var anchorIndent = new string(' ', summaryLines[insertAfter].TakeWhile(char.IsWhiteSpace).Count());
                            summaryLines.Insert(insertAfter + 1, anchorIndent + "  " + $"* [{newTitle}]({newRelPath})");
                        }
                        else
                        {
                            summaryLines.Add(newLine);
                        }
                    }
                    else if (parentGroupItem is not null)
                    {
                        // Insert as child at end of parent group
                        // Find the group header line by its title text
                        string? groupTitle = null;
                        if (parentGroupItem.Header is string hs) groupTitle = hs;
                        else if (parentGroupItem.Header is System.Windows.Controls.StackPanel gsp
                            && gsp.Children.Count > 0
                            && gsp.Children[0] is System.Windows.Controls.TextBlock gtb)
                            groupTitle = gtb.Text;

                        int groupHeaderLine = -1;
                        if (!string.IsNullOrEmpty(groupTitle))
                        {
                            for (int i = 0; i < summaryLines.Count; i++)
                            {
                                var m = System.Text.RegularExpressions.Regex.Match(
                                    summaryLines[i], @"^\s*\*\s+\[([^\]]+)\]");
                                if (m.Success && string.Equals(m.Groups[1].Value, groupTitle, StringComparison.OrdinalIgnoreCase))
                                {
                                    groupHeaderLine = i;
                                    break;
                                }
                            }
                        }

                        if (groupHeaderLine >= 0)
                        {
                            // Find last child line of this group (indented lines after the group header)
                            int lastChildLine = groupHeaderLine;
                            for (int i = groupHeaderLine + 1; i < summaryLines.Count; i++)
                            {
                                if (string.IsNullOrWhiteSpace(summaryLines[i])) continue;
                                var indent = summaryLines[i].TakeWhile(char.IsWhiteSpace).Count();
                                if (indent >= 2)
                                    lastChildLine = i;
                                else
                                    break;
                            }
                            summaryLines.Insert(lastChildLine + 1, newLine);
                        }
                        else
                        {
                            summaryLines.Add(newLine);
                        }
                    }
                    else
                    {
                        summaryLines.Add(newLine);
                    }

                    File.WriteAllLines(summaryPath, summaryLines);
                }
            }
            finally
            {
                if (_docsWatcher != null) _docsWatcher.EnableRaisingEvents = true;
            }

            PopulateDocumentationTopics();

            if (DocTopicsTreeView != null)
            {
                var newItem = FindDocNodeByTag(DocTopicsTreeView.Items, newFilePath);
                if (newItem is not null)
                {
                    newItem.IsSelected = true;
                    _docsRenameIsFromAdd = true;
                    EnterInPlaceRename(newItem, newFilePath);
                }
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(AddDocumentButton_Click), ex);
        }
    }

    private void ViewPagesButton_Click(object sender, RoutedEventArgs e)
    {
        if (_workspaceGitHubUrl is null) return;
        try
        {
            var uri      = new Uri(_workspaceGitHubUrl);
            var segments = uri.AbsolutePath.Trim('/').Split('/');
            if (segments.Length < 2) return;

            var pagesBase = $"https://{segments[0]}.github.io/{segments[1]}/";

            // If a doc is currently open, try to navigate directly to its Pages URL
            // by computing the relative path from the docs root to the current doc.
            var docRelativePath = TryGetCurrentDocPagesPath();
            var pagesUrl = docRelativePath is not null
                ? pagesBase + docRelativePath
                : pagesBase;

            Process.Start(new ProcessStartInfo(pagesUrl) { UseShellExecute = true });
        }
        catch { }
    }

    /// <summary>
    /// Returns the URL path segment (no leading slash) for the currently open doc
    /// relative to the GitHub Pages root, or <c>null</c> if no doc is open or the
    /// docs root cannot be resolved.
    /// </summary>
    /// <remarks>
    /// Jekyll (and just-the-docs) typically converts <c>docs/panels/Tasks.md</c> to
    /// the path <c>panels/Tasks/</c> relative to the Pages root.  The <c>.md</c>
    /// extension is stripped and a trailing slash is added.
    /// </remarks>
    private string? TryGetCurrentDocPagesPath()
    {
        if (string.IsNullOrEmpty(_currentDocPath)) return null;
        try
        {
            var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);
            if (string.IsNullOrEmpty(docsRoot)) return null;

            // Make both paths absolute for reliable comparison.
            var absDocPath  = Path.GetFullPath(_currentDocPath);
            var absDocsRoot = Path.GetFullPath(docsRoot);

            if (!absDocPath.StartsWith(absDocsRoot, StringComparison.OrdinalIgnoreCase)) return null;

            // e.g.  absDocPath  = …/docs/panels/Tasks.md
            //        absDocsRoot = …/docs
            // →  rel = "panels/Tasks.md"  →  "panels/Tasks/"
            var relativePath = absDocPath[absDocsRoot.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
            var noExtension  = Path.ChangeExtension(relativePath, null).TrimEnd('.');
            // Convert Windows backslashes to forward slashes for URLs.
            // Jekyll on GitHub Pages uses .html permalinks by default (no trailing slash).
            var urlPath = noExtension.Replace(Path.DirectorySeparatorChar, '/') + ".html";
            return urlPath;
        }
        catch { return null; }
    }

    // ── Source editor (View Source panel) ────────────────────────────────────

    private static readonly System.Text.RegularExpressions.Regex FrontMatterRegex =
        new(@"^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?",
            System.Text.RegularExpressions.RegexOptions.Compiled);

    /// <summary>
    /// Strips a Jekyll/JTD YAML front matter block from <paramref name="raw"/> and returns
    /// the body text.  <paramref name="frontMatter"/> receives the stripped block (including
    /// the trailing newline) so it can be prepended on save without data loss.
    /// </summary>
    private static string StripDocFrontMatter(string raw, out string frontMatter) =>
        DocumentUtilities.StripDocFrontMatter(raw, out frontMatter);

    private bool _suppressDocSourceTextChanged;
    private bool _suppressDocSourceNextTextInput;

    // ── Shift+F3 case-cycle state (DocSourceTextBox) ─────────────────────────────
    private string?       _docCycleOriginal;    // original selected text before first cycle press
    private List<string>? _docCycleVariants;    // [TitleCase, SentenceCase, UPPER, PascalCase]
    private int           _docCycleIndex;       // index of the variant currently shown
    private int           _docCycleSelStart;    // selection start when cycle began
    private DispatcherTimer? _docSourceSaveTimer;
    private DispatcherTimer? _docPreviewRefreshTimer;

    private void ViewSourceButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var showing = IsDocSourceVisible();
            if (!showing)
                ShowDocSourcePanel();
            else
                HideDocSourcePanel();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ViewSourceButton_Click), ex);
        }
    }

    private bool IsDocSourceVisible() =>
        _docSourceLayoutTopBottom
            ? (DocsSourceRow?.ActualHeight ?? 0) > 0
            : (DocsSourceColumn?.ActualWidth ?? 0) > 0;

    private double GetDocSourceSize() =>
        _docSourceLayoutTopBottom
            ? DocsSourceRow?.ActualHeight ?? 0
            : DocsSourceColumn?.ActualWidth ?? 0;

    private void ShowDocSourcePanel()
    {
        if (DocsSourceSplitterColumn is null || DocsSourceColumn is null) return;
        if (DocsSourceSplitterRow is null || DocsSourceRow is null) return;

        const double splitterWidth = 6;
        double availableWidth = (DocsPanelColumn?.ActualWidth ?? 600)
                                - (DocsTopicsColumn?.ActualWidth ?? 220)
                                - splitterWidth;
        double sourceWidth = Math.Max(100, availableWidth / 2);
        double sourceSize = _docSourceLayoutTopBottom ? 300 : sourceWidth;

        ApplyDocSourceLayout(_docSourceLayoutTopBottom, sourceSize);
        if (ViewSourceButton is not null) ViewSourceButton.Content = "Hide Source";

        ApplyDocSourceFontSize();
        PopulateDocSourceEditor();

        // Inject hover JS now that the source panel is visible — the initial LoadCompleted
        // may have fired before the panel was shown (e.g., on startup restore).
        if (DocMarkdownViewer is not null)
        {
            try { DocMarkdownViewer.InvokeScript("eval", new object[] { HoverInjectionScript }); }
            catch { }
        }
    }

    private void HideDocSourcePanel()
    {
        if (DocsSourceSplitterColumn is null || DocsSourceColumn is null) return;
        if (DocsSourceSplitterRow is null || DocsSourceRow is null) return;

        // Flush any pending edits before hiding; stopping the timer without saving
        // would silently discard content the user typed in the last 400 ms.
        SaveDocSourceToDisk();
        _docSourceSaveTimer?.Stop();
        _docPreviewRefreshTimer?.Stop();
        DocsSourceSplitterColumn.Width = new GridLength(0);
        DocsSourceColumn.Width = new GridLength(0);
        if (DocsPreviewColumn is not null)
            DocsPreviewColumn.Width = new GridLength(1, GridUnitType.Star);
        DocsSourceSplitterRow.Height = new GridLength(0);
        DocsSourceRow.Height = new GridLength(0);
        if (DocSourceSplitter is not null) DocSourceSplitter.Visibility = Visibility.Collapsed;
        if (DocSourcePanel is not null) DocSourcePanel.Visibility = Visibility.Collapsed;
        if (ViewSourceButton is not null) ViewSourceButton.Content = "View Source";
    }

    private void ApplyDocSourceLayout(bool topBottom, double sourceSize)
    {
        if (DocsSourceSplitterColumn is null || DocsSourceColumn is null) return;
        if (DocsSourceSplitterRow is null || DocsSourceRow is null) return;
        if (DocSourceSplitter is null || DocSourcePanel is null) return;

        const double splitterSize = 6;

        if (topBottom)
        {
            // Collapse column-based dimensions; restore preview column to full star
            if (DocsPreviewColumn is not null)
                DocsPreviewColumn.Width = new GridLength(1, GridUnitType.Star);
            DocsSourceSplitterColumn.Width = new GridLength(0);
            DocsSourceColumn.Width = new GridLength(0);

            // Set row-based dimensions
            DocsSourceSplitterRow.Height = new GridLength(splitterSize);
            DocsSourceRow.Height = new GridLength(Math.Max(100, sourceSize), GridUnitType.Pixel);

            // Move splitter and source panel to row layout (col 1 = viewer column)
            Grid.SetRow(DocSourceSplitter, 1);
            Grid.SetColumn(DocSourceSplitter, 1);
            Grid.SetRow(DocSourcePanel, 2);
            Grid.SetColumn(DocSourcePanel, 1);

            DocSourceSplitter.Width = double.NaN;
            DocSourceSplitter.Height = splitterSize;
            DocSourceSplitter.Cursor = System.Windows.Input.Cursors.SizeNS;
            DocSourceSplitter.ResizeDirection = GridResizeDirection.Rows;
        }
        else
        {
            // Collapse row-based dimensions
            DocsSourceSplitterRow.Height = new GridLength(0);
            DocsSourceRow.Height = new GridLength(0);

            // Set column-based dimensions using star sizing so both panels
            // scale proportionally on window resize and the source never overflows.
            DocsSourceSplitterColumn.Width = new GridLength(splitterSize);
            ApplyDocSourceSideBySide(sourceSize);

            // Move splitter and source panel back to column layout
            Grid.SetRow(DocSourceSplitter, 0);
            Grid.SetColumn(DocSourceSplitter, 2);
            Grid.SetRow(DocSourcePanel, 0);
            Grid.SetColumn(DocSourcePanel, 3);

            DocSourceSplitter.Width = splitterSize;
            DocSourceSplitter.Height = double.NaN;
            DocSourceSplitter.Cursor = System.Windows.Input.Cursors.SizeWE;
            DocSourceSplitter.ResizeDirection = GridResizeDirection.Columns;
        }

        DocSourceSplitter.Visibility = Visibility.Visible;
        DocSourcePanel.Visibility = Visibility.Visible;
    }

    /// <summary>
    /// Sets the preview and source columns to proportional star widths so the
    /// two panels always share the available space and neither overflows when
    /// the window is resized narrower.
    /// </summary>
    private void ApplyDocSourceSideBySide(double sourceSize)
    {
        if (DocsPreviewColumn is null || DocsSourceColumn is null) return;
        const double splitterSize = 6;
        double docsAvailable = (DocsPanelColumn?.ActualWidth ?? 600)
                               - (DocsTopicsColumn?.ActualWidth ?? 220)
                               - splitterSize;
        double safeSource  = Math.Clamp(sourceSize, 100, Math.Max(100, docsAvailable - 100));
        double previewSize = Math.Max(100, docsAvailable - safeSource);
        DocsPreviewColumn.Width = new GridLength(previewSize, GridUnitType.Star);
        DocsSourceColumn.Width  = new GridLength(safeSource,  GridUnitType.Star);
    }

    private void SetDocSourceLayout(bool topBottom)
    {
        if (_docSourceLayoutTopBottom == topBottom) return;

        // Capture visibility BEFORE flipping the flag — IsDocSourceVisible()
        // checks the dimension matching the CURRENT mode, so it must run first.
        bool isVisible = IsDocSourceVisible();

        _docSourceLayoutTopBottom = topBottom;
        UpdateDocSourceLayoutButtons();

        if (isVisible)
        {
            // Reset to 50/50 split in the new orientation.
            double halfSize = topBottom
                ? Math.Max(100, (DocsPanel?.ActualHeight ?? 600) / 2)
                : Math.Max(100, ((DocsPanelColumn?.ActualWidth ?? 600) - (DocsTopicsColumn?.ActualWidth ?? 220) - 6) / 2);
            ApplyDocSourceLayout(topBottom, halfSize);
        }

        SaveDocSourceLayoutPreference();
    }

    private void UpdateDocSourceLayoutButtons()
    {
        var activeBrush   = TryFindResource("QueueTabActiveBorder") as Brush ?? Brushes.CornflowerBlue;
        var inactiveBrush = Brushes.Transparent;
        if (DocSourceSideBySideIndicator is not null)
            DocSourceSideBySideIndicator.Background = !_docSourceLayoutTopBottom ? activeBrush : inactiveBrush;
        if (DocSourceTopBottomIndicator is not null)
            DocSourceTopBottomIndicator.Background  = _docSourceLayoutTopBottom  ? activeBrush : inactiveBrush;
    }

    private void SaveDocSourceLayoutPreference()
    {
        _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with
        {
            SourceLayoutTopBottom = _docSourceLayoutTopBottom ? true : null,
        };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void DocSourceSideBySideButton_Click(object sender, RoutedEventArgs e)
    {
        try { SetDocSourceLayout(topBottom: false); }
        catch (Exception ex) { HandleUiCallbackException(nameof(DocSourceSideBySideButton_Click), ex); }
    }

    private void DocSourceTopBottomButton_Click(object sender, RoutedEventArgs e)
    {
        try { SetDocSourceLayout(topBottom: true); }
        catch (Exception ex) { HandleUiCallbackException(nameof(DocSourceTopBottomButton_Click), ex); }
    }

    private void PopulateDocSourceEditor()
    {
        if (DocSourceTextBox is null) return;

        // Cancel any pending preview refresh so it doesn't fire with stale content
        // after the document switch.
        _docPreviewRefreshTimer?.Stop();

        _suppressDocSourceTextChanged = true;
        try
        {
            if (string.IsNullOrEmpty(_currentDocPath) || !File.Exists(_currentDocPath))
            {
                DocSourceTextBox.SetPlainText(string.Empty);
            }
            else
            {
                var raw      = File.ReadAllText(_currentDocPath);
                var stripped = StripDocFrontMatter(raw, out var frontMatter);
                _currentDocFrontMatter = frontMatter;
                DocSourceTextBox.SetPlainText(stripped);
            }
        }
        finally
        {
            _suppressDocSourceTextChanged = false;
        }
    }

    private void DocSourceTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        // During shutdown WPF can fire TextChanged as the visual tree is torn down (template
        // detachment / document reset).  Restarting the debounce timer at that point would
        // cause it to fire later with an empty document and wipe the file to frontmatter-only.
        if (_suppressDocSourceTextChanged || _isClosing) return;

        // Debounce the markdown preview refresh — re-navigating the WebBrowser on every
        // keystroke is expensive, especially for tables.
        if (_docPreviewRefreshTimer is null)
        {
            _docPreviewRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
            _docPreviewRefreshTimer.Tick += DocPreviewRefreshTimer_Tick;
        }
        _docPreviewRefreshTimer.Stop();
        _docPreviewRefreshTimer.Start();

        // Debounce save to disk
        if (_docSourceSaveTimer is null)
        {
            _docSourceSaveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
            _docSourceSaveTimer.Tick += DocSourceSaveTimer_Tick;
        }
        _docSourceSaveTimer.Stop();
        _docSourceSaveTimer.Start();
    }

    private void DocSourceSaveTimer_Tick(object? sender, EventArgs e)
    {
        _docSourceSaveTimer?.Stop();
        // The flush in MainWindow_Closing already saved the correct content;
        // ignore any tick that slips through after shutdown begins.
        if (_isClosing) return;
        SaveDocSourceToDisk();
    }

    private void DocPreviewRefreshTimer_Tick(object? sender, EventArgs e)
    {
        _docPreviewRefreshTimer?.Stop();
        if (_isClosing) return;
        RefreshDocMarkdownViewerFromSource();
    }

    private static string HoverInjectionScript => MarkdownDocumentScripts.HoverInjectionScript;

    private void DocMarkdownViewer_LoadCompleted_InjectHover(object sender, NavigationEventArgs e)
    {
        if (DocSourcePanel?.Visibility != Visibility.Visible) return;
        try
        {
            DocMarkdownViewer.InvokeScript("eval", new object[] { HoverInjectionScript });
        }
        catch { }
    }

    private void RefreshDocMarkdownViewerFromSource()
    {
        if (DocMarkdownViewer is null || DocSourceTextBox is null) return;

        // Capture current scroll position before re-rendering
        try
        {
            var result = DocMarkdownViewer.InvokeScript("eval",
                new object[] { "document.documentElement.scrollTop || document.body.scrollTop" });
            if (result is not null && double.TryParse(result.ToString(), out var y))
                _docPreviewScrollY = y;
        }
        catch { /* WebBrowser has no document yet */ }

        var markdown = DocSourceTextBox.GetPlainText();
        var title = string.IsNullOrEmpty(_currentDocPath) ? "Documentation" : Path.GetFileNameWithoutExtension(_currentDocPath);
        var html = MarkdownHtmlBuilder.Build(markdown, title, filePath: _currentDocPath, isDark: AgentStatusCard.IsDarkTheme);

        // Restore scroll after load completes
        var scrollY = _docPreviewScrollY;
        LoadCompletedEventHandler? restoreScroll = null;
        restoreScroll = (s, e) =>
        {
            DocMarkdownViewer.LoadCompleted -= restoreScroll;
            try
            {
                DocMarkdownViewer.InvokeScript("eval",
                    new object[] { $"document.documentElement.scrollTop={scrollY};document.body.scrollTop={scrollY};" });
            }
            catch { }
        };
        DocMarkdownViewer.LoadCompleted += restoreScroll;

        DocMarkdownViewer.NavigateToString(html);
    }

    // Feature 3: Highlight doc source from hover
    internal void HighlightDocSourceFromHover(string lineHint)
    {
        if (DocSourceTextBox is null || string.IsNullOrEmpty(lineHint)) return;
        if (!int.TryParse(lineHint, out var lineNum) || lineNum < 1) return;

        var lines = DocSourceTextBox.GetPlainText().Split('\n');
        if (lineNum > lines.Length) return;

        // Find the start position of the line
        int startPos = 0;
        for (int i = 0; i < lineNum - 1; i++)
        {
            startPos += lines[i].Length + 1; // +1 for newline
        }

        var lineLength = lineNum - 1 < lines.Length ? lines[lineNum - 1].Length : 0;
        HighlightDocSourceRange(startPos, lineLength);
    }

    // Called when the user clicks an element in the preview — scrolls source to that line and highlights it.
    internal void ScrollDocSourceToLine(string lineHint)
    {
        if (DocSourceTextBox is null || string.IsNullOrEmpty(lineHint)) return;
        if (!int.TryParse(lineHint, out var lineNum) || lineNum < 1) return;

        var lines = DocSourceTextBox.GetPlainText().Split('\n');
        if (lineNum > lines.Length) return;

        int startPos = 0;
        for (int i = 0; i < lineNum - 1; i++)
            startPos += lines[i].Length + 1;

        var lineLength = lineNum - 1 < lines.Length ? lines[lineNum - 1].Length : 0;
        DocSourceTextBox.ScrollToOffset(startPos);
        // Defer highlight until after WPF has processed the scroll (layout settles at Render priority).
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Render,
            () => HighlightDocSourceRange(startPos, lineLength));
    }

    private Canvas EnsureDocSourceOverlayCanvas()
    {
        if (_docSourceOverlayCanvas is not null) return _docSourceOverlayCanvas;

        if (DocSourcePanel is null) throw new InvalidOperationException("DocSourcePanel is null");

        Grid grid;
        if (DocSourcePanel.Child is Grid existingGrid)
        {
            grid = existingGrid;
        }
        else
        {
            var child = DocSourcePanel.Child;
            grid = new Grid();
            DocSourcePanel.Child = grid;
            if (child is not null)
                grid.Children.Add(child);
        }

        _docSourceOverlayCanvas = new Canvas
        {
            IsHitTestVisible = false,
            Background = Brushes.Transparent
        };
        grid.Children.Add(_docSourceOverlayCanvas);
        return _docSourceOverlayCanvas;
    }

    private void HighlightDocSourceRange(int start, int length)
    {
        if (DocSourceTextBox is null || DocSourcePanel is null) return;

        // Remove existing hover highlight
        if (_docSourceHoverHighlight is not null)
        {
            (_docSourceHoverHighlight.Parent as Canvas)?.Children.Remove(_docSourceHoverHighlight);
            _docSourceHoverHighlight = null;
        }

        _docSourceHoverTimer?.Stop();

        if (length <= 0) return;

        // Get the bounding rect of the character in TextBox space
        var rect = DocSourceTextBox.GetRectFromOffset(start);
        if (rect == Rect.Empty) return;

        var overlayCanvas = EnsureDocSourceOverlayCanvas();

        // Convert TextBox-local coordinates to overlay Canvas coordinates
        var origin = DocSourceTextBox.TranslatePoint(new Point(0, 0), overlayCanvas);
        var charTopLeft = DocSourceTextBox.TranslatePoint(rect.TopLeft, overlayCanvas);

        // If the target line is scrolled outside the visible area of the source box, don't draw.
        double visibleTop = origin.Y;
        double visibleBottom = origin.Y + DocSourceTextBox.ActualHeight;
        if (charTopLeft.Y < visibleTop || charTopLeft.Y >= visibleBottom) return;

        var isDark = AgentStatusCard.IsDarkTheme;
        var highlightColor = isDark
            ? Color.FromArgb(60, 255, 220, 80)    // warm amber tint on dark
            : Color.FromArgb(50, 100, 180, 255);   // cool blue tint on light

        double highlightWidth = Math.Max(DocSourceTextBox.ActualWidth - (charTopLeft.X - origin.X), 0);

        _docSourceHoverHighlight = new Shapes.Rectangle
        {
            Width = highlightWidth,
            Height = Math.Max(rect.Height, 14),
            Fill = new SolidColorBrush(highlightColor),
            IsHitTestVisible = false
        };

        Canvas.SetLeft(_docSourceHoverHighlight, charTopLeft.X);
        Canvas.SetTop(_docSourceHoverHighlight, charTopLeft.Y);
        overlayCanvas.Children.Add(_docSourceHoverHighlight);

        // Auto-clear after 1 second
        _docSourceHoverTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
        _docSourceHoverTimer.Tick += (s, e) =>
        {
            _docSourceHoverTimer.Stop();
            if (_docSourceHoverHighlight is not null)
            {
                (_docSourceHoverHighlight.Parent as Canvas)?.Children.Remove(_docSourceHoverHighlight);
                _docSourceHoverHighlight = null;
            }
        };
        _docSourceHoverTimer.Start();
    }

    private void SaveDocSourceToDisk()
    {
        if (DocSourceTextBox is null || string.IsNullOrEmpty(_currentDocPath)) return;
        try
        {
            var bodyText = DocSourceTextBox.GetPlainText();

            // Safety net: never overwrite a doc file with an empty body.
            // WPF fires TextChanged (and can thereby trigger saves) during visual-tree
            // teardown with an empty document.  Writing at that point would reduce the
            // file to frontmatter-only — silently destroying user content.
            // This guard catches any remaining code-path that reaches SaveDocSourceToDisk
            // with a blank text box, regardless of whether _isClosing is set.
            if (string.IsNullOrWhiteSpace(bodyText))
            {
                SquadDashTrace.Write("DocSave", $"Skipping save — body is empty, likely a teardown artifact: {_currentDocPath}");
                return;
            }

            _docSaveSuppressionUntil = DateTime.UtcNow.AddMilliseconds(500);
            _lastSavedDocPath = _currentDocPath;
            File.WriteAllText(_currentDocPath, _currentDocFrontMatter + bodyText);

            // If the saved file is a loop file, re-scan so the combo box picks up
            // any frontmatter changes (e.g. updated description / display name).
            if (_loopFileEntries.Any(e => string.Equals(e.FilePath, _currentDocPath, StringComparison.OrdinalIgnoreCase)))
                PopulateLoopFilePicker();
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("DocSource", $"Failed to save doc source: {ex.Message}");
        }
    }

    private void ReloadCurrentDocFromDisk()
    {
        if (string.IsNullOrEmpty(_currentDocPath) || !File.Exists(_currentDocPath)) return;
        PopulateDocSourceEditor();
        RefreshDocMarkdownViewerFromSource();
    }

    private void DocSourceTextBox_ApplyBold()
    {
        if (DocSourceTextBox is null) return;
        ApplyMarkdownBold(DocSourceTextBox);
    }

    private void PromptTextBox_ApplyBold()
    {
        ApplyMarkdownBold(PromptTextBox);
    }

    private static void ApplyMarkdownBold(TextBox box) => MarkdownEditorCommands.ApplyBold(box);
    private static void ApplyMarkdownBold(RichTextBox box) => MarkdownEditorCommands.ApplyBold(box);

    private void DocSourceTextBox_ApplyItalic()
    {
        if (DocSourceTextBox is null) return;
        ApplyMarkdownItalic(DocSourceTextBox);
        DocSourceTextBox.Focus();
    }

    private static void ApplyMarkdownItalic(TextBox box) => MarkdownEditorCommands.ApplyItalic(box);
    private static void ApplyMarkdownItalic(RichTextBox box) => MarkdownEditorCommands.ApplyItalic(box);

    private void DocSourceTextBox_SelectionChanged(object sender, RoutedEventArgs e)
    {
    }

    private void DocEditorToolbar_ImageInsertRequested(object sender, RoutedEventArgs e)
    {
        if (DocSourceTextBox is null) return;
        DocSourceTextBox_InsertImagePlaceholder();
        DocSourceTextBox.Focus();
    }

    private void DocSourceTextBox_InsertImagePlaceholder()
    {
        if (DocSourceTextBox is null) return;
        var id = Guid.NewGuid().ToString("N")[..8];
        var placeholder =
            $"![Screenshot: brief description](images/screenshot-{id}.png)\n" +
            "> 📸 *Screenshot needed: Detailed description of what to capture in this screenshot.*";
        DocSourceTextBox.Focus();
        DocSourceTextBox.Selection.Text = placeholder; // inserts at caret, participates in WPF undo stack
    }

    private void DocSourceTextBox_PreviewTextInput(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        if (_suppressDocSourceNextTextInput)
        {
            _suppressDocSourceNextTextInput = false;
            e.Handled = true;
            return;
        }

        // Paren embedding (works on all keyboard layouts)
        if ((e.Text == "(" || e.Text == ")") && DocSourceTextBox.GetSelectionLength() > 0
            && !DocSourceTextBox.GetSelectedText().Contains('\n'))
        {
            if (MarkdownEditorCommands.ApplyInlineParens(DocSourceTextBox, e.Text))
            {
                e.Handled = true;
                return;
            }
        }
    }

    private void DocSourceTextBox_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (DocSourceTextBox is null) return;
        try
        {
            var menu = MakeMenu();

            var hasSelection = DocSourceTextBox.GetSelectionLength() > 0;
            var capturedSelStart = hasSelection ? DocSourceTextBox.GetSelectionStart() : 0;
            var capturedSelLen   = hasSelection ? DocSourceTextBox.GetSelectionLength() : 0;

            if (hasSelection)
            {
                // Capture now — WPF clears the selection when the ContextMenu takes focus.
                var docTitle = _currentDocPath is not null
                    ? System.IO.Path.GetFileNameWithoutExtension(_currentDocPath)
                    : "Documentation";

                var addToChatItem = new MenuItem
                {
                    Header = "Add to chat",
                    Style  = (Style)FindResource("ThemedMenuItemStyle")
                };
                addToChatItem.Click += (_, _) => {
                    var text = DocSourceTextBox.GetSubstring(capturedSelStart, capturedSelLen);
                    if (!string.IsNullOrWhiteSpace(text)) {
                        var sb = new System.Text.StringBuilder();
                        sb.AppendLine($"## Documentation: {docTitle}");
                        if (_currentDocPath is not null) sb.AppendLine($"File: {_currentDocPath}");
                        sb.AppendLine();
                        sb.AppendLine(text.Trim());
                        AttachContextFollowUp($"Doc: {docTitle}", sb.ToString().TrimEnd());
                    }
                };
                menu.Items.Add(addToChatItem);

                var addToNotesItem = new MenuItem
                {
                    Header = "Add to Notes",
                    Style  = (Style)FindResource("ThemedMenuItemStyle")
                };
                addToNotesItem.Click += (_, _) => {
                    var text = DocSourceTextBox.GetSubstring(capturedSelStart, capturedSelLen);
                    if (!string.IsNullOrWhiteSpace(text))
                        AddNoteFromText(text);
                };
                menu.Items.Add(addToNotesItem);

                menu.Items.Add(new Separator { Style = (Style)FindResource("ThemedMenuSeparatorStyle") });
            }

            var cutItem = new MenuItem
            {
                Header = "Cu_t",
                Style = (Style)FindResource("ThemedMenuItemStyle"),
                Command = ApplicationCommands.Cut,
                CommandTarget = DocSourceTextBox
            };
            var copyItem = new MenuItem
            {
                Header = "_Copy",
                Style = (Style)FindResource("ThemedMenuItemStyle"),
                Command = ApplicationCommands.Copy,
                CommandTarget = DocSourceTextBox
            };
            var pasteItem = new MenuItem
            {
                Header = "_Paste",
                Style = (Style)FindResource("ThemedMenuItemStyle"),
                Command = ApplicationCommands.Paste,
                CommandTarget = DocSourceTextBox
            };

            cutItem.IsEnabled = hasSelection;
            copyItem.IsEnabled = hasSelection;
            pasteItem.IsEnabled = Clipboard.ContainsText();

            menu.Items.Add(cutItem);
            menu.Items.Add(copyItem);
            menu.Items.Add(pasteItem);

            if (Clipboard.ContainsImage())
            {
                menu.Items.Add(new Separator { Style = (Style)FindResource("ThemedMenuSeparatorStyle") });
                var imgItem = new MenuItem
                {
                    Header = "Paste image from clipboard",
                    Style = (Style)FindResource("ThemedMenuItemStyle")
                };
                imgItem.Click += (_, _) => DocSourceTextBox_PasteImageFromClipboard();
                menu.Items.Add(imgItem);
            }

            if (hasSelection)
            {
                menu.Items.Add(new Separator { Style = (Style)FindResource("ThemedMenuSeparatorStyle") });

                var reviseItem = new MenuItem
                {
                    Header           = "✏ _Revise with AI",
                    InputGestureText = "Ctrl+Shift+A",
                    Style            = (Style)FindResource("ThemedMenuItemStyle")
                };
                reviseItem.Click += (_, _) => ShowDocRevisePopup(DocSourceTextBox, _currentDocPath ?? "", capturedSelStart, capturedSelLen);
                menu.Items.Add(reviseItem);

                var cleanupItem = new MenuItem
                {
                    Header           = "⚡ _Quick Cleanup",
                    InputGestureText = "Ctrl+Shift+C",
                };
                cleanupItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                cleanupItem.Click += (_, _) => DirectReviseRichTextBox(DocSourceTextBox, _currentDocPath ?? "", _settingsSnapshot.CleanupPrompt);
                menu.Items.Add(cleanupItem);

                var smoothItem = new MenuItem
                {
                    Header           = "✨ Smooth Dictation",
                    InputGestureText = "Shift+Space",
                    Style            = (Style)FindResource("ThemedMenuItemStyle")
                };
                smoothItem.Click += (_, _) => {
                    DocSourceTextBox.SelectRange(capturedSelStart, capturedSelLen);
                    SmoothDictationHelper.ApplyToRichTextBox(DocSourceTextBox);
                };
                menu.Items.Add(smoothItem);
            }

            menu.PlacementTarget = DocSourceTextBox;
            menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocSourceTextBox_PreviewMouseRightButtonDown), ex);
        }
    }

    private void ShowDocRevisePopup(System.Windows.Controls.TextBox textBox, string filePath,
        int selStart = -1, int selLen = -1)
    {
        // Fall back to current selection if no captured coords were provided.
        if (selStart < 0)
        {
            selStart = textBox.SelectionStart;
            selLen   = textBox.SelectionLength;
        }
        if (selLen <= 0) return;

        // Capture focus before we move it to the text box, so we can restore it after submit.
        var priorFocus = Keyboard.FocusedElement as IInputElement;

        // Restore the selection visually so the user sees it highlighted in the popup background.
        textBox.Focus();
        textBox.SelectionStart  = selStart;
        textBox.SelectionLength = selLen;

        var originalText = textBox.Text.Substring(selStart, selLen);
        var fullText     = textBox.Text;

        var capturedStart = selStart;
        var capturedLen   = selLen;

        var popup = new DocRevisePopup(
            originalText,
            fullText,
            filePath,
            (instructions, sel, doc, workingDir, ct) =>
                _bridge.RunDocRevisionAsync(instructions, sel, doc, workingDir, ct),
            onRevised: revised => Dispatcher.Invoke(
                () => ApplyDocRevision(textBox, capturedStart, capturedLen, originalText, revised)),
            onSubmitting: popupCenter => {
                // Restore focus immediately (before the overlay appears, so it never steals it).
                priorFocus?.Focus();
                Keyboard.Focus(priorFocus);
                ShowRevisionWorkingOverlay(popupCenter);
            },
            startPtt: (tb) => {
                _pttTargetTextBox = tb;
                _sessionCaretIndex = tb.SelectionStart;
                _sessionSelectionLength = tb.SelectionLength;
                _voiceStartedWithSendEnabled = false;
                _pttState = PttState.Active;
                _ = StartPushToTalkAsync();
            },
            stopPtt: () => _ = StopPushToTalkAsync(send: false));

        PositionPopupNearCaret(popup, textBox, selStart, selLen);
        popup.Owner = this;
        popup.Show();
    }

    private void ShowDocRevisePopup(RichTextBox textBox, string filePath,
        int selStart = -1, int selLen = -1)
    {
        if (selStart < 0)
        {
            selStart = textBox.GetSelectionStart();
            selLen   = textBox.GetSelectionLength();
        }
        if (selLen <= 0) return;

        var priorFocus = Keyboard.FocusedElement as IInputElement;

        textBox.Focus();
        textBox.SelectRange(selStart, selLen);

        var originalText = textBox.GetSubstring(selStart, selLen);
        var fullText     = textBox.GetPlainText();

        // Capture live TextPointer anchors — use GetTextPointerAt so offsets are counted
        // as plain-text characters (not FlowDocument structural symbols).
        // WPF TextPointers automatically track insertions/deletions that happen before them.
        var startPointer = textBox.GetTextPointerAt(selStart);
        var endPointer   = textBox.GetTextPointerAt(selStart + selLen);

        RevisionPendingIndicator?  indicator = null;
        RevisionHighlightAdorner?  highlight = null;

        var popup = new DocRevisePopup(
            originalText,
            fullText,
            filePath,
            (instructions, sel, doc, workingDir, ct) =>
                _bridge.RunDocRevisionAsync(instructions, sel, doc, workingDir, ct),
            onRevised: revised => Dispatcher.Invoke(() => {
                indicator?.Detach();
                indicator = null;
                highlight?.Remove();
                highlight = null;
                
                // Use live TextPointers to get current text after any edits
                var currentSelectedText = new TextRange(startPointer, endPointer).Text;
                
                // Apply revision using TextPointers if text is still intact
                if (currentSelectedText == originalText)
                {
                    var replaceRange = new TextRange(startPointer, endPointer);
                    replaceRange.Text = revised;
                }
                else
                {
                    // Fallback: show in separate window if document was edited
                    var win = new RevisionResultWindow(revised) { Owner = this };
                    WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(win, this);
                    win.Show();
                }
            }),
            onSubmitting: popupCenter => {
                priorFocus?.Focus();
                Keyboard.Focus(priorFocus);
                highlight  = RevisionHighlightAdorner.Attach(textBox, startPointer, endPointer);
                indicator  = RevisionPendingIndicator.Attach(textBox, endPointer);
                ShowRevisionWorkingOverlay(popupCenter);
            },
            startPtt: (tb) => {
                _pttTargetTextBox = tb;
                _sessionCaretIndex = tb.SelectionStart;
                _sessionSelectionLength = tb.SelectionLength;
                _voiceStartedWithSendEnabled = false;
                _pttState = PttState.Active;
                _ = StartPushToTalkAsync();
            },
            stopPtt: () => _ = StopPushToTalkAsync(send: false));

        PositionPopupNearCaret(popup, textBox, selStart, selLen);
        popup.Owner = this;
        popup.Show();
    }

    private void PositionPopupNearCaret(Window popup, System.Windows.Controls.TextBox textBox,
        int selStart, int selLen = 0)
    {
        try
        {
            var startIdx = Math.Max(0, selStart);
            var endIdx   = selLen > 1 ? selStart + selLen - 1 : startIdx;

            var startRect = textBox.GetRectFromCharacterIndex(startIdx);
            var endRect   = textBox.GetRectFromCharacterIndex(endIdx);

            var startTopScreen    = textBox.PointToScreen(new Point(startRect.Left,  startRect.Top));
            var startBottomScreen = textBox.PointToScreen(new Point(startRect.Left,  startRect.Bottom));
            var endBottomScreen   = textBox.PointToScreen(new Point(endRect.Right,   endRect.Bottom));
            var endRightScreen    = textBox.PointToScreen(new Point(endRect.Right,   endRect.Top));

            var dpi      = System.Windows.Media.VisualTreeHelper.GetDpi(this);
            var workArea = NativeMethods.GetWorkAreaForPhysicalPoint((int)endBottomScreen.X, (int)endBottomScreen.Y);

            var waLeft   = workArea.Left   / dpi.DpiScaleX;
            var waTop    = workArea.Top    / dpi.DpiScaleY;
            var waRight  = workArea.Right  / dpi.DpiScaleX;
            var waBottom = workArea.Bottom / dpi.DpiScaleY;

            double startTopY    = startTopScreen.Y    / dpi.DpiScaleY;
            double startBottomY = startBottomScreen.Y / dpi.DpiScaleY;
            double startLeftX   = startTopScreen.X    / dpi.DpiScaleX;
            double endBottomY   = endBottomScreen.Y   / dpi.DpiScaleY;
            double endRightX    = endRightScreen.X    / dpi.DpiScaleX;

            const double PopupWidth  = 470;
            const double PopupHeight = 235;
            const double Gap         = 6;

            bool Fits(double l, double t) =>
                l >= waLeft && l + PopupWidth  <= waRight &&
                t >= waTop  && t + PopupHeight <= waBottom;

            double ClampX(double l) =>
                Math.Max(waLeft + 4, Math.Min(l, waRight - PopupWidth - 4));

            // 1. Below the last line of the selection
            {
                double t = endBottomY + Gap;
                double l = ClampX(startLeftX - 10);
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 2. Above the first line of the selection
            {
                double t = startTopY - PopupHeight - Gap;
                double l = ClampX(startLeftX - 10);
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 3. Left of the selection
            {
                double l = startLeftX - PopupWidth - Gap;
                double t = Math.Max(waTop + 4, Math.Min(startTopY, waBottom - PopupHeight - 4));
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 4. Right of the selection
            {
                double l = endRightX + Gap;
                double t = Math.Max(waTop + 4, Math.Min(startTopY, waBottom - PopupHeight - 4));
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 5. Fallback: below the first line (original behaviour)
            popup.Left = ClampX(startLeftX - 10);
            popup.Top  = startBottomY + Gap;
        }
        catch
        {
            var mp  = PointToScreen(Mouse.GetPosition(this));
            var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
            popup.Left = mp.X / dpi.DpiScaleX - 10;
            popup.Top  = mp.Y / dpi.DpiScaleY - 10;
        }
    }

    private void PositionPopupNearCaret(Window popup, RichTextBox textBox,
        int selStart, int selLen = 0)
    {
        try
        {
            var startIdx = Math.Max(0, selStart);
            var endIdx   = selLen > 1 ? selStart + selLen - 1 : startIdx;

            var startRect = textBox.GetRectFromOffset(startIdx);
            var endRect   = textBox.GetRectFromOffset(endIdx);

            var startTopScreen    = textBox.PointToScreen(new Point(startRect.Left,  startRect.Top));
            var startBottomScreen = textBox.PointToScreen(new Point(startRect.Left,  startRect.Bottom));
            var endBottomScreen   = textBox.PointToScreen(new Point(endRect.Right,   endRect.Bottom));
            var endRightScreen    = textBox.PointToScreen(new Point(endRect.Right,   endRect.Top));

            var dpi      = System.Windows.Media.VisualTreeHelper.GetDpi(this);
            var workArea = NativeMethods.GetWorkAreaForPhysicalPoint((int)endBottomScreen.X, (int)endBottomScreen.Y);

            var waLeft   = workArea.Left   / dpi.DpiScaleX;
            var waTop    = workArea.Top    / dpi.DpiScaleY;
            var waRight  = workArea.Right  / dpi.DpiScaleX;
            var waBottom = workArea.Bottom / dpi.DpiScaleY;

            double startTopY    = startTopScreen.Y    / dpi.DpiScaleY;
            double startBottomY = startBottomScreen.Y / dpi.DpiScaleY;
            double startLeftX   = startTopScreen.X    / dpi.DpiScaleX;
            double endBottomY   = endBottomScreen.Y   / dpi.DpiScaleY;
            double endRightX    = endRightScreen.X    / dpi.DpiScaleX;

            const double PopupWidth  = 470;
            const double PopupHeight = 235;
            const double Gap         = 6;

            bool Fits(double l, double t) =>
                l >= waLeft && l + PopupWidth  <= waRight &&
                t >= waTop  && t + PopupHeight <= waBottom;

            double ClampX(double l) =>
                Math.Max(waLeft + 4, Math.Min(l, waRight - PopupWidth - 4));

            // 1. Below the last line of the selection
            {
                double t = endBottomY + Gap;
                double l = ClampX(startLeftX - 10);
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 2. Above the first line of the selection
            {
                double t = startTopY - PopupHeight - Gap;
                double l = ClampX(startLeftX - 10);
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 3. Left of the selection
            {
                double l = startLeftX - PopupWidth - Gap;
                double t = Math.Max(waTop + 4, Math.Min(startTopY, waBottom - PopupHeight - 4));
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 4. Right of the selection
            {
                double l = endRightX + Gap;
                double t = Math.Max(waTop + 4, Math.Min(startTopY, waBottom - PopupHeight - 4));
                if (Fits(l, t)) { popup.Left = l; popup.Top = t; return; }
            }

            // 5. Fallback: below the first line (original behaviour)
            popup.Left = ClampX(startLeftX - 10);
            popup.Top  = startBottomY + Gap;
        }
        catch
        {
            var mp  = PointToScreen(Mouse.GetPosition(this));
            var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
            popup.Left = mp.X / dpi.DpiScaleX - 10;
            popup.Top  = mp.Y / dpi.DpiScaleY - 10;
        }
    }

    private void ApplyDocRevision(System.Windows.Controls.TextBox textBox,
        int selStart, int selLen, string originalText, string revised)
    {
        var currentText = textBox.Text;

        // Fast path: text is still at the exact captured offset.
        var intact = selStart >= 0 &&
                     selStart + selLen <= currentText.Length &&
                     currentText.Substring(selStart, selLen) == originalText;

        if (intact)
        {
            textBox.SelectionStart  = selStart;
            textBox.SelectionLength = selLen;
            textBox.SelectedText    = revised;
            ShowRevisionAppliedHint(textBox, selStart);
            return;
        }

        // Slow path: the user edited elsewhere in the document while AI was working
        // (e.g. added a newline on a different line), shifting the stored offset.
        // Search for the original text and apply there if it is unambiguous.
        var firstIdx  = currentText.IndexOf(originalText, StringComparison.Ordinal);
        var secondIdx = firstIdx < 0 ? -1
            : currentText.IndexOf(originalText, firstIdx + 1, StringComparison.Ordinal);

        if (firstIdx >= 0 && secondIdx < 0)
        {
            // Found exactly once — safe to apply at the new position.
            textBox.SelectionStart  = firstIdx;
            textBox.SelectionLength = originalText.Length;
            textBox.SelectedText    = revised;
            ShowRevisionAppliedHint(textBox, firstIdx);
            return;
        }

        // Text not found or found in multiple places — show the manual-copy fallback.
        var win = new RevisionResultWindow(revised) { Owner = this };
        WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(win, this);
        win.Show();
    }

    private void ApplyDocRevision(RichTextBox textBox,
        int selStart, int selLen, string originalText, string revised)
    {
        var currentText = textBox.GetPlainText();

        var intact = selStart >= 0 &&
                     selStart + selLen <= currentText.Length &&
                     currentText.Substring(selStart, selLen) == originalText;

        if (intact)
        {
            textBox.SelectRange(selStart, selLen);
            textBox.ReplaceSelection(revised);
            textBox.SelectRange(selStart, revised.Length);
            ShowRevisionAppliedHint(textBox, selStart);
            return;
        }

        var firstIdx  = currentText.IndexOf(originalText, StringComparison.Ordinal);
        var secondIdx = firstIdx < 0 ? -1
            : currentText.IndexOf(originalText, firstIdx + 1, StringComparison.Ordinal);

        if (firstIdx >= 0 && secondIdx < 0)
        {
            textBox.SelectRange(firstIdx, originalText.Length);
            textBox.ReplaceSelection(revised);
            textBox.SelectRange(firstIdx, revised.Length);
            ShowRevisionAppliedHint(textBox, firstIdx);
            return;
        }

        var win = new RevisionResultWindow(revised) { Owner = this };
        WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(win, this);
        win.Show();
    }

    private void ShowRevisionAppliedHint(System.Windows.Controls.TextBox textBox, int insertedAt)
    {
        try
        {
            var rect    = textBox.GetRectFromCharacterIndex(Math.Max(0, insertedAt));
            var visible = IsCharacterIndexVisible(textBox, insertedAt);

            // If not visible, pin hint to top of the text box's visible area instead.
            var localPt  = visible ? new Point(rect.Left, rect.Top) : new Point(4, 4);
            var screenPt = textBox.PointToScreen(localPt);
            var dpi      = System.Windows.Media.VisualTreeHelper.GetDpi(this);

            var hint = new RevisionHintOverlay("✅ AI revision inserted")
            {
                Left  = screenPt.X / dpi.DpiScaleX,
                Top   = screenPt.Y / dpi.DpiScaleY - 30,
                Owner = this
            };
            hint.Show();
        }
        catch { /* hint is cosmetic — swallow positioning errors */ }
    }

    private void ShowRevisionAppliedHint(RichTextBox textBox, int insertedAt)
    {
        try
        {
            var rect    = textBox.GetRectFromOffset(Math.Max(0, insertedAt));
            var visible = IsOffsetVisible(textBox, insertedAt);

            var localPt  = visible ? new Point(rect.Left, rect.Top) : new Point(4, 4);
            var screenPt = textBox.PointToScreen(localPt);
            var dpi      = System.Windows.Media.VisualTreeHelper.GetDpi(this);

            var hint = new RevisionHintOverlay("✅ AI revision inserted")
            {
                Left  = screenPt.X / dpi.DpiScaleX,
                Top   = screenPt.Y / dpi.DpiScaleY - 30,
                Owner = this
            };
            hint.Show();
        }
        catch { }
    }

    private void ShowRevisionWorkingOverlay(Point popupCenter)
        => RevisionWorkingOverlay.ShowAt(popupCenter, this);

    private bool TryShowRevisePopupForFocusedTextBox()
    {
        if (Keyboard.FocusedElement is RichTextBox rtb)
        {
            if (rtb.GetSelectionLength() <= 0) return false;
            var filePath = ReferenceEquals(rtb, DocSourceTextBox) ? (_currentDocPath ?? "") : "";
            ShowDocRevisePopup(rtb, filePath);
            return true;
        }
        if (Keyboard.FocusedElement is System.Windows.Controls.TextBox tb)
        {
            if (tb.SelectionLength <= 0) return false;
            ShowDocRevisePopup(tb, "");
            return true;
        }
        return false;
    }

    /// <summary>
    /// Directly runs the Revise with AI operation on the focused text box's current selection
    /// using <paramref name="instructions"/>, bypassing the popup. Shows the working overlay
    /// and highlight adorner, then applies the result when the AI responds.
    /// </summary>
    private bool TryDirectReviseForFocusedTextBox(string instructions)
    {
        if (string.IsNullOrWhiteSpace(instructions)) return false;

        if (Keyboard.FocusedElement is RichTextBox rtb)
        {
            if (rtb.GetSelectionLength() <= 0) return false;
            var filePath = ReferenceEquals(rtb, DocSourceTextBox) ? (_currentDocPath ?? "") : "";
            DirectReviseRichTextBox(rtb, filePath, instructions);
            return true;
        }
        if (Keyboard.FocusedElement is System.Windows.Controls.TextBox tb)
        {
            if (tb.SelectionLength <= 0) return false;
            DirectReviseTextBox(tb, "", instructions);
            return true;
        }
        return false;
    }

    private void DirectReviseTextBox(
        System.Windows.Controls.TextBox textBox,
        string filePath,
        string instructions)
    {
        var selStart      = textBox.SelectionStart;
        var selLen        = textBox.SelectionLength;
        if (selLen <= 0) return;

        var originalText  = textBox.Text.Substring(selStart, selLen);
        var fullText      = textBox.Text;
        var capturedStart = selStart;
        var capturedLen   = selLen;

        ShowRevisionWorkingOverlay(new Point(Left + Width / 2, Top + Height / 2));

        var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(120));
        _ = Task.Run(async () =>
        {
            try
            {
                var cwd = string.IsNullOrEmpty(filePath)
                    ? string.Empty
                    : System.IO.Path.GetDirectoryName(filePath) ?? string.Empty;
                var revised = await _bridge.RunDocRevisionAsync(
                    instructions, originalText, fullText, cwd, cts.Token);
                if (!string.IsNullOrWhiteSpace(revised))
                    Dispatcher.Invoke(() => ApplyDocRevision(textBox, capturedStart, capturedLen, originalText, revised));
            }
            catch { /* swallow — user may have navigated away */ }
            finally
            {
                cts.Dispose();
            }
        });
    }

    private void DirectReviseRichTextBox(
        RichTextBox textBox,
        string filePath,
        string instructions)
    {
        MarkdownRevisionExecutor.DirectRevise(
            textBox, filePath, instructions,
            (instr, sel, doc, cwd, ct) => _bridge.RunDocRevisionAsync(instr, sel, doc, cwd, ct),
            this);
    }

    private static bool IsCharacterIndexVisible(System.Windows.Controls.TextBox textBox, int charIndex)
    {
        try
        {
            var rect = textBox.GetRectFromCharacterIndex(charIndex);
            var sv   = FindVisualChild<ScrollViewer>(textBox);
            if (sv is null) return true;
            return rect.Top    < sv.VerticalOffset + sv.ViewportHeight &&
                   rect.Bottom > sv.VerticalOffset;
        }
        catch { return false; }
    }

    private static bool IsOffsetVisible(RichTextBox textBox, int offset)
    {
        try
        {
            var rect = textBox.GetRectFromOffset(offset);
            var sv   = FindVisualChild<ScrollViewer>(textBox);
            if (sv is null) return true;
            return rect.Top    < sv.VerticalOffset + sv.ViewportHeight &&
                   rect.Bottom > sv.VerticalOffset;
        }
        catch { return false; }
    }

    private void DocSourceTextBox_PasteImageFromClipboard()
    {
        if (DocSourceTextBox is null || !Clipboard.ContainsImage()) return;
        if (string.IsNullOrEmpty(_currentDocPath)) return;

        var clipImg = Clipboard.GetImage()!;
        var currentDocPath = _currentDocPath;
        var editor = new ClipboardImageEditorWindow(this, clipImg);
        editor.ImageAccepted += image =>
        {
            var docName = Path.GetFileNameWithoutExtension(currentDocPath);
            var timestamp = DateTime.Now.ToString("yyyyMMddHHmmss");
            var fileName = $"{docName}-{timestamp}.png";
            var docDir = Path.GetDirectoryName(currentDocPath)!;
            var imagesDir = Path.Combine(docDir, "images");
            Directory.CreateDirectory(imagesDir);
            var fullImagePath = Path.Combine(imagesDir, fileName);

            var encoder = new PngBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(image));
            using (var stream = File.OpenWrite(fullImagePath))
                encoder.Save(stream);

            var caretIndex = DocSourceTextBox?.GetCaretOffset() ?? 0;
            var markdown = $"![{docName} screenshot](images/{fileName})";
            DocSourceTextBox?.SetPlainText(DocSourceTextBox.GetPlainText().Insert(caretIndex, markdown));
            DocSourceTextBox?.SetCaretOffset(caretIndex + markdown.Length);
        };
        OpenEditorModeless(editor);
    }

    // ── Feature 2: Find-in-source bar ───────────────────────────────────────────
    private void ShowDocSourceFindBar()
    {
        if (DocSourcePanel is null || DocSourceTextBox is null) return;

        if (_docSourceFindBar is not null)
        {
            _docSourceFindTextBox?.Focus();
            _docSourceFindTextBox?.SelectAll();
            return;
        }

        // Ensure overlay canvas (and Grid wrapper) exist
        var overlayCanvas = EnsureDocSourceOverlayCanvas();
        var grid = DocSourcePanel.Child as Grid;
        if (grid is null) return;

        // Create a separate Canvas for find match highlights
        _docSourceFindOverlay = new Canvas
        {
            IsHitTestVisible = false,
            Background = Brushes.Transparent
        };
        grid.Children.Add(_docSourceFindOverlay);

        // Re-render highlights whenever the user scrolls the source editor
        var sv = FindVisualChild<ScrollViewer>(DocSourceTextBox);
        if (sv is not null)
            sv.ScrollChanged += DocSourceFind_ScrollChanged;

        // Create find bar
        var findPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4) };

        _docSourceFindTextBox = new TextBox
        {
            Width = 150,
            Padding = new Thickness(4),
            Margin = new Thickness(0, 0, 6, 0)
        };
        _docSourceFindTextBox.TextChanged += DocSourceFind_TextChanged;
        _docSourceFindTextBox.PreviewKeyDown += DocSourceFind_KeyDown;

        var prevBtn = new Button
        {
            Content = "▲",
            Width = 24,
            Padding = new Thickness(0),
            Margin = new Thickness(0, 0, 2, 0)
        };
        prevBtn.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        prevBtn.Click += (s, e) => DocSourceFind_NavigatePrevious();

        var nextBtn = new Button
        {
            Content = "▼",
            Width = 24,
            Padding = new Thickness(0),
            Margin = new Thickness(0, 0, 6, 0)
        };
        nextBtn.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        nextBtn.Click += (s, e) => DocSourceFind_NavigateNext();

        _docSourceFindMatchCount = new TextBlock
        {
            VerticalAlignment = VerticalAlignment.Center,
            Margin = new Thickness(0, 0, 8, 0),
            FontSize = (double)Application.Current.Resources["FontSizeSmall"]
        };
        _docSourceFindMatchCount.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");

        var closeBtn = new Button
        {
            Content = "✕",
            Width = 24,
            Padding = new Thickness(0)
        };
        closeBtn.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        closeBtn.Click += (s, e) => HideDocSourceFindBar();

        findPanel.Children.Add(_docSourceFindTextBox);
        findPanel.Children.Add(prevBtn);
        findPanel.Children.Add(nextBtn);
        findPanel.Children.Add(_docSourceFindMatchCount);
        findPanel.Children.Add(closeBtn);

        _docSourceFindBar = new Border
        {
            Child = findPanel,
            CornerRadius = new CornerRadius(6),
            Padding = new Thickness(8, 6, 8, 6),
            HorizontalAlignment = HorizontalAlignment.Right,
            VerticalAlignment = VerticalAlignment.Top,
            Margin = new Thickness(0, 10, 10, 0)
        };
        _docSourceFindBar.SetResourceReference(Border.BackgroundProperty, "PopupSurface");
        _docSourceFindBar.SetResourceReference(Border.BorderBrushProperty, "PanelBorder");
        _docSourceFindBar.BorderThickness = new Thickness(1);

        grid.Children.Add(_docSourceFindBar);

        _docSourceFindTextBox.Focus();
    }

    private void HideDocSourceFindBar()
    {
        if (_docSourceFindBar is null) return;

        // Unsubscribe scroll listener
        if (DocSourceTextBox is not null)
        {
            var sv = FindVisualChild<ScrollViewer>(DocSourceTextBox);
            if (sv is not null)
                sv.ScrollChanged -= DocSourceFind_ScrollChanged;
        }

        var grid = DocSourcePanel?.Child as Grid;
        if (grid is not null)
        {
            grid.Children.Remove(_docSourceFindBar);
            if (_docSourceFindOverlay is not null)
                grid.Children.Remove(_docSourceFindOverlay);
        }

        _docSourceFindBar = null;
        _docSourceFindTextBox = null;
        _docSourceFindMatchCount = null;
        _docSourceFindOverlay = null;
        _docSourceFindMatches.Clear();
        _docSourceFindCurrentIndex = -1;
        DocSourceTextBox?.Focus();
    }

    private void DocSourceFind_ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        DocSourceFind_RenderHighlights();
    }

    private void DocSourceFind_TextChanged(object sender, TextChangedEventArgs e)
    {
        _docSourceFindDebounceTimer?.Stop();
        _docSourceFindDebounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
        _docSourceFindDebounceTimer.Tick += (s, args) =>
        {
            _docSourceFindDebounceTimer.Stop();
            DocSourceFind_UpdateMatches();
        };
        _docSourceFindDebounceTimer.Start();
    }

    private void DocSourceFind_KeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Escape)
        {
            HideDocSourceFindBar();
            e.Handled = true;
        }
        else if (e.Key == Key.Enter || e.Key == Key.F3)
        {
            if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
                DocSourceFind_NavigatePrevious();
            else
                DocSourceFind_NavigateNext();
            e.Handled = true;
        }
    }

    private void DocSourceFind_UpdateMatches()
    {
        if (DocSourceTextBox is null || _docSourceFindTextBox is null || _docSourceFindOverlay is null) return;

        _docSourceFindMatches.Clear();
        _docSourceFindCurrentIndex = -1;
        _docSourceFindOverlay.Children.Clear();

        var searchText = _docSourceFindTextBox.Text;
        if (string.IsNullOrEmpty(searchText))
        {
            if (_docSourceFindMatchCount is not null)
                _docSourceFindMatchCount.Text = string.Empty;
            return;
        }

        var text = DocSourceTextBox.GetPlainText();
        var index = 0;
        while ((index = text.IndexOf(searchText, index, StringComparison.OrdinalIgnoreCase)) >= 0)
        {
            _docSourceFindMatches.Add(index);
            index += searchText.Length;
        }

        if (_docSourceFindMatches.Count > 0)
            _docSourceFindCurrentIndex = 0;

        DocSourceFind_RenderHighlights();
        DocSourceFind_UpdateMatchCountDisplay();

        if (_docSourceFindCurrentIndex >= 0)
            DocSourceFind_ScrollToCurrentMatch();
    }

    private void DocSourceFind_RenderHighlights()
    {
        if (DocSourceTextBox is null || _docSourceFindOverlay is null || _docSourceFindTextBox is null) return;

        _docSourceFindOverlay.Children.Clear();

        var isDark = AgentStatusCard.IsDarkTheme;
        var matchBg = isDark ? Color.FromArgb(200, 74, 62, 16) : Color.FromArgb(200, 200, 224, 255);
        var currentBg = isDark ? Color.FromArgb(220, 200, 160, 0) : Color.FromArgb(220, 32, 96, 192);
        var searchLen = _docSourceFindTextBox.Text.Length;
        if (searchLen == 0) return;

        // Draw match rectangles.
        // GetRectFromCharacterIndex returns coords in TextBox local space (accounting for
        // current scroll). TranslatePoint converts them to the overlay Canvas coordinate space.
        for (int i = 0; i < _docSourceFindMatches.Count; i++)
        {
            var pos = _docSourceFindMatches[i];
            var startRect = DocSourceTextBox.GetRectFromOffset(pos);
            if (startRect == Rect.Empty) continue; // off-screen — skip, ScrollChanged will re-render

            // Use start+end rects for accurate width (handles variable-width fonts too)
            var endPos = Math.Min(pos + searchLen, DocSourceTextBox.GetTextLength());
            var endRect = DocSourceTextBox.GetRectFromOffset(endPos);
            double highlightWidth = (endRect != Rect.Empty && endRect.Left >= startRect.Left)
                ? Math.Max(2, endRect.Left - startRect.Left)
                : Math.Max(2, searchLen * (startRect.Width > 0 ? startRect.Width : 8));

            var canvasOrigin = DocSourceTextBox.TranslatePoint(
                new Point(startRect.Left, startRect.Top), _docSourceFindOverlay);

            var highlight = new Shapes.Rectangle
            {
                Width = highlightWidth,
                Height = Math.Max(2, startRect.Height),
                Fill = new SolidColorBrush(i == _docSourceFindCurrentIndex ? currentBg : matchBg),
                Opacity = 0.55
            };

            Canvas.SetLeft(highlight, canvasOrigin.X);
            Canvas.SetTop(highlight, canvasOrigin.Y);
            _docSourceFindOverlay.Children.Add(highlight);
        }

        // Draw scrollbar tick marks proportional to the total text length
        if (DocSourceTextBox.GetTextLength() > 0 && _docSourceFindMatches.Count > 0)
        {
            var sv = FindVisualChild<ScrollViewer>(DocSourceTextBox);
            var scrollBar = sv is not null ? FindVisualChild<ScrollBar>(sv) : null;
            double trackHeight = scrollBar?.ActualHeight ?? DocSourceTextBox.ActualHeight;

            foreach (var pos in _docSourceFindMatches)
            {
                var fraction = (double)pos / DocSourceTextBox.GetTextLength();
                var tick = new Shapes.Rectangle
                {
                    Width = 4,
                    Height = 3,
                    Fill = new SolidColorBrush(matchBg)
                };
                Canvas.SetRight(tick, 0);
                Canvas.SetTop(tick, fraction * trackHeight);
                _docSourceFindOverlay.Children.Add(tick);
            }
        }
    }

    private void DocSourceFind_UpdateMatchCountDisplay()
    {
        if (_docSourceFindMatchCount is null) return;

        if (_docSourceFindMatches.Count == 0)
            _docSourceFindMatchCount.Text = "No matches";
        else
            _docSourceFindMatchCount.Text = $"{_docSourceFindCurrentIndex + 1} / {_docSourceFindMatches.Count}";
    }

    private void DocSourceFind_NavigateNext()
    {
        if (_docSourceFindMatches.Count == 0) return;

        _docSourceFindCurrentIndex = (_docSourceFindCurrentIndex + 1) % _docSourceFindMatches.Count;
        DocSourceFind_RenderHighlights();
        DocSourceFind_UpdateMatchCountDisplay();
        DocSourceFind_ScrollToCurrentMatch();
    }

    private void DocSourceFind_NavigatePrevious()
    {
        if (_docSourceFindMatches.Count == 0) return;

        _docSourceFindCurrentIndex--;
        if (_docSourceFindCurrentIndex < 0)
            _docSourceFindCurrentIndex = _docSourceFindMatches.Count - 1;

        DocSourceFind_RenderHighlights();
        DocSourceFind_UpdateMatchCountDisplay();
        DocSourceFind_ScrollToCurrentMatch();
    }

    private void DocSourceFind_ScrollToCurrentMatch()
    {
        if (DocSourceTextBox is null || _docSourceFindCurrentIndex < 0 || _docSourceFindCurrentIndex >= _docSourceFindMatches.Count) return;

        var pos = _docSourceFindMatches[_docSourceFindCurrentIndex];

        // Scroll vertically to the matching line
        DocSourceTextBox.ScrollToOffset(pos);

        // After the vertical scroll settles, handle horizontal scroll, re-render highlights
        // (positions changed due to scroll), then return focus to the find box.
        _ = Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
        {
            var sv = FindVisualChild<ScrollViewer>(DocSourceTextBox);
            if (sv is not null && DocSourceTextBox is not null && _docSourceFindTextBox is not null)
            {
                var matchRect = DocSourceTextBox.GetRectFromOffset(pos);
                if (matchRect != Rect.Empty)
                {
                    // Bring the match into horizontal view with a small margin
                    const double margin = 24;
                    if (matchRect.Left < 0)
                        sv.ScrollToHorizontalOffset(Math.Max(0, sv.HorizontalOffset + matchRect.Left - margin));
                    else if (matchRect.Right > DocSourceTextBox.ActualWidth)
                        sv.ScrollToHorizontalOffset(sv.HorizontalOffset + matchRect.Right - DocSourceTextBox.ActualWidth + margin);
                }
            }

            DocSourceFind_RenderHighlights();
            _docSourceFindTextBox?.Focus();
        });
    }

    private static T? FindVisualChild<T>(DependencyObject parent) where T : DependencyObject
    {
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child is T typedChild)
                return typedChild;
            var result = FindVisualChild<T>(child);
            if (result is not null)
                return result;
        }
        return null;
    }

    private static bool IsPrintableKey(Key key) =>
        (key >= Key.A && key <= Key.Z) ||
        (key >= Key.D0 && key <= Key.D9) ||
        (key >= Key.NumPad0 && key <= Key.NumPad9) ||
        key is Key.OemTilde or Key.OemMinus or Key.OemPlus or Key.OemOpenBrackets
            or Key.OemCloseBrackets or Key.OemPipe or Key.OemSemicolon or Key.OemQuotes
            or Key.OemComma or Key.OemPeriod or Key.OemQuestion or Key.Space;

    private static char? KeyToChar(Key key, bool shift)
    {
        if (key >= Key.A && key <= Key.Z)
            return shift ? (char)('A' + (key - Key.A)) : (char)('a' + (key - Key.A));
        if (key >= Key.D0 && key <= Key.D9)
        {
            var digits = "0123456789";
            var shifted = ")!@#$%^&*(";
            int i = key - Key.D0;
            return shift ? shifted[i] : digits[i];
        }
        if (key >= Key.NumPad0 && key <= Key.NumPad9)
            return (char)('0' + (key - Key.NumPad0));
        return key switch
        {
            Key.Space => ' ',
            Key.OemTilde => shift ? '~' : '`',
            Key.OemMinus => shift ? '_' : '-',
            Key.OemPlus => shift ? '+' : '=',
            Key.OemOpenBrackets => shift ? '{' : '[',
            Key.OemCloseBrackets => shift ? '}' : ']',
            Key.OemPipe => shift ? '|' : '\\',
            Key.OemSemicolon => shift ? ':' : ';',
            Key.OemQuotes => shift ? '"' : '\'',
            Key.OemComma => shift ? '<' : ',',
            Key.OemPeriod => shift ? '>' : '.',
            Key.OemQuestion => shift ? '?' : '/',
            _ => null
        };
    }

    private void ApplyViewMode()
    {
        if (NormalViewMenuItem is not null)
            NormalViewMenuItem.IsChecked = !_transcriptFullScreenEnabled && !_agentsPanelFocusModeEnabled;

        if (FullScreenTranscriptMenuItem is not null)
            FullScreenTranscriptMenuItem.IsChecked = _transcriptFullScreenEnabled;

        if (FocusModeMenuItem is not null)
            FocusModeMenuItem.IsChecked = _agentsPanelFocusModeEnabled;

        if (ViewDocumentationMenuItem is not null)
            ViewDocumentationMenuItem.IsChecked = _documentationModeEnabled;

        if (StatusPanelBorder is not null)
            StatusPanelBorder.Visibility = (_transcriptFullScreenEnabled || _agentsPanelFocusModeEnabled)
                ? Visibility.Collapsed : Visibility.Visible;

        if (PromptBorder is not null)
            PromptBorder.Visibility = (_transcriptFullScreenEnabled && !_fullScreenPromptVisible)
                ? Visibility.Collapsed
                : Visibility.Visible;

        // In fullscreen both status panel and prompt are hidden — zero out top/bottom margin so
        // the transcript sits flush against the outer 14 px MainGrid margin on all four sides.
        // In focus mode only the status panel is hidden — keep the bottom margin for the prompt,
        // but zero the top since there is no longer a neighbour above the transcript.
        if (TranscriptPanelsGrid is not null)
            TranscriptPanelsGrid.Margin = _transcriptFullScreenEnabled
                ? new Thickness(0)
                : _agentsPanelFocusModeEnabled
                    ? new Thickness(0, 0, 0, 14)
                    : new Thickness(0, 14, 0, 14);

        // Documentation mode: show docs panel whenever documentation mode is active
        var docsVisible = _documentationModeEnabled;

        if (DocsSplitter is not null)
            DocsSplitter.Visibility = docsVisible ? Visibility.Visible : Visibility.Collapsed;

        if (DocsSplitterColumn is not null)
            DocsSplitterColumn.Width = docsVisible ? new GridLength(8) : new GridLength(0);

        if (DocsPanel is not null)
            DocsPanel.Visibility = docsVisible ? Visibility.Visible : Visibility.Collapsed;

        if (DocsPanelColumn is not null)
        {
            if (docsVisible)
            {
                // Restore saved width or default to 600
                var width = _docsPanelState?.PanelWidth ?? _settingsSnapshot.DocsPanelWidth ?? 600;
                DocsPanelColumn.Width = new GridLength(width);
            }
            else
            {
                DocsPanelColumn.Width = new GridLength(0);
            }
        }

        UpdateAgentCardVisibility();
    }

    private void RemoveTemporaryAgents()
    {
        var removableThreads = _agentThreadRegistry.ThreadOrder
            .Where(thread => !AgentThreadRegistry.HasRosterBackedIdentity(thread) && !thread.IsPlaceholderThread && !_backgroundTaskPresenter.IsThreadActiveForDisplay(thread))
            .ToArray();

        if (removableThreads.Length == 0)
        {
            MessageBox.Show(
                this,
                "No inactive temporary agents are available to remove.",
                "Cleanup",
                MessageBoxButton.OK,
                MessageBoxImage.Information);
            return;
        }

        var removedSelectedThread = _selectedTranscriptThread is not null &&
                                    removableThreads.Any(thread => ReferenceEquals(thread, _selectedTranscriptThread));

        foreach (var thread in removableThreads)
            _backgroundTaskPresenter.RemovePromotionEntry(thread.ThreadId);

        RemovePrimaryAgentTranscriptHosts(removableThreads);
        _agentThreadRegistry.RemoveThreads(removableThreads);

        if (removedSelectedThread)
            SelectTranscriptThread(CoordinatorThread);

        SyncAgentCardsWithThreads();
        _conversationManager.PersistConversationState(_conversationManager.ConversationState with
        {
            SessionId = _conversationManager.CurrentSessionId,
            PromptDraft = PromptTextBox.Text,
            PromptHistory = _conversationManager.PromptHistory.ToArray(),
            Threads = _conversationManager.BuildPersistedAgentThreadRecords(includeCurrentTurns: false)
        });
    }

    private void UpdateStatusTitle()
    {
        var version = _squadCliAdapter.SquadVersion;

        if (SquadVersionTextBlock is not null)
        {
            SquadVersionTextBlock.Text = string.IsNullOrWhiteSpace(version) ? "Squad" : $"Squad v{version}";
        }

        if (SquadDashVersionTextBlock is not null)
        {
            SquadDashVersionTextBlock.Text = $"SquadDash v{AppVersion.Full}";
        }
    }

    private async Task OpenWorkspace(
        string folderPath,
        bool rememberFolder,
        bool closeWindowIfActivatedExisting = false,
        bool showBlockedDialog = true)
    {
        if (!Directory.Exists(folderPath))
        {
            MessageBox.Show(
                $"The folder does not exist:\n{folderPath}",
                "Folder Not Found",
                MessageBoxButton.OK,
                MessageBoxImage.Warning);
            return;
        }

        var targetWorkspace = SessionWorkspace.Create(folderPath);
        if (_currentWorkspace is not null &&
            string.Equals(_currentWorkspace.FolderPath, targetWorkspace.FolderPath, StringComparison.OrdinalIgnoreCase))
        {
            if (rememberFolder)
                RememberWorkspaceFolder(targetWorkspace.FolderPath);

            ActivateOwnedWindow();
            return;
        }

        var workspaceLease = TakeStartupWorkspaceLease(targetWorkspace.FolderPath);
        if (workspaceLease is null &&
            !WorkspaceStartupRoutingPolicy.ShouldBypassSingleInstanceRouting(_screenshotRefreshOptions))
        {
            var decision = _workspaceOpenCoordinator.ReserveOrActivate(
                _workspacePaths.ApplicationRoot,
                targetWorkspace.FolderPath,
                Environment.ProcessId,
                _processStartedAtUtcTicks,
                _workspaceOwnershipLease);

            switch (decision.Disposition)
            {
                case WorkspaceOpenDisposition.AlreadyOpenHere:
                    if (rememberFolder)
                        RememberWorkspaceFolder(targetWorkspace.FolderPath);

                    ActivateOwnedWindow();
                    return;

                case WorkspaceOpenDisposition.ActivatedExisting:
                    if (rememberFolder)
                        RememberWorkspaceFolder(targetWorkspace.FolderPath);

                    SquadDashTrace.Write(
                        "Workspace",
                        $"Activated an existing SquadDash instance for workspace={targetWorkspace.FolderPath}.");
                    if (closeWindowIfActivatedExisting && _currentWorkspace is null)
                        _ = Dispatcher.BeginInvoke(Close);

                    return;

                case WorkspaceOpenDisposition.Blocked:
                    SquadDashTrace.Write(
                        "Workspace",
                        $"Workspace open was blocked because another instance already owns {targetWorkspace.FolderPath}.");

                    if (showBlockedDialog)
                    {
                        MessageBox.Show(
                            this,
                            $"That workspace is already open in another SquadDash window:{Environment.NewLine}{targetWorkspace.FolderPath}",
                            "Workspace Already Open",
                            MessageBoxButton.OK,
                            MessageBoxImage.Information);
                    }

                    if (closeWindowIfActivatedExisting && _currentWorkspace is null)
                        _ = Dispatcher.BeginInvoke(Close);

                    return;

                case WorkspaceOpenDisposition.OpenHere:
                    workspaceLease = decision.Lease;
                    break;
            }
        }

        _conversationManager.SaveWorkspaceInputState();

        var openWsSw = Stopwatch.StartNew();

        var previousLease = _workspaceOwnershipLease;
        _workspaceOwnershipLease = workspaceLease;
        _startupWorkspaceLease = null;

        _currentWorkspace = targetWorkspace;
        DeveloperMenuItem.Visibility = File.Exists(Path.Combine(targetWorkspace.FolderPath, "squad-dash.slnx"))
            ? Visibility.Visible : Visibility.Collapsed;
        BuildAgentSuggestions();
        _currentSolutionPath = _currentWorkspace.SolutionPath;
        _currentSolutionName = _currentWorkspace.SolutionName;
        _workspaceGitHubUrl = TryResolveGitHubUrl(_currentWorkspace.FolderPath);
        ViewPagesButton.Visibility = _workspaceGitHubUrl is not null ? Visibility.Visible : Visibility.Collapsed;

        var workspaceStateDir = _conversationManager.ConversationStore.GetWorkspaceStateDirectory(_currentWorkspace.FolderPath);
        SquadDashTrace.SetWorkspace(workspaceStateDir);
        _approvalStore = new CommitApprovalStore(workspaceStateDir);
        _approvalItems = _approvalStore.Load();
        _approvalPanel?.ReplaceAllItems(_approvalItems);

        _notesStore = new NotesStore(workspaceStateDir);
        _noteItems  = _notesStore.LoadAll();
        _notesPanel?.Refresh(_noteItems);

        ClearRuntimeIssue();

        var repairSw = Stopwatch.StartNew();
        SquadScribeWorkspaceRepairService.Repair(_currentWorkspace.FolderPath);
        repairSw.Stop();
        SquadDashTrace.Write(TraceCategory.Performance, $"WORKSPACE_REPAIR: {repairSw.ElapsedMilliseconds}ms folder={_currentWorkspace.FolderPath}");

        RefreshInstallationState();
        RefreshDeveloperRuntimeIssuePreview();
        SquadInstallerService.EnsureSquadDashUniverseFiles(_currentWorkspace.FolderPath);
        ConfigureTeamFileWatcher();

        if (rememberFolder)
            RememberWorkspaceFolder(_currentWorkspace.FolderPath);

        ApplyWorkspaceTint(_currentWorkspace.FolderPath);

        ClearSessionView();
        RefreshAgentCards();
        // Suppress per-turn scroll operations during history load; EndLoad() will issue
        // exactly one scroll-to-bottom once all stored turns have been appended.
        _coordinatorScrollController.BeginLoad();

        // If a shutdown timestamp was recorded for this workspace, register a session-gap
        // boundary BEFORE LoadWorkspaceConversationAsync so it is injected into the turn
        // list before rendering starts and is saved as part of the conversation history.
        var shutdownTimesCount = _settingsSnapshot.WorkspaceShutdownTimes?.Count ?? -1;
        var lookupKey = Path.GetFullPath(_currentWorkspace?.FolderPath ?? string.Empty)
            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
        SquadDashTrace.Write(TraceCategory.UI, $"SessionGap: WorkspaceShutdownTimes count={shutdownTimesCount} lookupKey={lookupKey}");
        if (_currentWorkspace is not null &&
            _settingsSnapshot.WorkspaceShutdownTimes is { } shutdownTimes &&
            shutdownTimes.TryGetValue(
                lookupKey,
                out var shutdownTime))
        {
            var offlineDuration = DateTimeOffset.UtcNow - shutdownTime;
            SquadDashTrace.Write(TraceCategory.UI, $"SessionGap: MATCH found shutdownTime={shutdownTime:O} offlineDuration={offlineDuration.TotalSeconds:F1}s");
            _conversationManager.PrependSessionBoundary(shutdownTime, offlineDuration);
            _settingsSnapshot = _settingsStore.ClearWorkspaceShutdownTime(_currentWorkspace.FolderPath);
        }
        else
        {
            SquadDashTrace.Write(TraceCategory.UI, "SessionGap: no shutdown time found — stripe skipped.");
        }

        SquadDashTrace.Write(TraceCategory.Performance, $"LOAD_CONVERSATION_START: folder={_currentWorkspace?.FolderPath}");
        var loadConvSw = Stopwatch.StartNew();
        await _conversationManager.LoadWorkspaceConversationAsync();
        loadConvSw.Stop();
        SquadDashTrace.Write(TraceCategory.Performance, $"LOAD_CONVERSATION_END: {loadConvSw.ElapsedMilliseconds}ms");
        _ = Dispatcher.BeginInvoke(
            new Action(() => _backgroundTaskPresenter.PromoteRestoredBackgroundAgentReports("workspace-load")),
            System.Windows.Threading.DispatcherPriority.ApplicationIdle);

        // Prune agent reports older than 2 weeks on each workspace load.
        var reportStateDir = _conversationManager.ConversationStore.GetWorkspaceStateDirectory(_currentWorkspace?.FolderPath ?? string.Empty);
        var reportsDir = AgentReportStore.GetReportsDir(reportStateDir);
        AgentReportStore.PruneOld(reportsDir);
        // Render buttons for any .md files not referenced by a persisted turn (e.g. app crashed mid-turn).
        RenderOrphanedAgentReports(reportsDir);

        // Fire-and-forget: prune expired pasted images for this workspace.
        _ = _pastedImageStore.PruneAsync(_currentWorkspace?.FolderPath ?? string.Empty);

        // Restore loop-queued-to-dequeue state from previous session.
        _loopQueued = _conversationManager.ConversationState.LoopQueuedToDequeue == true;

        // Restore queued prompts saved before last shutdown.
        var savedEntries = _conversationManager.ConversationState.QueuedPromptEntries;
        var savedLegacy = _conversationManager.ConversationState.QueuedPrompts;
        var savedQueuePaused = _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath).QueuePaused == true;
        // Restore _queueManuallyPaused before SyncQueuePanel() so it doesn't overwrite QueuePaused=true
        // in the settings store. Without this, the UI calls SetQueuePaused(false) which resets the
        // persisted value, causing RestoreDocsPanelState() to miss the paused state.
        if (savedQueuePaused)
            _queueManuallyPaused = true;
        if (savedEntries is { Count: > 0 })
        {
            _promptQueueSeq = 0;
            // Read held/active state BEFORE SyncQueuePanel(), which calls UpdateQueuedPromptsState
            // and overwrites QueueRightmostHeld/QueueActiveTabIndex with the current (null) tab state.
            bool wasHeld = _conversationManager.ConversationState.QueueRightmostHeld == true;
            var savedActiveTabIndex = _conversationManager.ConversationState.QueueActiveTabIndex;
            foreach (var entry in savedEntries)
            {
                _promptQueue.Enqueue(entry.Text, ++_promptQueueSeq, entry.IsDictated, isSystemInjected: entry.IsSystemInjected);
                var restoredItem = _promptQueue.Items[^1];
                if (entry.IsSimEntry)
                {
                    restoredItem.IsSimEntry      = true;
                    restoredItem.SimResponse     = entry.SimResponse;
                    restoredItem.SimDelaySeconds = entry.SimDelaySeconds;
                }
                if (entry.Attachments is { Count: > 0 })
                {
                    var newId = _promptQueue.Items[^1].Id;
                    _followUpAttachments[newId] = entry.Attachments
                        .Where(a => !string.IsNullOrEmpty(a.Description))
                        .Select(a => new FollowUpAttachment(
                            a.CommitSha!,
                            a.Description!,
                            a.OriginalPrompt,
                            a.TranscriptQuote,
                            a.ContentBlock,
                            a.ImagePath,
                            a.ImageSubmittedAt is not null && DateTime.TryParse(a.ImageSubmittedAt, out var dt2) ? dt2 : null))
                        .ToList();
                }
            }
            SyncQueuePanel();

            // Restore active tab selection before held/drain decision so label logic sees correct state.
            if (savedActiveTabIndex.HasValue && savedActiveTabIndex.Value < _promptQueue.Items.Count)
            {
                var restoredTabId = _promptQueue.Items[savedActiveTabIndex.Value].Id;
                _ = Dispatcher.InvokeAsync(() => OnQueueTabClicked(restoredTabId),
                    System.Windows.Threading.DispatcherPriority.Background);
            }

            SquadDashTrace.Write("Queue", $"Restore(entries): count={_promptQueue.Count} changedAt={QueueLastChangedForTrace()} wasHeld={wasHeld} shiftHeld={_startupShiftHeld} items={DescribeQueueForTrace(_promptQueue.Items)}");
            if ((wasHeld || _startupShiftHeld) && _promptQueue.Count > 0)
            {
                // Restore the rightmost-tab hold (or Shift-hold on startup): select the first
                // item's tab so the user must explicitly Send or switch away before it dispatches.
                _ = Dispatcher.InvokeAsync(() => OnQueueTabClicked(_promptQueue.Items[0].Id),
                    System.Windows.Threading.DispatcherPriority.Background);
                // OnQueueTabClicked → SyncQueuePanel renders "Queue is draining (active tab pausing
                // dispatch)". If the queue was ALSO manually paused at shutdown, re-apply the pause
                // styling at a lower priority so it wins over the tab-derived label.
                if (savedQueuePaused)
                    _ = Dispatcher.InvokeAsync(() => SetQueuePaused(true),
                        System.Windows.Threading.DispatcherPriority.ContextIdle);
            }
            else
            {
                if (savedQueuePaused)
                    SquadDashTrace.Write("Queue", $"Restore: skipping drain — queue was paused at shutdown");
                else
                    _ = Dispatcher.InvokeAsync(() => _ = DrainQueueIfNeededAsync(),
                        System.Windows.Threading.DispatcherPriority.Background);
            }

            // If the loop was paused to dequeue at last shutdown, auto-resume once the queue drains.
            // If there are no queue items to drain, resume immediately.
            // Suppressed when Shift is held on startup — loop stays paused until manually started.
            if (_loopQueued)
            {
                if (_startupShiftHeld)
                {
                    AppendLoopOutputLine("⏸ Loop paused — Shift held on startup. Press Start Loop to resume.", LoopLifecycleBrush);
                }
                else if (!_promptQueue.HasReadyItems)
                {
                    _ = Dispatcher.InvokeAsync(async () => await MaybeFireQueuedLoopAsync(),
                        System.Windows.Threading.DispatcherPriority.Background);
                    AppendLoopOutputLine("⏸ Loop paused — resuming after queue drains…", LoopLifecycleBrush);
                }
                else
                {
                    // DrainQueueIfNeededAsync already scheduled above will call MaybeFireQueuedLoopAsync when done
                    AppendLoopOutputLine("⏸ Loop paused — resuming after queue drains…", LoopLifecycleBrush);
                }
                SyncLoopPanel();
            }
        }
        else if (savedLegacy is { Count: > 0 })
        {
            _promptQueueSeq = 0;
            // Read held/active state BEFORE SyncQueuePanel() overwrites them.
            bool wasHeld = _conversationManager.ConversationState.QueueRightmostHeld == true;
            var savedActiveTabIndex = _conversationManager.ConversationState.QueueActiveTabIndex;
            foreach (var text in savedLegacy)
                _promptQueue.Enqueue(text, ++_promptQueueSeq);
            SyncQueuePanel();

            // Restore active tab selection before held/drain decision so label logic sees correct state.
            if (savedActiveTabIndex.HasValue && savedActiveTabIndex.Value < _promptQueue.Items.Count)
            {
                var restoredTabId = _promptQueue.Items[savedActiveTabIndex.Value].Id;
                _ = Dispatcher.InvokeAsync(() => OnQueueTabClicked(restoredTabId),
                    System.Windows.Threading.DispatcherPriority.Background);
            }

            SquadDashTrace.Write("Queue", $"Restore(legacy): count={_promptQueue.Count} changedAt={QueueLastChangedForTrace()} wasHeld={wasHeld} shiftHeld={_startupShiftHeld} items={DescribeQueueForTrace(_promptQueue.Items)}");
            if ((wasHeld || _startupShiftHeld) && _promptQueue.Count > 0)
            {
                // Restore the rightmost-tab hold (or Shift-hold on startup): select the first
                // item's tab so the user must explicitly Send or switch away before it dispatches.
                _ = Dispatcher.InvokeAsync(() => OnQueueTabClicked(_promptQueue.Items[0].Id),
                    System.Windows.Threading.DispatcherPriority.Background);
                // Re-apply manual pause label after tab-derived "Queue is draining" label fires.
                if (savedQueuePaused)
                    _ = Dispatcher.InvokeAsync(() => SetQueuePaused(true),
                        System.Windows.Threading.DispatcherPriority.ContextIdle);
            }
            else
            {
                // Auto-dispatch restored queue items once the UI is fully initialised.
                if (savedQueuePaused)
                    SquadDashTrace.Write("Queue", $"Restore: skipping drain — queue was paused at shutdown");
                else
                    _ = Dispatcher.InvokeAsync(() => _ = DrainQueueIfNeededAsync(),
                        System.Windows.Threading.DispatcherPriority.Background);
            }

            // If the loop was paused to dequeue at last shutdown, auto-resume once the queue drains.
            // If there are no queue items to drain, resume immediately.
            // Suppressed when Shift is held on startup — loop stays paused until manually started.
            if (_loopQueued)
            {
                if (_startupShiftHeld)
                {
                    AppendLoopOutputLine("⏸ Loop paused — Shift held on startup. Press Start Loop to resume.", LoopLifecycleBrush);
                }
                else if (!_promptQueue.HasReadyItems)
                {
                    _ = Dispatcher.InvokeAsync(async () => await MaybeFireQueuedLoopAsync(),
                        System.Windows.Threading.DispatcherPriority.Background);
                    AppendLoopOutputLine("⏸ Loop paused — resuming after queue drains…", LoopLifecycleBrush);
                }
                else
                {
                    // DrainQueueIfNeededAsync already scheduled above will call MaybeFireQueuedLoopAsync when done
                    AppendLoopOutputLine("⏸ Loop paused — resuming after queue drains…", LoopLifecycleBrush);
                }
                SyncLoopPanel();
            }
        }
        else if (_loopQueued)
        {
            // No queue items but loop was paused — resume immediately (unless Shift held on startup).
            if (_startupShiftHeld)
            {
                AppendLoopOutputLine("⏸ Loop paused — Shift held on startup. Press Start Loop to resume.", LoopLifecycleBrush);
            }
            else
            {
                _ = Dispatcher.InvokeAsync(async () => await MaybeFireQueuedLoopAsync(),
                    System.Windows.Threading.DispatcherPriority.Background);
                AppendLoopOutputLine("⏸ Loop paused — resuming…", LoopLifecycleBrush);
            }
            SyncLoopPanel();
        }

        // Restore active-draft sim entry if one was saved.
        var savedDraftSimResponse = _conversationManager.ConversationState.ActiveDraftSimResponse;
        if (!string.IsNullOrEmpty(savedDraftSimResponse))
        {
            int delay = _conversationManager.ConversationState.ActiveDraftSimDelaySeconds ?? 0;
            _pec.ActiveDraftSimEntry = (savedDraftSimResponse, delay);
            SquadDashTrace.Write("Queue", $"Restore: active-draft sim entry restored (delay={delay}s)");
        }

        // Restore per-workspace loop settings (override global app settings).
        var savedState = _conversationManager.ConversationState;
        if (savedState.LoopMode is { } savedLoopMode)
            _settingsSnapshot = _settingsStore.SaveLoopMode(savedLoopMode);
        if (savedState.LoopContinuousContext is { } savedContinuous)
            _settingsSnapshot = _settingsStore.SaveLoopContinuousContext(savedContinuous);

        // Auto-resume the loop if it was active when the app last exited.
        // Clear the flag first so a crash-loop can't occur if the loop fails to start.
        // Suppressed when Shift is held on startup.
        if (_settingsSnapshot.LoopActiveOnExit)
        {
            _settingsSnapshot = _settingsStore.SaveLoopActive(false);
            if (_startupShiftHeld)
            {
                AppendLoopOutputLine("⏸ Loop paused — Shift held on startup. Press Start Loop to resume.", LoopLifecycleBrush);
                SyncLoopPanel();
            }
            else
            {
                _ = Dispatcher.InvokeAsync(async () =>
                {
                    if (_lastQuickReplyEntry?.AllowQuickReplies == true)
                    {
                        _loopPausedForQuickReply = true;
                        AppendLoopOutputLine("⏸ Loop paused — pending quick replies require your input. The loop will resume automatically after you respond.", LoopLifecycleBrush);
                        SyncLoopPanel();
                        return;
                    }
                    AppendLoopOutputLine("🔄 Resuming loop from previous session…", LoopLifecycleBrush);
                    await StartLoopImmediateAsync(resumeFromIteration: _settingsSnapshot.LoopLastIteration);
                }, System.Windows.Threading.DispatcherPriority.Background);
            }
        }

        // Auto-resume Remote Access if it was active when the app last exited.
        // Clear the flag first to prevent crash-loops.
        if (_settingsSnapshot.RemoteAccessActiveOnExit)
        {
            _settingsSnapshot = _settingsStore.SaveRemoteAccessActive(false);
            // Optimistically show "Stop" immediately — HandleRcStarted will confirm,
            // HandleRcError will revert if startup fails.
            _remoteAccessActive = true;
            UpdateRemoteAccessMenuHeader();
            _ = Dispatcher.InvokeAsync(async () =>
            {
                if (_currentWorkspace is null) return;
                var savedPort = _settingsSnapshot.RcPersistentPort;
                AppendLine("📡 Resuming Remote Access from previous session…");
                SquadDashTrace.Write("UI", $"RC auto-resume: requesting port={savedPort} (0=OS-assigned)");
                var repo = System.IO.Path.GetFileName(_currentWorkspace.FolderPath);
                var machine = System.Environment.MachineName;
                await _bridge.StartRemoteAsync(
                    repo: repo,
                    branch: "main",
                    machine: machine,
                    squadDir: _currentWorkspace.SquadFolderPath,
                    cwd: _currentWorkspace.FolderPath,
                    port: savedPort,
                    sessionId: _conversationManager.CurrentSessionId,
                    tunnelMode: _settingsSnapshot.TunnelMode,
                    tunnelToken: _settingsSnapshot.TunnelToken,
                    rcToken: _settingsSnapshot.RcPersistentToken).ConfigureAwait(false);
            }, System.Windows.Threading.DispatcherPriority.Background);
        }

        RestoreWorkspaceWindowPlacement();
        _conversationManager.ResetHistoryNavigation();
        UpdateWindowTitle();
        UpdateStatusTitle();
        UpdateLeadAgent("Ready", string.Empty, string.Empty);
        UpdateSessionState("Ready");
        RefreshSidebar();
        UpdateInteractiveControlState();
        ScrollToEndIfAtBottom();
        MaybePromptForUniverseSelection();
        MaybePublishMissingUtilityAgentNotice();
        UpdateRunningInstanceRegistration();

        openWsSw.Stop();
        SquadDashTrace.Write(TraceCategory.Performance, $"OPEN_WORKSPACE_TOTAL: {openWsSw.ElapsedMilliseconds}ms");

        previousLease?.Dispose();
    }

    private void MaybePromptForUniverseSelection()
    {
        if (_currentWorkspace is null ||
            _currentInstallationState?.IsSquadInstalledForActiveDirectory != true)
        {
            return;
        }

        var members = _teamRosterLoader.Load(_currentWorkspace.FolderPath);
        if (!SquadTeamRosterLoader.HasNonUtilityMembers(members))
            _pec.InjectUniverseSelectorTurn();
    }

    private void MaybePublishMissingUtilityAgentNotice()
    {
        if (_currentWorkspace is null ||
            _currentInstallationState?.IsSquadInstalledForActiveDirectory != true)
        {
            _lastMissingUtilityAgentNoticeKey = null;
            return;
        }

        var members = _teamRosterLoader.Load(_currentWorkspace.FolderPath);
        if (!SquadTeamRosterLoader.HasNonUtilityMembers(members))
        {
            _lastMissingUtilityAgentNoticeKey = null;
            return;
        }

        var missingUtilities = SquadTeamRosterLoader.GetMissingUtilityAgentNames(_currentWorkspace.FolderPath);
        if (missingUtilities.Count == 0)
        {
            _lastMissingUtilityAgentNoticeKey = null;
            return;
        }

        var noticeKey = _currentWorkspace.FolderPath + "|" + string.Join("|", missingUtilities);
        if (string.Equals(_lastMissingUtilityAgentNoticeKey, noticeKey, StringComparison.OrdinalIgnoreCase))
            return;

        _lastMissingUtilityAgentNoticeKey = noticeKey;
        AppendLine(
            $"[info] Squad checked the team setup and found missing built-in utility agents: {string.Join(", ", missingUtilities)}. Shared workflows like decision merging or backlog monitoring may be incomplete until they are restored.",
            ThemeBrush("SystemInfoText"));
    }

    private WorkspaceOwnershipLease? TakeStartupWorkspaceLease(string folderPath)
    {
        if (_startupWorkspaceLease is null ||
            !_startupWorkspaceLease.Matches(_workspacePaths.ApplicationRoot, folderPath))
        {
            return null;
        }

        var lease = _startupWorkspaceLease;
        _startupWorkspaceLease = null;
        return lease;
    }

    private void RememberWorkspaceFolder(string folderPath)
    {
        _settingsSnapshot = _settingsStore.RememberFolder(folderPath);
        RefreshRecentFoldersMenu(_settingsSnapshot.RecentFolders);
        App.RefreshJumpList(_settingsSnapshot.RecentFolders);
    }

    private void RefreshRecentFoldersMenu(IReadOnlyList<string> recentFolders)
    {
        RecentFoldersMenuItem.Items.Clear();

        var existingFolders = recentFolders
            .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
            .ToArray();

        if (existingFolders.Length == 0)
        {
            RecentFoldersMenuItem.IsEnabled = false;
            RecentFoldersMenuItem.Items.Add(new MenuItem
            {
                Header = "(No recent folders)",
                IsEnabled = false
            });
            return;
        }

        RecentFoldersMenuItem.IsEnabled = true;
        foreach (var folder in existingFolders)
        {
            RecentFoldersMenuItem.Items.Add(new MenuItem
            {
                Header = folder,
                Tag = folder
            });
        }

        foreach (var item in RecentFoldersMenuItem.Items.OfType<MenuItem>())
        {
            item.Click += RecentFolderMenuItem_Click;
        }
    }

    private Paragraph CreateTranscriptParagraph(double bottomMargin = 6)
    {
        return new Paragraph
        {
            Margin = new Thickness(0, 0, 0, bottomMargin)
        };
    }

    private TranscriptThreadState CreateCoordinatorTranscriptThread()
    {
        return new TranscriptThreadState(
            "coordinator",
            TranscriptThreadKind.Coordinator,
            "Coordinator",
            DateTimeOffset.Now);
    }

    private IEnumerable<TranscriptThreadState> EnumerateTranscriptThreads()
    {
        yield return CoordinatorThread;

        foreach (var thread in _agentThreadRegistry.ThreadOrder)
            yield return thread;
    }

    private static FlowDocument CreateEmptyTranscriptDocument() =>
        new()
        {
            PagePadding = new Thickness(0)
        };

    private static bool HasTranscriptSelection(RichTextBox box)
    {
        try { return !box.Selection.IsEmpty; }
        catch { return false; }
    }

    private static bool CollapseTranscriptSelection(RichTextBox box)
    {
        try
        {
            if (box.Selection.IsEmpty)
                return false;

            var caret = box.Selection.Start;
            box.Selection.Select(caret, caret);
            return true;
        }
        catch
        {
            return false;
        }
    }

    private int CollapseTranscriptSelectionsForFastSwitch(string reason)
    {
        var sw = Stopwatch.StartNew();
        var cleared = 0;

        if (CollapseTranscriptSelection(OutputTextBox))
        {
            _transcriptSnapshots.Remove(CoordinatorThread);
            cleared++;
        }

        foreach (var entry in _primaryAgentTranscriptHosts.Values)
        {
            if (!CollapseTranscriptSelection(entry.TranscriptBox))
                continue;

            _transcriptSnapshots.Remove(entry.Thread);
            cleared++;
        }

        foreach (var entry in _secondaryTranscripts)
        {
            if (CollapseTranscriptSelection(entry.TranscriptBox))
                cleared++;
        }

        sw.Stop();
        if (cleared > 0 || sw.ElapsedMilliseconds >= 10)
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SELECTION_COLLAPSE reason={reason} cleared={cleared} work={sw.ElapsedMilliseconds}ms");

        return cleared;
    }

    private RichTextBox? GetTranscriptBoxForBulkChange(TranscriptThreadState thread)
    {
        if (ReferenceEquals(thread, CoordinatorThread))
            return OutputTextBox;

        if (thread.Document.Parent is RichTextBox currentOwner)
            return currentOwner;

        if (thread.Kind == TranscriptThreadKind.Agent)
        {
            var entry = GetOrCreatePrimaryAgentTranscriptHost(thread);
            AttachDocumentToPrimaryAgentHost(entry, closeSecondaryOwner: false);
            return entry.TranscriptBox;
        }

        return null;
    }

    private void BeginBulkTranscriptDocumentLoad(TranscriptThreadState thread)
    {
        var box = GetTranscriptBoxForBulkChange(thread);
        if (box is null)
            return;

        _bulkChangeTranscriptBoxes[thread] = box;
        box.BeginChange();
    }

    private void EndBulkTranscriptDocumentLoad(TranscriptThreadState thread)
    {
        if (!_bulkChangeTranscriptBoxes.Remove(thread, out var box))
            return;

        box.EndChange();
    }

    private ThrottledScrollRichTextBox CreatePrimaryAgentTranscriptBox()
    {
        var rtb = new ThrottledScrollRichTextBox
        {
            Visibility = Visibility.Collapsed,
            Opacity = 0,
            IsHitTestVisible = false,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            IsDocumentEnabled = true,
            IsReadOnly = true,
            IsUndoEnabled = false,
            IsInactiveSelectionHighlightEnabled = false,
            ContextMenu = null,
            FontSize = _transcriptFontSize
        };
        AttachTranscriptBoxRenderDiagnostics(rtb, "primary-agent");
        rtb.SetResourceReference(RichTextBox.ForegroundProperty, "LabelText");
        rtb.SetResourceReference(RichTextBox.SelectionBrushProperty, "DocEditorSelectionBrush");
        rtb.SetResourceReference(RichTextBox.SelectionOpacityProperty, "DocEditorSelectionOpacity");
        rtb.PreviewMouseWheel += OutputTextBox_PreviewMouseWheel;
        rtb.PreviewMouseDown += OutputTextBox_PreviewMouseDown;
        rtb.PreviewMouseRightButtonDown += OutputTextBox_PreviewMouseRightButtonDown;
        rtb.Loaded += (_, _) =>
        {
            try
            {
                if (_selectedTranscriptThread?.Kind == TranscriptThreadKind.Agent
                    && _primaryAgentTranscriptHosts.TryGetValue(_selectedTranscriptThread, out var activeEntry)
                    && ReferenceEquals(activeEntry.TranscriptBox, rtb))
                    RefreshActiveTranscriptScrollViewer();
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("PrimaryAgentTranscript.Loaded", ex);
            }
        };
        rtb.CommandBindings.Add(new CommandBinding(
            System.Windows.Input.ApplicationCommands.Copy,
            OutputTextBox_CopyExecuted,
            OutputTextBox_CopyCanExecute));
        return rtb;
    }

    private PrimaryTranscriptHostEntry GetOrCreatePrimaryAgentTranscriptHost(TranscriptThreadState thread)
    {
        if (thread.Kind != TranscriptThreadKind.Agent)
            throw new InvalidOperationException("Only agent threads use cached primary transcript hosts.");

        if (_primaryAgentTranscriptHosts.TryGetValue(thread, out var existing))
            return existing;

        var rtb = CreatePrimaryAgentTranscriptBox();
        var scrollController = new TranscriptScrollController(rtb, Dispatcher);
        scrollController.SetScrollToBottomButton(ScrollToBottomButton);
        scrollController.TraceTarget = _traceWindow;

        var entry = new PrimaryTranscriptHostEntry
        {
            Thread = thread,
            TranscriptBox = rtb,
            ScrollController = scrollController
        };

        _primaryAgentTranscriptHosts.Add(thread, entry);
        AgentTranscriptHost.Children.Add(rtb);
        Panel.SetZIndex(rtb, 0);
        ApplyTranscriptFontSizeToDocument(thread.Document);
        return entry;
    }

    private bool AttachDocumentToPrimaryAgentHost(PrimaryTranscriptHostEntry entry, bool closeSecondaryOwner)
    {
        var thread = entry.Thread;
        if (ReferenceEquals(entry.TranscriptBox.Document, thread.Document))
            return true;

        if (thread.Document.Parent is RichTextBox currentOwner && currentOwner != entry.TranscriptBox)
        {
            var secondaryEntry = _secondaryTranscripts.FirstOrDefault(e => e.TranscriptBox == currentOwner);
            if (secondaryEntry is not null)
            {
                if (!closeSecondaryOwner)
                    return false;

                CloseSecondaryPanel(secondaryEntry);
            }
            else
            {
                currentOwner.Document = CreateEmptyTranscriptDocument();
            }
        }

        entry.TranscriptBox.Document = thread.Document;
        return true;
    }

    private bool IsPrimaryAgentTranscriptBox(RichTextBox box) =>
        _primaryAgentTranscriptHosts.Values.Any(entry => ReferenceEquals(entry.TranscriptBox, box));

    private void MarkPrimaryAgentHostRecentlyUsed(TranscriptThreadState thread)
    {
        _primaryAgentHostMru.RemoveAll(candidate => ReferenceEquals(candidate, thread));
        _primaryAgentHostMru.Insert(0, thread);
    }

    private bool IsPrimaryAgentHostLive(TranscriptThreadState thread)
    {
        var liveCount = Math.Min(PrimaryAgentLiveHostLimit, _primaryAgentHostMru.Count);
        for (var i = 0; i < liveCount; i++)
        {
            if (ReferenceEquals(_primaryAgentHostMru[i], thread))
                return true;
        }

        return false;
    }

    private string GetPrimaryAgentHostSummary()
    {
        var total = _primaryAgentTranscriptHosts.Count;
        var visible = _primaryAgentTranscriptHosts.Values.Count(entry => entry.TranscriptBox.Visibility == Visibility.Visible);
        var attached = _primaryAgentTranscriptHosts.Values.Count(entry => ReferenceEquals(entry.TranscriptBox.Document, entry.Thread.Document));
        return $"hosts={total} visible={visible} attached={attached} mru={_primaryAgentHostMru.Count}";
    }

    private void AttachTranscriptBoxRenderDiagnostics(RichTextBox box, string source)
    {
        box.LayoutUpdated += (_, _) => TraceTranscriptLayoutDuringInput(source, box, "layout");
        box.SizeChanged += (_, e) =>
            TraceTranscriptLayoutDuringInput(
                source,
                box,
                $"size {e.PreviousSize.Width:0.#}x{e.PreviousSize.Height:0.#}->{e.NewSize.Width:0.#}x{e.NewSize.Height:0.#}");
    }

    private void TraceTranscriptLayoutDuringInput(string source, RichTextBox box, string reason)
    {
        try
        {
            var now = DateTimeOffset.Now;
            if (now - _lastKeyboardInputAt > TimeSpan.FromSeconds(3))
                return;

            if (_lastTranscriptLayoutDuringInputTraceAt != 0
                && ElapsedMillisecondsSince(_lastTranscriptLayoutDuringInputTraceAt) < 1000)
                return;

            _lastTranscriptLayoutDuringInputTraceAt = Stopwatch.GetTimestamp();
            SquadDashTrace.Write(
                TraceCategory.Performance,
                $"TRANSCRIPT_LAYOUT_DURING_INPUT source={source} reason=\"{reason}\" {BuildTranscriptRenderState(box)}");
        }
        catch
        {
            // Diagnostics must not perturb layout or input.
        }
    }

    private string BuildTranscriptRenderState(RichTextBox? observedBox = null)
    {
        try
        {
            var thread = _selectedTranscriptThread ?? CoordinatorThread;
            var activeBox = observedBox ?? TryGetMainTranscriptBox(thread) ?? ActiveTranscriptBox;
            var cacheMode = activeBox.CacheMode?.GetType().Name ?? "none";
            var snapshotThread = _snapshotThread?.ThreadId ?? "none";
            var inputAgeMs = _lastKeyboardInputAt == DateTimeOffset.MinValue
                ? -1
                : (int)Math.Max(0, (DateTimeOffset.Now - _lastKeyboardInputAt).TotalMilliseconds);
            var snapshotAgeMs = _lastTranscriptSnapshotCaptureAt == 0
                ? -1
                : ElapsedMillisecondsSince(_lastTranscriptSnapshotCaptureAt);

            return
                $"selected={thread.Kind}:{thread.ThreadId} activeSize={activeBox.ActualWidth:0.#}x{activeBox.ActualHeight:0.#} " +
                $"activeVisible={activeBox.Visibility} activeOpacity={activeBox.Opacity:0.##} cache={cacheMode} " +
                $"docBlocks={activeBox.Document?.Blocks.Count ?? 0} outputSize={OutputTextBox.ActualWidth:0.#}x{OutputTextBox.ActualHeight:0.#} " +
                $"agentHostSize={AgentTranscriptHost.ActualWidth:0.#}x{AgentTranscriptHost.ActualHeight:0.#} " +
                $"snapshotCount={_transcriptSnapshots.Count} snapshotThread={snapshotThread} " +
                $"lastSnapshotAgeMs={snapshotAgeMs} lastSnapshot=\"{_lastTranscriptSnapshotCaptureSummary}\" " +
                $"inputAgeMs={inputAgeMs} promptFocused={PromptTextBox?.IsKeyboardFocusWithin == true} " +
                $"docFocused={DocSourceTextBox?.IsKeyboardFocusWithin == true} promptLen={PromptTextBox?.Text.Length ?? 0}";
        }
        catch (Exception ex)
        {
            return $"transcriptRenderStateError={ex.GetType().Name}";
        }
    }

    private RichTextBox? TryGetMainTranscriptBox(TranscriptThreadState? thread)
    {
        if (thread is null || !_mainTranscriptVisible)
            return null;

        if (ReferenceEquals(thread, CoordinatorThread))
            return OutputTextBox;

        return _primaryAgentTranscriptHosts.TryGetValue(thread, out var entry)
            ? entry.TranscriptBox
            : null;
    }

    private TranscriptScrollController? TryGetMainTranscriptScrollController(TranscriptThreadState? thread)
    {
        if (thread is null || !_mainTranscriptVisible)
            return null;

        if (ReferenceEquals(thread, CoordinatorThread))
            return _coordinatorScrollController;

        return _primaryAgentTranscriptHosts.TryGetValue(thread, out var entry)
            ? entry.ScrollController
            : null;
    }

    private TranscriptViewportAnchor? TryCaptureMainTranscriptViewportAnchor(string reason)
    {
        var thread = _selectedTranscriptThread ?? CoordinatorThread;
        var box = TryGetMainTranscriptBox(thread);
        if (box is null
            || box.Visibility != Visibility.Visible
            || box.Opacity <= 0.01
            || box.ActualWidth < 2
            || box.ActualHeight < 2)
        {
            return null;
        }

        var sv = FindScrollViewer(box);
        if (sv is null || sv.ViewportWidth <= 0 || sv.ViewportHeight <= 0)
            return null;

        var viewportX = Math.Max(1, sv.ViewportWidth * 0.5);
        var viewportY = Math.Max(1, sv.ViewportHeight * 0.5);
        var capturePoint = new Point(box.ActualWidth * 0.5, box.ActualHeight * 0.5);
        try
        {
            capturePoint = sv.TransformToAncestor(box).Transform(new Point(viewportX, viewportY));
        }
        catch
        {
            viewportY = Math.Max(1, box.ActualHeight * 0.5);
        }

        capturePoint.X = Math.Clamp(capturePoint.X, 1, Math.Max(1, box.ActualWidth - 1));
        capturePoint.Y = Math.Clamp(capturePoint.Y, 1, Math.Max(1, box.ActualHeight - 1));

        if (!box.IsArrangeValid || !box.IsMeasureValid)
            return null;

        TextPointer? pointer;
        try
        {
            pointer = box.GetPositionFromPoint(capturePoint, true);
        }
        catch (InvalidOperationException)
        {
            // TextView layout not yet validated — layout is still in progress; skip anchor capture.
            return null;
        }
        if (pointer is null)
            return null;

        SquadDashTrace.Write(TraceCategory.Performance,
            $"TRANSCRIPT_VIEWPORT_ANCHOR_CAPTURE reason={reason} thread={thread.ThreadId} " +
            $"offset={sv.VerticalOffset:0.#} viewportY={viewportY:0.#} viewportH={sv.ViewportHeight:0.#} width={box.ActualWidth:0.#}");
        return new TranscriptViewportAnchor(
            thread,
            pointer,
            viewportY,
            sv.VerticalOffset,
            sv.ViewportHeight,
            box.ActualWidth);
    }

    private void QueueRestoreMainTranscriptViewportAnchor(TranscriptViewportAnchor anchor)
    {
        var queuedAt = Stopwatch.GetTimestamp();
        _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, () =>
        {
            var queueMs = (long)((Stopwatch.GetTimestamp() - queuedAt) * 1000.0 / Stopwatch.Frequency);
            RestoreMainTranscriptViewportAnchor(anchor, queueMs);
        });
    }

    private void RestoreMainTranscriptViewportAnchor(TranscriptViewportAnchor anchor, long queueMs)
    {
        if (!_mainTranscriptVisible || !ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, anchor.Thread))
            return;

        var box = TryGetMainTranscriptBox(anchor.Thread);
        var controller = TryGetMainTranscriptScrollController(anchor.Thread);
        if (box is null || controller is null || box.Visibility != Visibility.Visible)
            return;

        var sv = FindScrollViewer(box);
        if (sv is null)
            return;

        var sw = Stopwatch.StartNew();
        try
        {
            var rect = anchor.Pointer.GetCharacterRect(LogicalDirection.Forward);
            if (rect.IsEmpty || double.IsNaN(rect.Top) || double.IsInfinity(rect.Top))
                return;

            var beforeOffset = sv.VerticalOffset;
            var targetOffset = sv.VerticalOffset + rect.Top - anchor.ViewportY;
            var clampedOffset = Math.Clamp(targetOffset, 0, sv.ScrollableHeight);
            controller.RestoreViewportAnchorOffset(clampedOffset);
            SyncPromptNavButtons(allowGeometry: false);
            SchedulePromptNavGeometryRefresh();

            sw.Stop();
            var delta = Math.Abs(clampedOffset - beforeOffset);
            if (delta >= 1 || sw.ElapsedMilliseconds >= 10 || queueMs >= 20)
            {
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"TRANSCRIPT_VIEWPORT_ANCHOR_RESTORE thread={anchor.Thread.ThreadId} " +
                    $"queue={queueMs}ms work={sw.ElapsedMilliseconds}ms " +
                    $"capturedOffset={anchor.PreviousVerticalOffset:0.#} offset={beforeOffset:0.#}->{clampedOffset:0.#} delta={delta:0.#} " +
                    $"width={anchor.PreviousWidth:0.#}->{box.ActualWidth:0.#} " +
                    $"viewportH={anchor.PreviousViewportHeight:0.#}->{sv.ViewportHeight:0.#}");
            }
        }
        catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
        {
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_VIEWPORT_ANCHOR_RESTORE_SKIP thread={anchor.Thread.ThreadId} error={ex.GetType().Name}");
        }
    }

    private bool TryCaptureTranscriptSnapshot(TranscriptThreadState? thread, string reason = "refresh")
    {
        if (thread is null || !_mainTranscriptVisible)
            return false;

        RichTextBox box;
        if (ReferenceEquals(thread, CoordinatorThread))
        {
            box = OutputTextBox;
        }
        else if (_primaryAgentTranscriptHosts.TryGetValue(thread, out var entry))
        {
            box = entry.TranscriptBox;
        }
        else
        {
            return false;
        }

        if (box.Visibility != Visibility.Visible || box.ActualWidth < 2 || box.ActualHeight < 2)
            return false;

        if (HasTranscriptSelection(box))
        {
            _transcriptSnapshots.Remove(thread);
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SNAPSHOT_SKIP thread={thread.ThreadId} reason=selection-active captureReason={reason}");
            return false;
        }

        var sw = Stopwatch.StartNew();
        try
        {
            var dpi = VisualTreeHelper.GetDpi(box);
            var pixelWidth = Math.Max(1, (int)Math.Ceiling(box.ActualWidth * dpi.DpiScaleX));
            var pixelHeight = Math.Max(1, (int)Math.Ceiling(box.ActualHeight * dpi.DpiScaleY));
            var bitmap = new RenderTargetBitmap(
                pixelWidth,
                pixelHeight,
                96.0 * dpi.DpiScaleX,
                96.0 * dpi.DpiScaleY,
                PixelFormats.Pbgra32);
            bitmap.Render(box);
            bitmap.Freeze();
            _transcriptSnapshots[thread] = new TranscriptSnapshot(
                bitmap,
                box.ActualWidth,
                box.ActualHeight,
                pixelWidth,
                pixelHeight,
                _activeThemeName);
            sw.Stop();
            _lastTranscriptSnapshotCaptureAt = Stopwatch.GetTimestamp();
            _lastTranscriptSnapshotCaptureSummary =
                $"{reason}:{thread.ThreadId}:{pixelWidth}x{pixelHeight}:{sw.ElapsedMilliseconds}ms";
            if (sw.ElapsedMilliseconds >= 10 || string.Equals(reason, "leaving-thread", StringComparison.Ordinal))
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"TRANSCRIPT_SNAPSHOT_CAPTURE thread={thread.ThreadId} reason={reason} {sw.ElapsedMilliseconds}ms size={pixelWidth}x{pixelHeight}");
            return true;
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SNAPSHOT_CAPTURE_FAILED thread={thread.ThreadId} reason={reason} error={ex.Message}");
            return false;
        }
    }

    private bool TryShowTranscriptSnapshot(TranscriptThreadState thread)
    {
        if (!_transcriptSnapshots.TryGetValue(thread, out var snapshot))
            return false;

        if (!string.Equals(snapshot.ThemeName, _activeThemeName, StringComparison.OrdinalIgnoreCase))
        {
            _transcriptSnapshots.Remove(thread);
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SNAPSHOT_SKIP thread={thread.ThreadId} reason=theme-mismatch " +
                $"snapshot={snapshot.ThemeName} current={_activeThemeName}");
            return false;
        }

        var currentWidth = OutputTextBox.ActualWidth;
        var currentHeight = OutputTextBox.ActualHeight;
        if (Math.Abs(snapshot.LogicalWidth - currentWidth) > 1.0
            || Math.Abs(snapshot.LogicalHeight - currentHeight) > 1.0)
        {
            _transcriptSnapshots.Remove(thread);
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SNAPSHOT_SKIP thread={thread.ThreadId} reason=size-mismatch " +
                $"snapshot={snapshot.LogicalWidth:0.#}x{snapshot.LogicalHeight:0.#} current={currentWidth:0.#}x{currentHeight:0.#}");
            return false;
        }

        TranscriptSnapshotImage.BeginAnimation(OpacityProperty, null);
        TranscriptSnapshotImage.Opacity = 1;
        TranscriptSnapshotBackdrop.Visibility = Visibility.Visible;
        TranscriptSnapshotImage.Source = snapshot.Source;
        TranscriptSnapshotImage.Visibility = Visibility.Visible;
        _snapshotThread = thread;
        SquadDashTrace.Write(TraceCategory.Performance,
            $"TRANSCRIPT_SNAPSHOT_SHOW thread={thread.ThreadId} source={snapshot.PixelWidth}x{snapshot.PixelHeight}");
        return true;
    }

    private void HideTranscriptSnapshot(TranscriptThreadState thread)
    {
        if (!ReferenceEquals(_snapshotThread, thread))
            return;

        TranscriptSnapshotImage.BeginAnimation(OpacityProperty, null);
        TranscriptSnapshotImage.Opacity = 1;
        TranscriptSnapshotImage.Visibility = Visibility.Collapsed;
        TranscriptSnapshotImage.Source = null;
        TranscriptSnapshotBackdrop.Visibility = Visibility.Collapsed;
        _snapshotThread = null;
        if (ReferenceEquals(_selectedTranscriptThread, thread))
        {
            if (thread.Kind == TranscriptThreadKind.Coordinator)
            {
                OutputTextBox.IsHitTestVisible = true;
            }
            else
            {
                AgentTranscriptHost.IsHitTestVisible = true;
                ApplyPrimaryAgentHostVisibility(thread);
            }
        }
        SquadDashTrace.Write(TraceCategory.Performance, $"TRANSCRIPT_SNAPSHOT_HIDE thread={thread.ThreadId}");
    }

    private void InvalidateTranscriptSnapshots(string reason)
    {
        if (_transcriptSnapshots.Count == 0 && _snapshotThread is null)
            return;

        _transcriptSnapshots.Clear();
        if (_snapshotThread is not null)
        {
            TranscriptSnapshotImage.BeginAnimation(OpacityProperty, null);
            TranscriptSnapshotImage.Opacity = 1;
            TranscriptSnapshotImage.Visibility = Visibility.Collapsed;
            TranscriptSnapshotImage.Source = null;
            TranscriptSnapshotBackdrop.Visibility = Visibility.Collapsed;
            _snapshotThread = null;
        }

        if ((_selectedTranscriptThread?.Kind ?? TranscriptThreadKind.Coordinator) == TranscriptThreadKind.Coordinator)
            OutputTextBox.IsHitTestVisible = true;
        else
            AgentTranscriptHost.IsHitTestVisible = true;

        SquadDashTrace.Write(TraceCategory.Performance, $"TRANSCRIPT_SNAPSHOT_INVALIDATE reason={reason}");
    }

    private void ApplyLiveTranscriptVisibility(TranscriptThreadState thread)
    {
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            OutputTextBox.Opacity = 1;
            OutputTextBox.IsHitTestVisible = true;
            HidePrimaryAgentTranscriptHost();
        }
        else
        {
            ShowPrimaryAgentTranscriptHost(thread);
            OutputTextBox.Opacity = 0;
            OutputTextBox.IsHitTestVisible = false;
            AgentTranscriptHost.IsHitTestVisible = true;
            ApplyPrimaryAgentHostVisibility(thread);
        }
    }

    private void QueueDeferredLiveTranscriptSwitch(TranscriptThreadState thread, bool scrollToStart)
    {
        var queuedAt = Stopwatch.GetTimestamp();
        _ = Dispatcher.BeginInvoke(PostVisualUpdatePriority, () =>
        {
            if (!ReferenceEquals(_selectedTranscriptThread, thread))
                return;

            var sw = Stopwatch.StartNew();
            var queueMs = (long)((Stopwatch.GetTimestamp() - queuedAt) * 1000.0 / Stopwatch.Frequency);
            ApplyLiveTranscriptVisibility(thread);
            RefreshActiveTranscriptScrollViewer();
            ApplyTranscriptFontSizeToDocument(thread.Document);
            if (_conversationManager.HasPendingRender(thread))
                _ = _conversationManager.EnsureAgentThreadRenderedAsync(thread);
            ScrollTranscriptThread(thread, scrollToStart);
            SyncPromptNavButtons(allowGeometry: false);
            SchedulePromptNavGeometryRefresh();
            UpdateInteractiveControlState();
            sw.Stop();
            SquadDashTrace.Write(TraceCategory.Performance,
                $"DEFERRED_LIVE_TRANSCRIPT_SWITCH thread={thread.ThreadId} queue={queueMs}ms work={sw.ElapsedMilliseconds}ms");

            if (ReferenceEquals(_selectedTranscriptThread, thread))
                HideTranscriptSnapshot(thread);
        });
    }

    private void ApplyPrimaryAgentHostVisibility(TranscriptThreadState? activeAgentThread)
    {
        foreach (var entry in _primaryAgentTranscriptHosts.Values)
        {
            var isActive = ReferenceEquals(entry.Thread, activeAgentThread);
            entry.TranscriptBox.Visibility = isActive ? Visibility.Visible : Visibility.Collapsed;
            entry.TranscriptBox.Opacity = isActive ? 1 : 0;
            entry.TranscriptBox.IsHitTestVisible = isActive && AgentTranscriptHost.IsHitTestVisible;
            Panel.SetZIndex(entry.TranscriptBox, isActive ? 1 : 0);
        }
    }

    private void ShowPrimaryAgentTranscriptHost(TranscriptThreadState thread)
    {
        var sw = Stopwatch.StartNew();
        var existed = _primaryAgentTranscriptHosts.ContainsKey(thread);
        var entry = GetOrCreatePrimaryAgentTranscriptHost(thread);
        var hostMs = sw.ElapsedMilliseconds;
        sw.Restart();
        AttachDocumentToPrimaryAgentHost(entry, closeSecondaryOwner: true);
        var attachMs = sw.ElapsedMilliseconds;
        sw.Restart();
        MarkPrimaryAgentHostRecentlyUsed(thread);
        AgentTranscriptHost.Opacity = 1;
        AgentTranscriptHost.IsHitTestVisible = true;
        ApplyPrimaryAgentHostVisibility(thread);
        var visibilityMs = sw.ElapsedMilliseconds;
        SquadDashTrace.Write(TraceCategory.Performance,
            $"PRIMARY_HOST_SHOW thread={thread.ThreadId} existed={existed} host={hostMs}ms attach={attachMs}ms visibility={visibilityMs}ms {GetPrimaryAgentHostSummary()}");
    }

    private void HidePrimaryAgentTranscriptHost()
    {
        AgentTranscriptHost.Opacity = 0;
        AgentTranscriptHost.IsHitTestVisible = false;
        ApplyPrimaryAgentHostVisibility(activeAgentThread: null);
    }

    private void RemovePrimaryAgentTranscriptHosts(IEnumerable<TranscriptThreadState> threads)
    {
        foreach (var thread in threads.ToArray())
        {
            if (!_primaryAgentTranscriptHosts.Remove(thread, out var entry))
                continue;

            _primaryAgentHostMru.RemoveAll(candidate => ReferenceEquals(candidate, thread));
            _bulkChangeTranscriptBoxes.Remove(thread);
            _transcriptSnapshots.Remove(thread);
            if (ReferenceEquals(_snapshotThread, thread))
                HideTranscriptSnapshot(thread);
            if (ReferenceEquals(entry.TranscriptBox.Document, thread.Document))
                entry.TranscriptBox.Document = CreateEmptyTranscriptDocument();
            AgentTranscriptHost.Children.Remove(entry.TranscriptBox);
        }
    }

    private IReadOnlyList<TranscriptThreadState> GetPrimaryAgentWarmupCandidates()
    {
        var selected = _selectedTranscriptThread;
        return _agentThreadRegistry.ThreadOrder
            .Where(thread => thread.Kind == TranscriptThreadKind.Agent)
            .OrderByDescending(thread => ReferenceEquals(thread, selected))
            .ThenByDescending(thread => _backgroundTaskPresenter.IsThreadActiveForDisplay(thread))
            .ThenByDescending(AgentThreadRegistry.GetThreadLastActivityAt)
            .Take(PrimaryAgentLiveHostLimit)
            .ToArray();
    }

    private void SchedulePrimaryAgentHostWarmup() =>
        SchedulePrimaryAgentHostWarmup(TimeSpan.Zero, "default");

    private void SchedulePrimaryAgentHostWarmup(TimeSpan delay, string reason)
    {
        if (_primaryAgentWarmupPending)
            return;

        _primaryAgentWarmupPending = true;
        if (delay <= TimeSpan.Zero)
        {
            _ = Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, WarmPrimaryAgentTranscriptHosts);
            return;
        }

        if (delay >= TimeSpan.FromSeconds(1))
        {
            SquadDashTrace.Write(TraceCategory.Performance,
                $"PRIMARY_HOST_WARMUP_DELAY reason={reason} delayMs={delay.TotalMilliseconds:0}");
        }
        _ = DelayPrimaryAgentHostWarmupAsync(delay);
    }

    private async Task DelayPrimaryAgentHostWarmupAsync(TimeSpan delay)
    {
        try
        {
            await Task.Delay(delay);
            if (!_isClosing)
                _ = Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, WarmPrimaryAgentTranscriptHosts);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DelayPrimaryAgentHostWarmupAsync), ex, showDialog: false);
        }
    }

    private async void WarmPrimaryAgentTranscriptHosts()
    {
        _primaryAgentWarmupPending = false;
        try
        {
            if (ShouldDeferNonEssentialUiWorkForInput(out var inputDelay))
            {
                SchedulePrimaryAgentHostWarmup(inputDelay, "input-active");
                return;
            }

            foreach (var thread in GetPrimaryAgentWarmupCandidates())
            {
                if (_isClosing)
                    return;

                var sw = Stopwatch.StartNew();
                var entry = GetOrCreatePrimaryAgentTranscriptHost(thread);
                if (!AttachDocumentToPrimaryAgentHost(entry, closeSecondaryOwner: false))
                    continue;

                MarkPrimaryAgentHostRecentlyUsed(thread);
                ApplyPrimaryAgentHostVisibility(
                    (_selectedTranscriptThread?.Kind ?? TranscriptThreadKind.Coordinator) == TranscriptThreadKind.Agent
                        ? _selectedTranscriptThread
                        : null);

                if (_conversationManager.HasPendingRender(thread))
                    await _conversationManager.EnsureAgentThreadRenderedAsync(thread);

                if (entry.TranscriptBox.Visibility == Visibility.Visible)
                    entry.TranscriptBox.UpdateLayout();

                sw.Stop();
                if (sw.ElapsedMilliseconds >= 20)
                    SquadDashTrace.Write(TraceCategory.Performance,
                        $"PRIMARY_HOST_WARMUP thread={thread.ThreadId} visible={entry.TranscriptBox.Visibility == Visibility.Visible} work={sw.ElapsedMilliseconds}ms {GetPrimaryAgentHostSummary()}");
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(WarmPrimaryAgentTranscriptHosts), ex);
        }
    }

    private void SelectTranscriptThread(TranscriptThreadState thread, bool scrollToStart = false)
    {
        SelectTranscriptThreadCore(thread, scrollToStart, allowSnapshotFastPath: true, previousThreadOverride: null);
    }

    private bool IsThreadAlreadyVisibleInMainTranscript(TranscriptThreadState thread)
    {
        if (!_mainTranscriptVisible)
            return false;

        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            return ReferenceEquals(thread.Document.Parent, OutputTextBox) &&
                   OutputTextBox.Visibility == Visibility.Visible &&
                   OutputTextBox.Opacity > 0;
        }

        return _primaryAgentTranscriptHosts.TryGetValue(thread, out var entry) &&
               ReferenceEquals(thread.Document.Parent, entry.TranscriptBox) &&
               AgentTranscriptHost.Visibility == Visibility.Visible &&
               AgentTranscriptHost.Opacity > 0 &&
               entry.TranscriptBox.Visibility == Visibility.Visible &&
               entry.TranscriptBox.Opacity > 0;
    }

    private void QueueDeferredSnapshotSelectionCompletion(
        TranscriptThreadState thread,
        TranscriptThreadState? previousThread,
        bool scrollToStart)
    {
        var queuedAt = Stopwatch.GetTimestamp();
        _ = Dispatcher.BeginInvoke(PostVisualUpdatePriority, () =>
        {
            if (!ReferenceEquals(_selectedTranscriptThread, thread))
                return;

            var queueMs = (long)((Stopwatch.GetTimestamp() - queuedAt) * 1000.0 / Stopwatch.Frequency);
            SquadDashTrace.Write(TraceCategory.Performance,
                $"SNAPSHOT_SELECTION_COMPLETE_START thread={thread.ThreadId} queue={queueMs}ms");

            // Preserve any in-progress search across the snapshot completion.  The
            // completion is a deferred rendering step, not a user-initiated thread
            // switch, so clearing _searchMatches (and SearchBox.Text) here would
            // silently discard results the user just requested.
            var savedNavigating = _searchNavigating;
            _searchNavigating = true;
            try
            {
                SelectTranscriptThreadCore(thread, scrollToStart, allowSnapshotFastPath: false, previousThreadOverride: previousThread);
            }
            finally
            {
                _searchNavigating = savedNavigating;
            }

            if (ReferenceEquals(_selectedTranscriptThread, thread))
                HideTranscriptSnapshot(thread);
        });
    }

    private void TraceSnapshotFirstRender(TranscriptThreadState thread, Stopwatch stopwatch)
    {
        EventHandler? renderingHandler = null;
        renderingHandler = (_, _) =>
        {
            CompositionTarget.Rendering -= renderingHandler;
            SquadDashTrace.Write(TraceCategory.Performance,
                $"TRANSCRIPT_SNAPSHOT_FIRST_RENDER thread={thread.ThreadId} {stopwatch.ElapsedMilliseconds}ms");
        };
        CompositionTarget.Rendering += renderingHandler;
    }

    private void SelectTranscriptThreadCore(
        TranscriptThreadState thread,
        bool scrollToStart,
        bool allowSnapshotFastPath,
        TranscriptThreadState? previousThreadOverride)
    {
        var swSelect = System.Diagnostics.Stopwatch.StartNew();
        var previousThread = previousThreadOverride ?? _selectedTranscriptThread;
        if (!scrollToStart &&
            !_searchNavigating &&
            string.IsNullOrEmpty(SearchBox.Text) &&
            _searchMatches.Count == 0 &&
            ReferenceEquals(previousThread, thread) &&
            !_conversationManager.HasPendingRender(thread) &&
            IsThreadAlreadyVisibleInMainTranscript(thread))
        {
            SquadDashTrace.Write(TraceCategory.Performance,
                $"SELECT_THREAD_SKIP target={thread.Kind} id={thread.ThreadId} reason=already-visible {GetPrimaryAgentHostSummary()}");
            return;
        }

        if (!ReferenceEquals(previousThread, thread))
        {
            CollapseTranscriptSelectionsForFastSwitch("select-thread");
            TryCaptureTranscriptSnapshot(previousThread, "leaving-thread");
        }
        var useSnapshotFastPath = allowSnapshotFastPath
            && !scrollToStart
            && !_searchNavigating
            && !ReferenceEquals(previousThread, thread)
            && TryShowTranscriptSnapshot(thread);

        _selectedTranscriptThread = thread;

        if (useSnapshotFastPath)
        {
            swSelect.Stop();
            SquadDashTrace.Write(TraceCategory.Performance,
                $"SELECT_THREAD_SNAPSHOT_FAST_PATH target={thread.Kind} id={thread.ThreadId} total={swSelect.ElapsedMilliseconds}ms");
            TraceSnapshotFirstRender(thread, Stopwatch.StartNew());
            QueueDeferredSnapshotSelectionCompletion(thread, previousThread, scrollToStart);
            return;
        }

        var parentKind = thread.Document.Parent switch
        {
            RichTextBox rtb when ReferenceEquals(rtb, OutputTextBox) => "OutputTextBox",
            RichTextBox rtb when IsPrimaryAgentTranscriptBox(rtb) => "PrimaryAgentHost",
            RichTextBox => "SecondaryRichTextBox",
            null => "null",
            var parent => parent.GetType().Name
        };
        SquadDashTrace.Write(TraceCategory.Performance,
            $"SELECT_THREAD_DETAIL target={thread.Kind} id={thread.ThreadId} scrollToStart={scrollToStart} " +
            $"pendingRender={_conversationManager.HasPendingRender(thread)} docBlocks={thread.Document.Blocks.Count} " +
            $"parent={parentKind} {GetPrimaryAgentHostSummary()}");

        // Preserve search state when navigating to a match in a different thread.
        // When _searchNavigating is set, the caller owns restoring state after the switch.
        if (!_searchNavigating)
        {
            _searchMatches = [];
            _searchMatchCursor = -1;
            _searchAdorner?.Clear();
            _scrollbarAdorner?.Clear();
            _cachedSearchPointers = null;
            ClearBucCellHighlights();
            if (!string.IsNullOrEmpty(SearchBox.Text))
                SearchBox.Text = string.Empty;
            UpdateSearchUi();
        }

        foreach (var candidate in EnumerateTranscriptThreads())
            candidate.IsSelected = ReferenceEquals(candidate, thread);
        var t0 = swSelect.ElapsedMilliseconds;

        UpdateTransientTranscriptFooters(thread);
        UpdateCompletedTimeFooters();
        var t1 = swSelect.ElapsedMilliseconds;

        // close that panel before assigning to the main cached host — FlowDocument can only
        // belong to one RichTextBox at a time. Coordinator doc stays permanently in OutputTextBox.
        if (thread.Document.Parent is RichTextBox secondaryOwner
            && secondaryOwner != OutputTextBox
            && !IsPrimaryAgentTranscriptBox(secondaryOwner))
        {
            var secondaryEntry = _secondaryTranscripts.FirstOrDefault(e => e.TranscriptBox == secondaryOwner);
            if (secondaryEntry != null)
                CloseSecondaryPanel(secondaryEntry);
            else
                secondaryOwner.Document = CreateEmptyTranscriptDocument(); // detach without a tracked panel
        }

        if (!useSnapshotFastPath)
        {
            ApplyLiveTranscriptVisibility(thread);
            RefreshActiveTranscriptScrollViewer();
        }
        var t2 = swSelect.ElapsedMilliseconds;

        ApplyTranscriptFontSizeToDocument(thread.Document);
        var t3 = swSelect.ElapsedMilliseconds;

        UpdateTranscriptThreadBadge();
        SyncAgentCardsForSelectionChange(previousThread, thread);
        var t4 = swSelect.ElapsedMilliseconds;

        if (!useSnapshotFastPath)
        {
            SyncPromptNavButtons(allowGeometry: false);
            SchedulePromptNavGeometryRefresh();
        }
        var t5 = swSelect.ElapsedMilliseconds;

        // If this agent thread's turns were deferred at startup (lazy rendering),
        // render them now.  The document is already assigned to the active transcript box so
        // BeginChange/EndChange in RenderConversationHistoryAsync suppress intermediate
        // layout passes correctly.  The Normal-priority dispatch ensures the render
        // completes before any subsequent Input-priority click events can fire, so there
        // is no race between the render and the user switching threads again.
        if (!useSnapshotFastPath && _conversationManager.HasPendingRender(thread))
            _ = _conversationManager.EnsureAgentThreadRenderedAsync(thread);

        // When switching to a thread with no focused prompt, briefly flash the
        // thread's start time in the same hint label so the user knows when
        // this conversation began.
        if (thread.PromptNavIndex == -1)
        {
            PromptNavHintTextBlock.Text = FormatRelativeTime(thread.StartedAt);
            ShowPromptNavHintWithFadeOut();
        }

        if (useSnapshotFastPath)
        {
            QueueDeferredLiveTranscriptSwitch(thread, scrollToStart);
        }
        else
        {
            ScrollTranscriptThread(thread, scrollToStart);
            UpdateInteractiveControlState();
        }
        swSelect.Stop();

        if (swSelect.ElapsedMilliseconds >= 20)
            SquadDashTrace.Write(TraceCategory.Performance,
                $"SELECT_THREAD ({thread.Kind}): total={swSelect.ElapsedMilliseconds}ms " +
                $"search/sel={t0}ms footers={t1 - t0}ms vis={t2 - t1}ms " +
                $"fontsize={t3 - t2}ms syncCards={t4 - t3}ms promptNav={t5 - t4}ms " +
                $"rest={swSelect.ElapsedMilliseconds - t5}ms");

        SchedulePrimaryAgentHostWarmup();

        // Dispatcher checkpoint trace. ContextIdle used to include a queued ScrollToEnd
        // layout flush; keep the old line for continuity, and add surrounding priority
        // checkpoints so slow switches can be attributed more precisely.
        var swRender = System.Diagnostics.Stopwatch.StartNew();
        var traceKind = thread.Kind;
        void QueueCheckpoint(DispatcherPriority priority, string label)
        {
            Dispatcher.BeginInvoke(priority, () =>
            {
                if (swRender.ElapsedMilliseconds >= 20)
                    SquadDashTrace.Write(TraceCategory.Performance,
                        $"SELECT_THREAD checkpoint ({traceKind}) {label}: {swRender.ElapsedMilliseconds}ms {GetPrimaryAgentHostSummary()}");
            });
        }

        EventHandler? renderingHandler = null;
        renderingHandler = (_, _) =>
        {
            CompositionTarget.Rendering -= renderingHandler;
            if (swRender.ElapsedMilliseconds >= 20)
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"SELECT_THREAD CompositionTarget.Rendering ({traceKind}): {swRender.ElapsedMilliseconds}ms");
        };
        CompositionTarget.Rendering += renderingHandler;

        QueueCheckpoint(DispatcherPriority.Render, "RenderPriority");
        QueueCheckpoint(DispatcherPriority.Loaded, "LoadedPriority");
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, () => {
            if (swRender.ElapsedMilliseconds >= 20)
                SquadDashTrace.Write(TraceCategory.Performance,
                    $"SELECT_THREAD render ({traceKind}): {swRender.ElapsedMilliseconds}ms (ContextIdle after switch)");
        });
        QueueCheckpoint(DispatcherPriority.ApplicationIdle, "ApplicationIdle");
    }

    private void UpdateTranscriptThreadBadge(TranscriptThreadState? threadOverride = null)
    {
        var thread = threadOverride ?? _selectedTranscriptThread ?? CoordinatorThread;
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            TranscriptTitleTextBlock.Text = "Coordinator";
            TranscriptTitleTextBlock.ToolTip = null;
            return;
        }

        var title = thread.DisplayTitle?.Trim();
        var displayTitle = string.IsNullOrWhiteSpace(title) ? "Agent" : AbbreviateAgentName(title);
        var possessive = $"{displayTitle}'s transcript";
        TranscriptTitleTextBlock.ToolTip = BuildGpaTooltip(displayTitle);

        var intent = thread.LatestIntent?.Trim();
        if (!string.IsNullOrWhiteSpace(intent))
        {
            const int MaxIntentLength = 60;
            var truncated = intent.Length > MaxIntentLength
                ? intent[..MaxIntentLength].TrimEnd() + "…"
                : intent;
            TranscriptTitleTextBlock.Text = $"{possessive} — {truncated}";
        }
        else
        {
            TranscriptTitleTextBlock.Text = possessive;
        }
    }

    private void UpdateTransientTranscriptFooters(TranscriptThreadState selectedThread)
    {
        foreach (var thread in EnumerateTranscriptThreads())
        {
            if (thread.TransientFooterParagraph is null)
                continue;

            thread.Document.Blocks.Remove(thread.TransientFooterParagraph);
            thread.TransientFooterParagraph = null;
        }

        if (selectedThread.Kind != TranscriptThreadKind.Agent)
            return;

        var footerParagraph = CreateTranscriptParagraph(bottomMargin: 0);
        footerParagraph.Margin = new Thickness(0, 14, 0, 0);
        _markdownRenderer.AppendInlineMarkdown(footerParagraph.Inlines, "[Back to main transcript](thread:coordinator)");
        selectedThread.Document.Blocks.Add(footerParagraph);
        selectedThread.TransientFooterParagraph = footerParagraph;
    }

    private void EnsureThreadFooterAtEnd(TranscriptThreadState thread)
    {
        // Determine expected last block.
        var expectedLast = (Block?)thread.CompletedTimeParagraph ?? thread.TransientFooterParagraph;
        if (expectedLast is null)
            return;

        if (ReferenceEquals(thread.Document.Blocks.LastBlock, expectedLast) &&
            (thread.TransientFooterParagraph is null || thread.CompletedTimeParagraph is null ||
             ReferenceEquals(thread.Document.Blocks.LastBlock.PreviousBlock, thread.TransientFooterParagraph)))
            return;

        // Re-anchor both footers in correct order: TransientFooter then CompletedTime.
        if (thread.TransientFooterParagraph is not null)
        {
            thread.Document.Blocks.Remove(thread.TransientFooterParagraph);
            thread.Document.Blocks.Add(thread.TransientFooterParagraph);
        }
        if (thread.CompletedTimeParagraph is not null)
        {
            thread.Document.Blocks.Remove(thread.CompletedTimeParagraph);
            thread.Document.Blocks.Add(thread.CompletedTimeParagraph);
        }
    }

    private void ScrollTranscriptThread(TranscriptThreadState thread, bool scrollToStart)
    {
        EnsureThreadFooterAtEnd(thread);
        ActiveScrollController.OnThreadSelected(scrollToStart);
    }

    private void ScrollTranscriptToEndAfterRender(TranscriptThreadState thread)
    {
        if (ReferenceEquals(thread, CoordinatorThread))
        {
            // During initial history load IsLoadingTranscript is true — route to EndLoad()
            // so the suppression flag is cleared and exactly one post-load scroll fires.
            // Outside of load (normal streaming) IsLoadingTranscript is false — use the
            // standard debounced RequestScrollToEnd path.
            if (_coordinatorScrollController.IsLoadingTranscript)
            {
                _coordinatorScrollController.EndLoad();
                LoadingTranscriptOverlay.Visibility = Visibility.Collapsed;
            }
            else
            {
                _coordinatorScrollController.RequestScrollToEnd();
            }

            return;
        }

        if (_primaryAgentTranscriptHosts.TryGetValue(thread, out var entry))
            entry.ScrollController.RequestScrollToEnd();
    }

    // ── Completed-time footer ────────────────────────────────────────────────

    private IEnumerable<TranscriptThreadState> GetVisibleTranscriptThreads()
    {
        if (_mainTranscriptVisible && _selectedTranscriptThread is not null)
            yield return _selectedTranscriptThread;
        foreach (var entry in _secondaryTranscripts)
            yield return entry.Thread;
    }

    private Paragraph CreateCompletedTimeParagraph(string text)
    {
        var p = CreateTranscriptParagraph(bottomMargin: 0);
        p.Margin = new Thickness(0, 10, 0, 0);
        var run = new Run(text) { FontSize = (double)Application.Current.Resources["FontSizeSmall"] };
        run.SetResourceReference(TextElement.ForegroundProperty, "SubtleText");
        p.Inlines.Add(run);
        return p;
    }

    /// <summary>
    /// Clears completion state on a thread that has received new activity after previously
    /// being marked complete (e.g. a resumed session).  Removes the "Completed N minutes ago"
    /// footer so it does not appear mid-session.
    /// </summary>
    private void MaybeReactivateThread(TranscriptThreadState thread)
    {
        if (thread.CompletedAt is null) return;
        thread.CompletedAt = null;
        if (thread.CompletedTimeParagraph is not null)
        {
            thread.Document.Blocks.Remove(thread.CompletedTimeParagraph);
            thread.CompletedTimeParagraph = null;
        }
    }

    private void UpdateCompletedTimeFooters()
    {
        var visibleCompleted = GetVisibleTranscriptThreads()
            .Where(t => t.CompletedAt is not null)
            .ToHashSet(ReferenceEqualityComparer.Instance);

        foreach (var thread in EnumerateTranscriptThreads())
        {
            if (visibleCompleted.Contains(thread))
            {
                var text = $"Completed {StatusTimingPresentation.FormatRelativeTimestamp(thread.CompletedAt!.Value)}";
                if (thread.CompletedTimeParagraph is null)
                {
                    var p = CreateCompletedTimeParagraph(text);
                    thread.Document.Blocks.Add(p);
                    thread.CompletedTimeParagraph = p;
                }
                else
                {
                    if (thread.CompletedTimeParagraph.Inlines.FirstInline is Run run)
                        run.Text = text;
                    if (!ReferenceEquals(thread.Document.Blocks.LastBlock, thread.CompletedTimeParagraph))
                    {
                        thread.Document.Blocks.Remove(thread.CompletedTimeParagraph);
                        thread.Document.Blocks.Add(thread.CompletedTimeParagraph);
                    }
                }
            }
            else if (thread.CompletedTimeParagraph is not null)
            {
                thread.Document.Blocks.Remove(thread.CompletedTimeParagraph);
                thread.CompletedTimeParagraph = null;
            }
        }

        if (visibleCompleted.Count > 0)
            EnsureCompletedTimeFooterTimerRunning();
        else
            _completedTimeFooterTimer?.Stop();
    }

    private void EnsureCompletedTimeFooterTimerRunning()
    {
        if (_completedTimeFooterTimer is null)
        {
            _completedTimeFooterTimer = new DispatcherTimer(TimeSpan.FromSeconds(60), DispatcherPriority.Background,
                (_, _) =>
                {
                    try { UpdateCompletedTimeFooters(); }
                    catch (Exception ex) { HandleUiCallbackException("CompletedTimeFooterTimer.Tick", ex); }
                },
                Dispatcher);
        }
        if (!_completedTimeFooterTimer.IsEnabled)
            _completedTimeFooterTimer.Start();
    }



    private void ShowSingleTranscript(AgentStatusCard agent)
    {
        var thread = GetTranscriptThreadForAgent(agent);
        var previousThread = ApplyImmediatePrimaryTranscriptSelectionVisuals(agent, thread);
        QueueDeferredPrimaryTranscriptSelection(agent, thread, previousThread);
    }

    /// <summary>
    /// Shows a specific agent thread full-screen in the main transcript area (plain chip click).
    /// Closes all secondary panels and shows main, identical to ShowSingleTranscript but
    /// targeting an explicit thread rather than the agent's most recent one.
    /// </summary>
    private void ShowThreadInMainTranscript(AgentStatusCard card, TranscriptThreadState thread)
    {
        var previousThread = ApplyImmediatePrimaryTranscriptSelectionVisuals(card, thread);
        QueueDeferredPrimaryTranscriptSelection(card, thread, previousThread);
    }

    private void ToggleAgentTranscriptVisibility(AgentStatusCard agent)
    {
        if (agent.IsLeadAgent)
        {
            if (_mainTranscriptVisible && CoordinatorThread.IsSelected)
            {
                if (_secondaryTranscripts.Count > 0)
                    HideMainTranscript();

                return;
            }

            ShowMainTranscript();
            SelectTranscriptThread(CoordinatorThread);
            return;
        }

        var existing = _secondaryTranscripts.FirstOrDefault(entry => entry.Agent == agent);
        if (existing is not null)
        {
            CloseSecondaryPanel(existing);
            if (!_mainTranscriptVisible && _secondaryTranscripts.Count == 0)
            {
                ShowMainTranscript();
                SelectTranscriptThread(CoordinatorThread);
            }

            return;
        }

        var thread = GetTranscriptThreadForAgent(agent);
        if (_mainTranscriptVisible && thread.IsSelected)
        {
            SelectTranscriptThread(CoordinatorThread);
            return;
        }

        OpenSecondaryPanel(agent, GetTranscriptThreadForAgent(agent), isAutoOpenedInMultiMode: false);
    }

    private void OpenSecondaryPanel(AgentStatusCard agent, TranscriptThreadState thread, bool isAutoOpenedInMultiMode)
    {
        if (_secondaryTranscripts.Any(e => ReferenceEquals(e.Thread, thread)))
        {
            SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                $"OpenSecondaryPanel skipped duplicate thread={thread.ThreadId} agent={agent.Name} seq={thread.SequenceNumber}");
            SyncSelectionControllerWithUiState("OpenSecondaryPanel.duplicate");

            // Find the existing panel and flash it
            var existingEntry = _secondaryTranscripts.First(e => ReferenceEquals(e.Thread, thread));
            FlashGlowHighlight(existingEntry.PanelBorder, ColorFromHex(agent.AccentColorHex));
            return;
        }

        // If this thread's document is already displayed in the main transcript,
        // opening a secondary panel would throw (FlowDocument can only belong to
        // one RichTextBox at a time).  Flash the main transcript border instead.
        if (_mainTranscriptVisible
            && ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, thread)
            && thread.Document.Parent is RichTextBox mainOwner
            && (mainOwner == OutputTextBox || IsPrimaryAgentTranscriptBox(mainOwner)))
        {
            SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                $"OpenSecondaryPanel skipped main-owned thread={thread.ThreadId} agent={agent.Name} selectedMain={thread.IsSelected}");
            SyncSelectionControllerWithUiState("OpenSecondaryPanel.mainOwner");
            FlashGlowHighlight(MainTranscriptBorder, ColorFromHex(agent.AccentColorHex));
            return;
        }

        var sw = Stopwatch.StartNew();
        var entry = CreateSecondaryTranscriptPanel(agent, thread);
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_OPEN CreatePanel={sw.ElapsedMilliseconds}ms thread={thread.ThreadId} turns={thread.SavedTurns.Count}");
        sw.Restart();
        entry.IsAutoOpenedInMultiMode = isAutoOpenedInMultiMode;
        _secondaryTranscripts.Add(entry);
        EnsureTranscriptTitleRefreshTimerRunning();
        thread.IsSecondaryPanelOpen = true;
        UpdateCompletedTimeFooters();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_OPEN UpdateFooters={sw.ElapsedMilliseconds}ms");
        SquadDashTrace.Write(TraceCategory.TranscriptPanels,
            $"OpenSecondaryPanel opened thread={thread.ThreadId} agent={agent.Name} seq={thread.SequenceNumber} auto={isAutoOpenedInMultiMode} title=\"{entry.TitleBlock.Text}\"");
        sw.Restart();
        SyncSelectionControllerWithUiState("OpenSecondaryPanel.opened");
        SyncTranscriptTargetIndicators();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_OPEN SyncState={sw.ElapsedMilliseconds}ms");
        sw.Restart();
        ClearAllTranscriptSelections();
        ScheduleGridRebuild();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_OPEN RebuildGrid=scheduled");
        FlashGlowHighlight(entry.PanelBorder, ColorFromHex(agent.AccentColorHex));
        _ = Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () =>
        {
            try { entry.ScrollController.ScrollToBottom(); }
            catch (Exception ex) { HandleUiCallbackException("SecondaryPanel.InitialScroll", ex); }
        });
    }

    private void CloseSecondaryPanel(SecondaryTranscriptEntry entry)
    {
        CancelAutoCloseCountdown(entry);
        var sw = Stopwatch.StartNew();
        if (ReferenceEquals(entry.TranscriptBox.Document, entry.Thread.Document))
            entry.TranscriptBox.Document = CreateEmptyTranscriptDocument();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_CLOSE DetachDoc={sw.ElapsedMilliseconds}ms thread={entry.Thread.ThreadId}");
        SquadDashTrace.Write(TraceCategory.TranscriptPanels,
            $"CloseSecondaryPanel closing thread={entry.Thread.ThreadId} agent={entry.Agent.Name} seq={entry.Thread.SequenceNumber} title=\"{entry.TitleBlock.Text}\"");
        _secondaryTranscripts.Remove(entry);
        entry.Thread.IsSecondaryPanelOpen = false;
        SyncThreadChip(entry.Thread);
        if (_secondaryTranscripts.Count == 0)
            _transcriptTitleRefreshTimer?.Stop();
        sw.Restart();
        UpdateCompletedTimeFooters();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_CLOSE UpdateFooters={sw.ElapsedMilliseconds}ms");
        sw.Restart();
        SyncSelectionControllerWithUiState("CloseSecondaryPanel.closed");
        SyncTranscriptTargetIndicators();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_CLOSE SyncState={sw.ElapsedMilliseconds}ms");
        sw.Restart();
        ClearAllTranscriptSelections();
        try { entry.TranscriptBox?.Selection.Select(entry.TranscriptBox.Document?.ContentStart, entry.TranscriptBox.Document?.ContentStart); } catch { }
        ScheduleGridRebuild();
        SquadDashTrace.Write(TraceCategory.Performance, $"PANEL_CLOSE RebuildGrid=scheduled");
    }

    private void ShowMainTranscript()
    {
        if (_mainTranscriptVisible) return; // already visible — no structural change needed
        _mainTranscriptVisible = true;
        _selectionController.SetMainVisible(true);
        MainTranscriptBorder.Visibility = Visibility.Visible;
        SyncSelectionControllerWithUiState("ShowMainTranscript");
        SyncTranscriptTargetIndicators();
        ScheduleGridRebuild();
    }

    private void HideMainTranscript()
    {
        if (!_mainTranscriptVisible) return; // already hidden — no structural change needed
        _mainTranscriptVisible = false;
        _selectionController.SetMainVisible(false);
        MainTranscriptBorder.Visibility = Visibility.Collapsed;
        SyncSelectionControllerWithUiState("HideMainTranscript");
        SyncTranscriptTargetIndicators();
        ScheduleGridRebuild();
    }

    private void HandleActiveAgentCountdownCheck()
    {
        foreach (var entry in _secondaryTranscripts.ToList())
        {
            if (!entry.IsAutoOpenedInMultiMode)
            {
                CancelAutoCloseCountdown(entry);
                continue;
            }

            var isStillActive = _backgroundTaskPresenter.IsThreadActiveForDisplay(entry.Thread);
            if (isStillActive)
            {
                // Agent is still running — stop any active countdown without permanently
                // cancelling, so the countdown can restart once the agent finishes.
                entry.CountdownStarted = false;
                entry.CountdownTimer?.Stop();
                entry.CountdownTimer = null;
                entry.CountdownSecondsRemaining = 0;
                entry.PostponeTimer?.Stop();
                entry.PostponeTimer = null;
                if (entry.CountdownOverlay is not null)
                {
                    entry.ContentGrid.Children.Remove(entry.CountdownOverlay);
                    entry.CountdownOverlay = null;
                }
                continue;
            }

            if (entry.CountdownTimer is null)
                StartAutoCloseCountdown(entry);
        }
    }

    /// <summary>
    /// Clears the selection on all transcript RichTextBoxes that do not currently
    /// have keyboard focus. Called when the panel layout changes (panel open/close)
    /// to prevent the inactive-selection adorner from rendering with stale coordinates
    /// across the layout reflow.
    /// </summary>
    private void ClearAllTranscriptSelections()
    {
        void TryClear(RichTextBox rtb)
        {
            if (rtb is null || rtb.IsKeyboardFocusWithin) return;
            try
            {
                var doc = rtb.Document;
                if (doc is not null)
                    rtb.Selection.Select(doc.ContentStart, doc.ContentStart);
            }
            catch { /* TextPointers can be temporarily invalid during document transitions */ }
        }

        TryClear(OutputTextBox);
        foreach (var entry in _secondaryTranscripts)
            TryClear(entry.TranscriptBox);
        foreach (var entry in _primaryAgentTranscriptHosts.Values)
            TryClear(entry.TranscriptBox);
    }

    private void ScheduleGridRebuild()
    {
        _pendingGridRebuildViewportAnchor ??= TryCaptureMainTranscriptViewportAnchor("grid-rebuild");
        if (_gridRebuildPending) return;
        _gridRebuildPending = true;
        _ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, () =>
        {
            _gridRebuildPending = false;
            var anchor = _pendingGridRebuildViewportAnchor;
            _pendingGridRebuildViewportAnchor = null;
            var sw = Stopwatch.StartNew();
            RebuildTranscriptPanelsGrid();
            SquadDashTrace.Write(TraceCategory.Performance, $"REBUILD_GRID (coalesced): {sw.ElapsedMilliseconds}ms panels={_secondaryTranscripts.Count}");
            if (anchor is not null)
                QueueRestoreMainTranscriptViewportAnchor(anchor);
        });
    }

    private void RebuildTranscriptPanelsGrid()
    {
        // Skip teardown entirely when the grid is already in the correct single-column state.
        // Children.Clear() removes MainTranscriptBorder from the visual tree, which discards
        // WPF's StructuralCache for OutputTextBox — defeating the coordinator-caching strategy.
        if (_secondaryTranscripts.Count == 0
            && TranscriptPanelsGrid.Children.Count == 1
            && ReferenceEquals(TranscriptPanelsGrid.Children[0], MainTranscriptBorder))
            return;

        // Remove all children (DocsSplitter and DocsPanel are at the root grid level, not in TranscriptPanelsGrid)
        TranscriptPanelsGrid.Children.Clear();
        TranscriptPanelsGrid.ColumnDefinitions.Clear();

        if (_secondaryTranscripts.Count == 0)
        {
            // Only main panel — always give it the full column.
            TranscriptPanelsGrid.ColumnDefinitions.Add(
                new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

            Grid.SetColumn(MainTranscriptBorder, 0);
            TranscriptPanelsGrid.Children.Add(MainTranscriptBorder);

            return;
        }

        // Build the ordered list of panels to display.
        // Main is included only when _mainTranscriptVisible; its Visibility property
        // was already set by ShowMainTranscript / HideMainTranscript — do NOT touch it here.
        var panels = new List<UIElement>();
        if (_mainTranscriptVisible)
            panels.Add(MainTranscriptBorder);
        foreach (var entry in _secondaryTranscripts)
            panels.Add(entry.PanelBorder);

        // Column layout: panel(1*), splitter(8px), panel(1*), splitter(8px), ...
        for (int i = 0; i < panels.Count; i++)
        {
            TranscriptPanelsGrid.ColumnDefinitions.Add(new ColumnDefinition
            {
                Width = new GridLength(1, GridUnitType.Star),
                MinWidth = 0
            });
            if (i < panels.Count - 1)
                TranscriptPanelsGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(8) });
        }

        for (int i = 0; i < panels.Count; i++)
        {
            Grid.SetColumn(panels[i], i * 2);
            TranscriptPanelsGrid.Children.Add(panels[i]);
        }

        for (int i = 0; i < panels.Count - 1; i++)
        {
            var splitter = new GridSplitter
            {
                Width = 8,
                HorizontalAlignment = HorizontalAlignment.Stretch,
                VerticalAlignment = VerticalAlignment.Stretch,
                Background = Brushes.Transparent,
                ResizeBehavior = GridResizeBehavior.PreviousAndNext,
                ResizeDirection = GridResizeDirection.Columns
            };
            splitter.DragCompleted += (_, _) => ClampTranscriptColumnWidths();
            Grid.SetColumn(splitter, i * 2 + 1);
            TranscriptPanelsGrid.Children.Add(splitter);
        }
    }

    private void RefreshSecondaryTranscriptEntries()
    {
        var seenThreadIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var entry in _secondaryTranscripts.ToList())
        {
            if (!seenThreadIds.Add(entry.Thread.ThreadId))
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptEntries closing duplicate panel thread={entry.Thread.ThreadId} title=\"{entry.TitleBlock.Text}\"");
                CloseSecondaryPanel(entry);
                continue;
            }

            var currentCard = FindAgentCardForThread(entry.Thread);
            if (currentCard is null)
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptEntries closing stale panel thread={entry.Thread.ThreadId} title=\"{entry.TitleBlock.Text}\" reason=no-card");
                CloseSecondaryPanel(entry);
                continue;
            }

            var hasAlternateMeaningfulThread = currentCard.Threads.Any(thread =>
                !ReferenceEquals(thread, entry.Thread) &&
                AgentThreadRegistry.HasMeaningfulThreadTranscript(thread));
            if (!AgentThreadRegistry.HasMeaningfulThreadTranscript(entry.Thread) && hasAlternateMeaningfulThread)
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptEntries closing empty placeholder panel thread={entry.Thread.ThreadId} agent={currentCard.Name} reason=alternate-meaningful-thread");
                CloseSecondaryPanel(entry);
                continue;
            }

            if (!ReferenceEquals(entry.Agent, currentCard))
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptEntries remapped panel thread={entry.Thread.ThreadId} oldAgent={entry.Agent.Name} newAgent={currentCard.Name}");
                entry.Agent = currentCard;
            }

            var newTitle = BuildSecondaryTranscriptTitle(currentCard, entry.Thread);
            if (!string.Equals(entry.TitleBlock.Text, newTitle, StringComparison.Ordinal))
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptEntries retitled thread={entry.Thread.ThreadId} old=\"{entry.TitleBlock.Text}\" new=\"{newTitle}\"");
                entry.TitleBlock.Text = newTitle;
            }

            entry.Thread.IsSecondaryPanelOpen = true;
        }
    }

    private void SyncSelectionControllerWithUiState(string reason)
    {
        _selectionController.ReconcilePanels(
            _secondaryTranscripts.Select(entry => (entry.Agent, entry.Thread)),
            _mainTranscriptVisible);

        var visibleThread = _mainTranscriptVisible
            ? (_selectedTranscriptThread?.ThreadId ?? CoordinatorThread.ThreadId)
            : "(hidden)";
        var panels = _secondaryTranscripts.Count == 0
            ? "(none)"
            : string.Join(", ", _secondaryTranscripts.Select(entry =>
                $"{entry.Agent.Name}:{entry.Thread.ThreadId}:seq{entry.Thread.SequenceNumber}:auto={entry.IsAutoOpenedInMultiMode}"));
        SquadDashTrace.Write(TraceCategory.TranscriptPanels,
            $"SyncSelectionControllerWithUiState reason={reason} mainVisible={_mainTranscriptVisible} selectedMain={visibleThread} panels={panels}");
    }

    private void ClampTranscriptColumnWidths()
    {
        double totalWidth = TranscriptPanelsGrid.ActualWidth;
        double minWidth = totalWidth / 7.0;
        for (int i = 0; i < TranscriptPanelsGrid.ColumnDefinitions.Count; i += 2)
        {
            var col = TranscriptPanelsGrid.ColumnDefinitions[i];
            if (col.ActualWidth < minWidth)
                col.Width = new GridLength(minWidth);
        }
    }

    private SecondaryTranscriptEntry CreateSecondaryTranscriptPanel(AgentStatusCard agent, TranscriptThreadState thread)
    {
        var titleText = BuildSecondaryTranscriptTitle(agent, thread);
        var baseDisplayName = AbbreviateAgentName(
            AgentThreadRegistry.ResolveSecondaryTranscriptDisplayName(thread, agent.Name));
        var titleBlock = new TextBlock
        {
            Text = titleText,
            ToolTip = BuildGpaTooltip(baseDisplayName),
            FontWeight = FontWeights.SemiBold,
            VerticalAlignment = VerticalAlignment.Center
        };
        titleBlock.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeSubtitle");
        titleBlock.SetResourceReference(TextBlock.ForegroundProperty, "LabelText");

        var navUp = CreateSecondaryNavButton(up: true);
        var navDown = CreateSecondaryNavButton(up: false);

        var closeBtn = new Button
        {
            Width = 22,
            Height = 22,
            Padding = new Thickness(0),
            Margin = new Thickness(8, 0, 0, 0),
            ToolTip = "Close panel"
        };
        closeBtn.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        var closeViewbox = new Viewbox { Width = 10, Height = 10 };
        var closePath = new System.Windows.Shapes.Path
        {
            Data = Geometry.Parse("M 1,1 L 9,9 M 9,1 L 1,9"),
            StrokeThickness = 1.8,
            StrokeStartLineCap = PenLineCap.Round,
            StrokeEndLineCap = PenLineCap.Round
        };
        closePath.SetResourceReference(System.Windows.Shapes.Shape.StrokeProperty, "LabelText");
        closeViewbox.Child = closePath;
        closeBtn.Content = closeViewbox;

        var sw = Stopwatch.StartNew();
        var rtb = new RichTextBox
        {
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            IsDocumentEnabled = true,
            IsReadOnly = true,
            IsUndoEnabled = false,
            IsInactiveSelectionHighlightEnabled = false,
            FontSize = _transcriptFontSize,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto
        };
        rtb.SetResourceReference(RichTextBox.ForegroundProperty, "LabelText");
        rtb.SetResourceReference(RichTextBox.SelectionBrushProperty, "DocEditorSelectionBrush");
        rtb.SetResourceReference(RichTextBox.SelectionOpacityProperty, "DocEditorSelectionOpacity");
        SquadDashTrace.Write(TraceCategory.Performance, $"CREATE_PANEL RtbCreated={sw.ElapsedMilliseconds}ms turns={thread.SavedTurns.Count} docBlocks={thread.Document.Blocks.Count}");
        sw.Restart();
        rtb.PreviewMouseRightButtonDown += (_, e) =>
        {
            try
            {
                var menu = new ContextMenu();
                menu.SetResourceReference(ContextMenu.StyleProperty, "ThemedContextMenuStyle");

                var hasSelection = !rtb.Selection.IsEmpty;

                if (hasSelection)
                {
                    var followUpItem = new MenuItem { Header = "Add to chat" };
                    followUpItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                    followUpItem.Click += (_, _) => AttachTranscriptFollowUp(rtb);
                    menu.Items.Add(followUpItem);

                    var addToNotesItem = new MenuItem { Header = "Add to Notes" };
                    addToNotesItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                    addToNotesItem.Click += (_, _) => {
                        var text = TranscriptCopyService.BuildSelectionMarkdown(rtb);
                        if (!string.IsNullOrWhiteSpace(text))
                            AddNoteFromText(text);
                        rtb.Selection.Select(rtb.CaretPosition, rtb.CaretPosition);
                    };
                    menu.Items.Add(addToNotesItem);

                    var sep = new Separator();
                    sep.SetResourceReference(Separator.StyleProperty, "ThemedMenuSeparatorStyle");
                    menu.Items.Add(sep);
                }

                var copyItem = new MenuItem { Header = "_Copy" };
                copyItem.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
                copyItem.IsEnabled = hasSelection;
                copyItem.Click += (_, _) =>
                {
                    var text = TranscriptCopyService.BuildSelectionText(rtb);
                    if (!string.IsNullOrEmpty(text))
                        SetClipboardTextWithRetry(text);
                };
                menu.Items.Add(copyItem);

                menu.PlacementTarget = rtb;
                menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
                menu.IsOpen = true;
                e.Handled = true;
            }
            catch (Exception ex)
            {
                HandleUiCallbackException("SecondaryTranscript.PreviewMouseRightButtonDown", ex);
            }
        };

        var scrollToBottomChevron = new System.Windows.Shapes.Path
        {
            Data = Geometry.Parse("M 1,3 L 9,11 L 17,3"),
            StrokeThickness = 2.5,
            StrokeLineJoin = PenLineJoin.Round,
            StrokeStartLineCap = PenLineCap.Round,
            StrokeEndLineCap = PenLineCap.Round,
            Fill = Brushes.Transparent,
        };
        scrollToBottomChevron.SetBinding(
            System.Windows.Shapes.Shape.StrokeProperty,
            new System.Windows.Data.Binding("Foreground")
            {
                RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.FindAncestor, typeof(Button), 1)
            });
        var scrollToBottomViewbox = new Viewbox
        {
            Width = 16,
            Height = 13,
            Stretch = Stretch.Uniform,
            Child = scrollToBottomChevron
        };

        var scrollToBottomOverlay = new Button
        {
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Bottom,
            Margin = new Thickness(0, 0, 0, 24),
            Width = 36,
            Height = 36,
            Focusable = false,
            IsTabStop = false,
            Opacity = 0,
            Visibility = Visibility.Collapsed,
            Content = scrollToBottomViewbox,
        };
        Panel.SetZIndex(scrollToBottomOverlay, 10);
        scrollToBottomOverlay.SetResourceReference(Control.StyleProperty, "ScrollToBottomButtonStyle");
        scrollToBottomOverlay.ToolTip = "Scroll to bottom (Ctrl+End)";

        var scrollController = new TranscriptScrollController(rtb, Dispatcher);
        scrollController.SetScrollToBottomButton(scrollToBottomOverlay);
        scrollToBottomOverlay.Click += (_, _) =>
        {
            try { scrollController.ScrollToBottom(); }
            catch (Exception ex) { HandleUiCallbackException("SecondaryPanel.ScrollToBottom", ex); }
        };

        var headerDock = new DockPanel { LastChildFill = false, Margin = new Thickness(0, 0, 0, 12) };
        var rightStack = new StackPanel
        {
            Orientation = Orientation.Horizontal,
            VerticalAlignment = VerticalAlignment.Center
        };
        rightStack.Children.Add(navUp);
        rightStack.Children.Add(navDown);
        rightStack.Children.Add(closeBtn);
        DockPanel.SetDock(rightStack, Dock.Right);
        headerDock.Children.Add(rightStack);
        headerDock.Children.Add(titleBlock);

        var contentGrid = new Grid();
        contentGrid.Children.Add(rtb);
        contentGrid.Children.Add(scrollToBottomOverlay);

        var outerDock = new DockPanel { LastChildFill = true };
        DockPanel.SetDock(headerDock, Dock.Top);
        outerDock.Children.Add(headerDock);
        outerDock.Children.Add(contentGrid);

        var outerBorder = new Border
        {
            Padding = new Thickness(14),
            CornerRadius = new CornerRadius(18),
            BorderThickness = new Thickness(1),
            Margin = new Thickness(0)
        };
        outerBorder.SetResourceReference(Border.BackgroundProperty, "TranscriptSurface");
        outerBorder.SetResourceReference(Border.BorderBrushProperty, "TranscriptBorder");
        outerBorder.Child = outerDock;

        var entry = new SecondaryTranscriptEntry
        {
            Agent = agent,
            Thread = thread,
            TranscriptBox = rtb,
            TitleBlock = titleBlock,
            NavUpButton = navUp,
            NavDownButton = navDown,
            CloseButton = closeBtn,
            PanelBorder = outerBorder,
            ContentGrid = contentGrid,
            ScrollController = scrollController
        };

        // Postpone auto-close on mouse move/scroll; permanently cancel on click
        outerBorder.MouseMove += (_, _) => { try { if (entry.CountdownStarted && !entry.CountdownCancelled) PostponeAutoCloseCountdown(entry); } catch { } };
        outerBorder.PreviewMouseDown += (_, _) => { try { CancelAutoCloseCountdown(entry); } catch { } };
        outerBorder.PreviewMouseWheel += (_, _) => { try { if (entry.CountdownStarted && !entry.CountdownCancelled) PostponeAutoCloseCountdown(entry); } catch { } };

        closeBtn.Click += (_, _) => { try { CloseSecondaryPanel(entry); } catch (Exception ex) { HandleUiCallbackException("SecondaryPanel.Close", ex); } };

        rtb.PreviewMouseWheel += (_, e) =>
        {
            try
            {
                if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return;
                _transcriptFontSize = Math.Clamp(
                    _transcriptFontSize + (e.Delta > 0 ? TranscriptFontSizeStep : -TranscriptFontSizeStep),
                    TranscriptFontSizeMin,
                    TranscriptFontSizeMax);
                ApplyTranscriptFontSize();
                _settingsSnapshot = _settingsStore.SaveTranscriptFontSize(_transcriptFontSize);
                e.Handled = true;
            }
            catch (Exception ex) { HandleUiCallbackException("SecondaryPanel.PreviewMouseWheel", ex); }
        };

        if (thread.Document.Parent is RichTextBox currentOwner && currentOwner != rtb)
            currentOwner.Document = CreateEmptyTranscriptDocument();
        rtb.Document = thread.Document;
        if (_conversationManager.HasPendingRender(thread))
            _ = _conversationManager.EnsureAgentThreadRenderedAsync(thread);
        SquadDashTrace.Write(TraceCategory.Performance, $"CREATE_PANEL DocAssigned+Border={sw.ElapsedMilliseconds}ms");

        return entry;
    }

    private static Button CreateSecondaryNavButton(bool up)
    {
        var btn = new Button
        {
            Width = 26,
            Height = 22,
            Padding = new Thickness(0),
            Margin = new Thickness(0, 0, up ? 4 : 0, 0),
            IsEnabled = false
        };
        btn.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        btn.ToolTip = up ? "Previous prompt" : "Next prompt";
        var pathData = up ? "M 1,8 L 5,2 L 9,8" : "M 1,2 L 5,8 L 9,2";
        var vb = new Viewbox { Width = 12, Height = 10, Stretch = Stretch.Uniform, Opacity = 0.8 };
        var path = new System.Windows.Shapes.Path
        {
            Data = Geometry.Parse(pathData),
            StrokeThickness = 2,
            StrokeLineJoin = PenLineJoin.Round,
            StrokeStartLineCap = PenLineCap.Round,
            StrokeEndLineCap = PenLineCap.Round,
            Fill = Brushes.Transparent
        };
        path.SetResourceReference(System.Windows.Shapes.Shape.StrokeProperty, "LabelText");
        vb.Child = path;
        btn.Content = vb;
        return btn;
    }

    private void LoadAgentTranscriptIntoBox(AgentStatusCard agent, RichTextBox rtb)
    {
        var thread = GetTranscriptThreadForAgent(agent);

        var doc = thread.Document;

        // FlowDocument can only belong to one RichTextBox at a time.  If it is
        // already parented to a different RTB (safety net — Case 1 in OpenSecondaryPanel
        // should have caught the OutputTextBox conflict before we get here), detach it
        // from the current owner by giving that owner a temporary empty document.
        if (doc.Parent is RichTextBox currentOwner && currentOwner != rtb)
        {
            currentOwner.Document = CreateEmptyTranscriptDocument();
        }

        rtb.Document = doc;
        if (_conversationManager.HasPendingRender(thread))
            _ = _conversationManager.EnsureAgentThreadRenderedAsync(thread);
    }

    private void EnsureTranscriptTitleRefreshTimerRunning()
    {
        if (_transcriptTitleRefreshTimer is null)
        {
            _transcriptTitleRefreshTimer = new DispatcherTimer(TimeSpan.FromSeconds(60), DispatcherPriority.Background,
                (_, _) =>
                {
                    foreach (var e in _secondaryTranscripts.ToList())
                        RefreshSecondaryTranscriptTitle(e.Thread);
                },
                Dispatcher);
        }
        if (!_transcriptTitleRefreshTimer.IsEnabled)
            _transcriptTitleRefreshTimer.Start();
    }

    private string AbbreviateAgentName(string name) =>
        name.Replace("General Purpose Agent", "GPA", StringComparison.OrdinalIgnoreCase)
            .Replace("general purpose agent", "GPA");

    private static string? BuildGpaTooltip(string displayName) =>
        displayName.Contains("GPA", StringComparison.Ordinal)
            ? displayName.Replace("GPA", "General Purpose Agent", StringComparison.Ordinal)
            : null;

    private string BuildSecondaryTranscriptTitle(AgentStatusCard agent, TranscriptThreadState thread)
    {
        var displayName = AbbreviateAgentName(
            AgentThreadRegistry.ResolveSecondaryTranscriptDisplayName(thread, agent.Name));
        return thread.PromptParagraphs.Count > 0
            ? $"{displayName} - {FormatRelativeTime(thread.PromptParagraphs[0].Timestamp)}"
            : displayName;
    }

    private void RefreshSecondaryTranscriptTitle(TranscriptThreadState thread)
    {
        foreach (var entry in _secondaryTranscripts.Where(entry => ReferenceEquals(entry.Thread, thread)))
        {
            var title = BuildSecondaryTranscriptTitle(entry.Agent, thread);
            if (!string.Equals(entry.TitleBlock.Text, title, StringComparison.Ordinal))
            {
                SquadDashTrace.Write(TraceCategory.TranscriptPanels,
                    $"RefreshSecondaryTranscriptTitle thread={thread.ThreadId} old=\"{entry.TitleBlock.Text}\" new=\"{title}\"");
                entry.TitleBlock.Text = title;
                var baseDisplayName = AbbreviateAgentName(
                    AgentThreadRegistry.ResolveSecondaryTranscriptDisplayName(thread, entry.Agent.Name));
                entry.TitleBlock.ToolTip = BuildGpaTooltip(baseDisplayName);
            }
        }
    }

    private TranscriptThreadState GetTranscriptThreadForAgent(AgentStatusCard agent) =>
        agent.IsLeadAgent
            ? CoordinatorThread
            : _agentThreadRegistry.GetOrCreateAgentDisplayThread(agent);

    private void SyncTranscriptTargetIndicators()
    {
        var visibleMainThread = _mainTranscriptVisible
            ? _selectedTranscriptThread ?? CoordinatorThread
            : null;

        foreach (var card in _agents)
        {
            if (card.IsLeadAgent)
            {
                card.IsTranscriptTargetSelected = visibleMainThread is not null &&
                                                  ReferenceEquals(visibleMainThread, CoordinatorThread);
                continue;
            }

            card.IsTranscriptTargetSelected =
                card.Threads.Any(thread => ReferenceEquals(thread, visibleMainThread)) ||
                _secondaryTranscripts.Any(entry =>
                    ReferenceEquals(entry.Thread, visibleMainThread) ||
                    ReferenceEquals(entry.Agent, card) ||
                    ReferenceEquals(FindAgentCardForThread(entry.Thread), card));
        }
    }

    private void CancelAutoCloseCountdown(SecondaryTranscriptEntry entry)
    {
        entry.CountdownTimer?.Stop();
        entry.CountdownTimer = null;
        entry.CountdownSecondsRemaining = 0;
        entry.PostponeTimer?.Stop();
        entry.PostponeTimer = null;
        entry.CountdownCancelled = true;
        if (entry.CountdownOverlay is not null)
        {
            entry.ContentGrid.Children.Remove(entry.CountdownOverlay);
            entry.CountdownOverlay = null;
        }
    }

    private void PostponeAutoCloseCountdown(SecondaryTranscriptEntry entry)
    {
        // If permanently cancelled by a click, do nothing
        if (entry.CountdownCancelled)
            return;

        // Stop the 10-second countdown if running, remove overlay
        if (entry.CountdownTimer is not null)
        {
            entry.CountdownTimer.Stop();
            entry.CountdownTimer = null;
            entry.CountdownSecondsRemaining = 0;
        }
        if (entry.CountdownOverlay is not null)
        {
            entry.ContentGrid.Children.Remove(entry.CountdownOverlay);
            entry.CountdownOverlay = null;
        }

        // Reset (or start) the 2-minute postpone timer
        entry.PostponeTimer?.Stop();
        entry.PostponeTimer = null;

        var postponeTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(2) };
        postponeTimer.Tick += (_, _) =>
        {
            postponeTimer.Stop();
            entry.PostponeTimer = null;
            // 2-minute postpone expired — restart the 10-second countdown
            StartAutoCloseCountdown(entry);
        };
        entry.PostponeTimer = postponeTimer;
        postponeTimer.Start();
    }

    private void StartAutoCloseCountdown(SecondaryTranscriptEntry entry)
    {
        entry.CountdownStarted = true;
        if (entry.CountdownCancelled)
            return;

        entry.CountdownSecondsRemaining = 10;

        var overlay = new TextBlock
        {
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Bottom,
            Margin = new Thickness(0, 0, 0, 8),
            FontSize = (double)Application.Current.Resources["FontSizeNormal"],
            IsHitTestVisible = false
        };
        overlay.SetValue(Panel.ZIndexProperty, 20);
        overlay.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");
        overlay.Text = $"Closing this transcript in {entry.CountdownSecondsRemaining} seconds";
        entry.CountdownOverlay = overlay;
        entry.ContentGrid.Children.Add(overlay);

        var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
        timer.Tick += (_, _) =>
        {
            entry.CountdownSecondsRemaining--;
            if (entry.CountdownSecondsRemaining <= 0)
            {
                timer.Stop();
                CloseSecondaryPanel(entry);
            }
            else if (entry.CountdownOverlay is not null)
            {
                entry.CountdownOverlay.Text = entry.CountdownSecondsRemaining == 1
                    ? "Closing this transcript in 1 second"
                    : $"Closing this transcript in {entry.CountdownSecondsRemaining} seconds";
            }
        };
        entry.CountdownTimer = timer;
        timer.Start();
    }

    private void FlashGlowHighlight(Border targetBorder, System.Windows.Media.Color accentColor)
    {
        var glow = new System.Windows.Media.Effects.DropShadowEffect
        {
            Color = accentColor,
            BlurRadius = 0,
            ShadowDepth = 0,
            Opacity = 0
        };
        targetBorder.Effect = glow;

        var blurAnim = new System.Windows.Media.Animation.DoubleAnimation(0, 24, TimeSpan.FromMilliseconds(200))
        {
            AutoReverse = true,
            RepeatBehavior = new System.Windows.Media.Animation.RepeatBehavior(2)
        };
        var opacityAnim = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))
        {
            AutoReverse = true,
            RepeatBehavior = new System.Windows.Media.Animation.RepeatBehavior(2)
        };

        blurAnim.Completed += (_, _) => targetBorder.Effect = null;

        glow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.BlurRadiusProperty, blurAnim);
        glow.BeginAnimation(System.Windows.Media.Effects.DropShadowEffect.OpacityProperty, opacityAnim);
    }

    private static System.Windows.Media.Color ColorFromHex(string hex)
    {
        try { return (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex); }
        catch { return System.Windows.Media.Colors.CornflowerBlue; }
    }

    private void OpenTranscriptThread(string target, bool scrollToStart)
    {
        if (string.Equals(target, "coordinator", StringComparison.OrdinalIgnoreCase))
        {
            if (!_mainTranscriptVisible)
                ShowMainTranscript();
            SelectTranscriptThread(CoordinatorThread, scrollToStart);
            return;
        }

        var thread = _agentThreadRegistry.ThreadOrder.FirstOrDefault(candidate =>
            string.Equals(candidate.ThreadId, target, StringComparison.OrdinalIgnoreCase));
        if (thread is null)
            return;

        var card = FindAgentCardForThread(thread);
        if (card is null)
            return;

        OpenSecondaryPanel(card, thread, isAutoOpenedInMultiMode: false);
    }

    internal static string SanitizeResponseText(string? text) =>
        TranscriptTextUtilities.SanitizeResponseText(text);

    private static string StripHostCommandBlock(string text)
    {
        if (HostCommandParser.TryExtract(text, out var body, out _))
            return body;
        return text;
    }

    private static string StripAwaitInputSentinel(string text) =>
        text.Replace(PromptExecutionController.QueueAwaitInputSentinel, string.Empty,
                     StringComparison.Ordinal);

    internal static string? SanitizeResponseTextOrNull(string? text) =>
        TranscriptTextUtilities.SanitizeResponseTextOrNull(text);

    internal static string GetSanitizedTurnResponseText(TranscriptTurnView? turn) =>
        TranscriptTextUtilities.GetSanitizedTurnResponseText(turn);

    private static string? GetSanitizedTurnResponseTextOrNull(TranscriptTurnView? turn) =>
        SanitizeResponseTextOrNull(turn?.ResponseTextBuilder.ToString());

    internal static string BuildThreadPreview(string text) =>
        TranscriptTextUtilities.BuildThreadPreview(text);

    private static string RemoveQuickReplySuffix(string text)
    {
        return TryExtractQuickReplyOptions(text, out var body, out _) ? body : text;
    }

    private static bool TryExtractQuickReplyOptions(
        string text,
        out string body,
        out string[] options)
    {
        return QuickReplyOptionParser.TryExtract(text, out body, out options);
    }

    private static bool TryExtractQuickReplyOptionMetadata(
        string text,
        out string body,
        out QuickReplyOptionMetadata[] options)
    {
        return QuickReplyOptionParser.TryExtractWithMetadata(text, out body, out options);
    }

    private void AppendResponseSegment(string text, bool startOnNewLine = false) =>
        AppendResponseSegment(CoordinatorThread, text, startOnNewLine);

    private void AppendResponseSegment(
        TranscriptThreadState thread,
        string text,
        bool startOnNewLine = false)
    {
        if (thread.CurrentTurn is null || string.IsNullOrEmpty(text))
            return;

        var entry = GetOrCreateResponseEntry(thread.CurrentTurn);
        if (startOnNewLine && entry.RawTextBuilder.Length > 0)
            entry.RawTextBuilder.Append('\n');

        entry.RawTextBuilder.Append(text);
        QueueResponseEntryRender(
            entry,
            flushImmediately: startOnNewLine || ShouldRenderResponseEntryImmediately(entry, text));
    }

    private TranscriptTurnView BeginTranscriptTurn(string prompt) =>
        BeginTranscriptTurn(CoordinatorThread, prompt);

    private TranscriptTurnView BeginTranscriptTurn(TranscriptThreadState thread, string? prompt)
    {
        prompt ??= string.Empty;
        thread.CurrentTurn = CreateTranscriptTurnView(thread, prompt, DateTimeOffset.Now, thinkingExpanded: true);
        if (ReferenceEquals(thread, CoordinatorThread))
            SquadDashTrace.Write("Persistence", $"Coordinator CurrentTurn created promptChars={prompt.Length} started={thread.CurrentTurn.StartedAt:O}");
        thread.ResponseStreamed = false;

        // Prompt submission scrolls the viewport so the user can see what was just submitted.
        // For direct sends (Enter/Send/PTT) always force to bottom even if scrolled away.
        // For queue-drain auto-dispatch, only scroll if the user is already near the bottom.
        if (!string.IsNullOrEmpty(prompt) && ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, thread))
        {
            EnsureThreadFooterAtEnd(thread);
            if (_queueDrainActive && ActiveScrollController.IsUserScrolledAway)
                ActiveScrollController.RequestScrollToEnd();
            else
                ActiveScrollController.ForceScrollToBottom();
        }
        else
        {
            ScrollToEndIfAtBottom(thread);
        }

        return thread.CurrentTurn;
    }

    private TranscriptTurnView CreateTranscriptTurnView(
        TranscriptThreadState thread,
        string prompt,
        DateTimeOffset startedAt,
        bool thinkingExpanded,
        bool isSystemInjected = false)
    {
        var topLevelBlocks = new List<Block>();
        var separatorParagraph = CreateTranscriptParagraph(bottomMargin: 2);
        var separatorRun = new Run(ToolTranscriptFormatter.BuildPromptSeparator());
        separatorRun.SetResourceReference(TextElement.ForegroundProperty, "TurnDividerText");
        separatorParagraph.Inlines.Add(separatorRun);
        thread.Document.Blocks.Add(separatorParagraph);
        topLevelBlocks.Add(separatorParagraph);

        if (thread.Kind == TranscriptThreadKind.Agent)
        {
            var startMarkerParagraph = CreateTranscriptParagraph(bottomMargin: 4);
            var startMarkerRun = new Run(ToolTranscriptFormatter.BuildAgentTurnStartMarker(prompt, startedAt))
            {
                FontWeight = FontWeights.SemiBold
            };
            startMarkerRun.SetResourceReference(TextElement.ForegroundProperty, "AgentTaskStartText");
            startMarkerParagraph.Inlines.Add(startMarkerRun);
            thread.Document.Blocks.Add(startMarkerParagraph);
            topLevelBlocks.Add(startMarkerParagraph);
        }

        var promptParagraph = CreateTranscriptParagraph(bottomMargin: 8);
        const string voiceAnnotation = "\n(some or all of this prompt was dictated by voice)";

        // Consume pending attachments captured by ApplyFollowUpHeader for this turn.
        var pendingAttachments = _pendingTranscriptAttachments;
        _pendingTranscriptAttachments = null;

        // Determine the display prompt: strip attachment header prefix if present.
        string displayPrompt = prompt;
        IReadOnlyList<FollowUpAttachment>? attachmentsForViewer = pendingAttachments;
        bool hasAttachments = pendingAttachments?.Count > 0;
        // Overrides the count for historical turns where attachmentsForViewer is null but we
        // can still infer the count from the prompt's attachment header.
        int? historicalAttachmentCount = null;

        if (hasAttachments)
        {
            // Nonce-fenced clipboard blocks (<<<SQUADCLIP:nonce>>>) may contain \n\n inside
            // the clipboard text, so we cannot split on the first \n\n — we must find the
            // matching close fence first, then the \n\n that follows it.
            if (displayPrompt.StartsWith("<<<SQUADCLIP:", StringComparison.Ordinal))
            {
                var bodyStart = StripClipboardFenceHeader(displayPrompt);
                if (bodyStart >= 0)
                    displayPrompt = displayPrompt[bodyStart..];
            }
            else if (displayPrompt.StartsWith("The user has attached", StringComparison.Ordinal) ||
                     displayPrompt.StartsWith("<attachment ", StringComparison.Ordinal))
            {
                // New typed-attachment format: scan past all XML blocks to find the user text.
                var bodyStart = StripTypedAttachmentHeaders(displayPrompt);
                if (bodyStart >= 0)
                    displayPrompt = displayPrompt[bodyStart..];
            }
            else
            {
                // Strip the header block (everything before the first \n\n separator).
                var nnIdx = displayPrompt.IndexOf("\n\n", StringComparison.Ordinal);
                if (nnIdx >= 0)
                    displayPrompt = displayPrompt[(nnIdx + 2)..];
            }
        }
        else
        {
            // Historical turns: detect attachment header prefix by content pattern.
            // Nonce-fenced clipboard blocks start with <<<SQUADCLIP: — handle before the
            // \n\n-split path because the clipboard content may itself contain \n\n.
            if (displayPrompt.StartsWith("<<<SQUADCLIP:", StringComparison.Ordinal))
            {
                hasAttachments = true;
                var bodyStart = StripClipboardFenceHeader(displayPrompt);
                if (bodyStart >= 0)
                    displayPrompt = displayPrompt[bodyStart..];
                // Content is not reconstructed for historical turns; the 📎 link is shown.
                // SQUADCLIP is always a single clipboard attachment.
                historicalAttachmentCount = 1;
                attachmentsForViewer = null;
            }
            else if (displayPrompt.StartsWith("The user has attached", StringComparison.Ordinal) ||
                     displayPrompt.StartsWith("<attachment ", StringComparison.Ordinal))
            {
                // New typed-attachment format used by recent prompts.
                hasAttachments = true;
                var bodyStart = StripTypedAttachmentHeaders(displayPrompt);
                // Count attachment blocks in the header before stripping so we can show the
                // correct "📎 N attachments" count even though content isn't reconstructed.
                var headerPart = bodyStart >= 0 ? displayPrompt[..bodyStart] : displayPrompt;
                var blockCount = AttachmentBlockFormatter.CountAttachmentBlocks(headerPart);
                historicalAttachmentCount = blockCount > 0 ? blockCount : 1;
                if (bodyStart >= 0)
                    displayPrompt = displayPrompt[bodyStart..];
                attachmentsForViewer = null;
            }
            else
            {
                var nnIdx = displayPrompt.IndexOf("\n\n", StringComparison.Ordinal);
                if (nnIdx > 0)
                {
                    var prefix = displayPrompt[..nnIdx];
                    if (prefix.StartsWith("[Follow-up on ", StringComparison.Ordinal) ||
                        prefix.StartsWith("Regarding this section of the transcript:", StringComparison.Ordinal) ||
                        prefix.Contains("[Attached image:", StringComparison.Ordinal))
                    {
                        hasAttachments = true;
                        displayPrompt  = displayPrompt[(nnIdx + 2)..];

                        // Reconstruct structured attachment objects from saved header lines so the
                        // viewer can show image thumbnails for [Attached image: path] entries.
                        var lines = prefix.Split('\n');
                        var reconstructed = new List<FollowUpAttachment>();
                        foreach (var line in lines)
                        {
                            const string imgPrefix = "[Attached image: ";
                            if (line.StartsWith(imgPrefix, StringComparison.Ordinal) && line.EndsWith("]"))
                            {
                                var imagePath = line[imgPrefix.Length..^1];
                                reconstructed.Add(new FollowUpAttachment("", "Image", null, null, null, ImagePath: imagePath));
                            }
                            else if (!string.IsNullOrWhiteSpace(line))
                            {
                                reconstructed.Add(new FollowUpAttachment("", "Attachment", line, null));
                            }
                        }
                        attachmentsForViewer = reconstructed.Count > 0 ? reconstructed : null;
                    }
                }
            }
        }

        bool hasDictation = displayPrompt.EndsWith(voiceAnnotation, StringComparison.Ordinal);
        var promptBody = hasDictation ? displayPrompt[..^voiceAnnotation.Length] : displayPrompt;

        // Capture the system-injected flag now, before the promptBody guard clears the field,
        // so it is also available for the TranscriptTurnView record (persisted → reload path).
        bool promptIsSystemInjected = isSystemInjected || _pendingPromptIsSystemInjected;
        _pendingPromptIsSystemInjected = false;

        if (!string.IsNullOrEmpty(promptBody))
        {
            // System-injected follow-up prompts (e.g. silent-completion watchdog) are not
            // typed by the user — display a compact system notice instead of the user name
            // and raw prompt text.

            if (promptIsSystemInjected)
            {
                var systemRun = new Run("↩ Following up…");
                systemRun.SetResourceReference(TextElement.ForegroundProperty, "SubtleText");
                promptParagraph.Inlines.Add(systemRun);
            }
            else if (thread.Kind == TranscriptThreadKind.Coordinator && !string.IsNullOrWhiteSpace(_settingsSnapshot.UserName))
            {
                var prefixRun = new Run($"{_settingsSnapshot.UserName}: ") { FontWeight = FontWeights.SemiBold };
                prefixRun.SetResourceReference(TextElement.ForegroundProperty, "UserPromptPrefixText");
                promptParagraph.Inlines.Add(prefixRun);
                AddPromptBodyInlines(promptParagraph.Inlines, promptBody, FontWeights.SemiBold, "UserPromptText");
            }
            else
            {
                var prefix = thread.Kind == TranscriptThreadKind.Coordinator ? null : $"{thread.Title}: ";
                if (prefix is not null)
                {
                    var prefixRun = new Run(prefix) { FontWeight = FontWeights.SemiBold };
                    prefixRun.SetResourceReference(TextElement.ForegroundProperty, "UserPromptPrefixText");
                    promptParagraph.Inlines.Add(prefixRun);
                }
                AddPromptBodyInlines(promptParagraph.Inlines, promptBody, FontWeights.SemiBold, "UserPromptText");
            }

            if (hasAttachments || hasDictation)
            {
                promptParagraph.Inlines.Add(new Run("\n") { FontWeight = FontWeights.Normal });

                if (hasAttachments)
                {
                    var capturedAttachments = attachmentsForViewer ?? Array.Empty<FollowUpAttachment>();
                    var count    = historicalAttachmentCount ?? capturedAttachments.Count;
                    var linkText = count == 1 ? "📎 1 attachment" : $"📎 {count} attachments";
                    var link     = new System.Windows.Documents.Hyperlink(new Run(linkText))
                    {
                        FontWeight      = FontWeights.Normal,
                        TextDecorations = null,
                        Cursor          = System.Windows.Input.Cursors.Hand,
                    };
                    link.SetResourceReference(System.Windows.Documents.TextElement.ForegroundProperty, "SubtleText");
                    link.ToolTip = "This prompt includes attachments. Click to view.";
                    link.Click  += (_, _) =>
                    {
                        if (capturedAttachments.Count > 0)
                            PromptAttachmentViewerWindow.Show(capturedAttachments, CanShowOwnedWindow() ? this : null);
                    };
                    promptParagraph.Inlines.Add(link);
                }

                if (hasDictation)
                {
                    if (hasAttachments)
                        promptParagraph.Inlines.Add(new Run("  ") { FontWeight = FontWeights.Normal });
                    var voiceRun = new Run("(some or all of this prompt was dictated by voice)")
                    {
                        FontWeight = FontWeights.Normal
                    };
                    voiceRun.SetResourceReference(TextElement.ForegroundProperty, "VoiceAnnotationText");
                    promptParagraph.Inlines.Add(voiceRun);
                }
            }
            thread.Document.Blocks.Add(promptParagraph);
            topLevelBlocks.Add(promptParagraph);
            thread.PromptParagraphs.Add(new PromptEntry(promptParagraph, startedAt));
            RefreshSecondaryTranscriptTitle(thread);
            if (ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, thread))
                SyncPromptNavButtons();
        }

        var narrativeSection = new Section();
        thread.Document.Blocks.Add(narrativeSection);
        topLevelBlocks.Add(narrativeSection);

        var view = new TranscriptTurnView(
            thread,
            prompt,
            startedAt,
            narrativeSection,
            topLevelBlocks) {
            IsSystemInjected = promptIsSystemInjected,
        };
        return view;
    }

    /// <summary>
    /// Handles a link click that originated from the transcript — both from AI-rendered
    /// markdown and from user-prompt inline links.
    /// </summary>
    private void HandleTranscriptLinkClick(string target)
    {
        if (target.StartsWith("app://open-loop-md:", StringComparison.OrdinalIgnoreCase))
        {
            var filePath = target["app://open-loop-md:".Length..];
            if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
                System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(filePath) { UseShellExecute = true });
            return;
        }
        if (target.StartsWith("app://open-path:", StringComparison.OrdinalIgnoreCase))
        {
            var rawPath = target["app://open-path:".Length..];
            if (!string.IsNullOrWhiteSpace(rawPath))
                OpenWindowsPath(rawPath);
            return;
        }
        if (target.StartsWith("app://show-rc-panel", StringComparison.OrdinalIgnoreCase))
        {
            ShowRcPanel();
            return;
        }
        if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
            target.StartsWith("chrome://", StringComparison.OrdinalIgnoreCase) ||
            target.StartsWith("edge://", StringComparison.OrdinalIgnoreCase))
            _ = OpenExternalLinkWithCommitCheckAsync(target);
        else
            OpenTranscriptThread(target, scrollToStart: true);
    }

    /// <summary>
    /// Expands environment variables in <paramref name="rawPath"/> and opens it in Explorer.
    /// For files: opens the containing folder with the file selected.
    /// For directories: opens the directory directly.
    /// For non-existent paths: walks up to the nearest existing ancestor and opens that.
    /// </summary>
    private static void OpenWindowsPath(string rawPath)
    {
        var expanded = Environment.ExpandEnvironmentVariables(rawPath);
        if (string.IsNullOrWhiteSpace(expanded)) return;

        try
        {
            if (File.Exists(expanded))
            {
                System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{expanded}\"");
                return;
            }

            if (Directory.Exists(expanded))
            {
                System.Diagnostics.Process.Start("explorer.exe", $"\"{expanded}\"");
                return;
            }

            // Path doesn't exist — climb to the nearest existing ancestor
            var parent = System.IO.Path.GetDirectoryName(expanded);
            while (!string.IsNullOrEmpty(parent) && !Directory.Exists(parent))
                parent = System.IO.Path.GetDirectoryName(parent);

            if (!string.IsNullOrEmpty(parent))
                System.Diagnostics.Process.Start("explorer.exe", $"\"{parent}\"");
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write(TraceCategory.General, $"OpenWindowsPath failed for '{rawPath}': {ex.Message}");
        }
    }
    /// markdown link syntax (<c>[text](url)</c>) in <paramref name="text"/>.
    /// Plain-text segments get a <see cref="Run"/> with the specified style;
    /// link segments become a <see cref="System.Windows.Documents.Hyperlink"/> that
    /// calls <see cref="HandleTranscriptLinkClick"/>.
    /// </summary>
    private void AddPromptBodyInlines(
        System.Windows.Documents.InlineCollection inlines,
        string text,
        FontWeight fontWeight,
        string colorKey)
    {
        var pattern = new System.Text.RegularExpressions.Regex(@"\[([^\]]+)\]\(([^)]+)\)");
        int lastEnd = 0;
        foreach (System.Text.RegularExpressions.Match m in pattern.Matches(text))
        {
            if (m.Index > lastEnd)
            {
                var run = new Run(text[lastEnd..m.Index]) { FontWeight = fontWeight };
                run.SetResourceReference(TextElement.ForegroundProperty, colorKey);
                inlines.Add(run);
            }
            var capturedUrl = m.Groups[2].Value;
            var link = new System.Windows.Documents.Hyperlink(new Run(m.Groups[1].Value))
            {
                FontWeight      = fontWeight,
                TextDecorations = null,
                Cursor          = System.Windows.Input.Cursors.Hand,
            };
            link.SetResourceReference(TextElement.ForegroundProperty, "ActionLinkText");
            link.Click += (_, _) => HandleTranscriptLinkClick(capturedUrl);
            inlines.Add(link);
            lastEnd = m.Index + m.Length;
        }
        if (lastEnd < text.Length)
        {
            var run = new Run(text[lastEnd..]) { FontWeight = fontWeight };
            run.SetResourceReference(TextElement.ForegroundProperty, colorKey);
            inlines.Add(run);
        }
    }

    /// <summary>
    /// Inserts a batch of older coordinator turns at the TOP (beginning) of
    /// <paramref name="thread"/>'s <see cref="FlowDocument"/>, preserving document
    /// order so that the oldest turn in <paramref name="turns"/> ends up first.
    ///
    /// <para>
    /// Because <see cref="RenderPersistedTurn"/> always <em>appends</em> to the
    /// document, we first let it append all batch turns to the end, collect the
    /// newly added blocks, remove them, then insert them before the original first
    /// block in document order.  Inserting before the same anchor in forward order
    /// (a, b, c) correctly produces [a, b, c, anchor, …] because each successive
    /// call to <c>InsertBefore(anchor, x)</c> places <c>x</c> immediately in front
    /// of the (stationary) anchor, accumulating content in the right sequence.
    /// </para>
    /// </summary>
    private void PrependPersistedTurnsBatch(
        TranscriptThreadState thread,
        IReadOnlyList<TranscriptTurnRecord> turns)
    {
        if (turns.Count == 0) return;

        var anchor = thread.Document.Blocks.FirstBlock;

        if (anchor is null)
        {
            // Document is empty — just render normally.
            for (var i = 0; i < turns.Count; i++)
                RenderPersistedTurn(thread, turns[i], i == turns.Count - 1);
            return;
        }

        // Snapshot how many blocks and prompt paragraphs exist before the batch render.
        var blocksBefore   = thread.Document.Blocks.Count;
        var promptsBefore  = thread.PromptParagraphs.Count;

        // Render each turn — they append to the END of the document and PromptParagraphs.
        for (var i = 0; i < turns.Count; i++)
            RenderPersistedTurn(thread, turns[i], isLastTurn: false);

        // Collect only the newly appended blocks (everything after blocksBefore).
        var newBlocks = thread.Document.Blocks.Skip(blocksBefore).ToList();
        if (newBlocks.Count == 0) return;

        // Detach them from the end of the document.
        foreach (var b in newBlocks)
            thread.Document.Blocks.Remove(b);

        // Re-insert before the original first block in forward order.
        // InsertBefore(anchor, x) always places x immediately before anchor,
        // so inserting a, b, c in sequence gives [a, b, c, anchor, …].
        foreach (var b in newBlocks)
            thread.Document.Blocks.InsertBefore(anchor, b);

        // Fix PromptParagraphs ordering: RenderPersistedTurn appended the new entries to
        // the end of the list, but their paragraphs are now at the beginning of the document.
        // Move them to the front so PromptParagraphs stays in document (chronological) order.
        var newPromptCount = thread.PromptParagraphs.Count - promptsBefore;
        if (newPromptCount > 0)
        {
            var prepended = thread.PromptParagraphs.GetRange(promptsBefore, newPromptCount);
            thread.PromptParagraphs.RemoveRange(promptsBefore, newPromptCount);
            thread.PromptParagraphs.InsertRange(0, prepended);

            // Shift PromptNavIndex to account for the inserted items.
            if (thread.PromptNavIndex >= 0)
                thread.PromptNavIndex += newPromptCount;
        }
    }

    private void RenderPersistedTurn(TranscriptThreadState thread, TranscriptTurnRecord turn, bool isLastTurn = false)
    {
        // Session-boundary markers are rendered as a stripe separator, not as AI content.
        if (turn.IsSessionBoundary)
        {
            if (ReferenceEquals(thread, CoordinatorThread))
                AppendSessionGapIndicator(
                    turn.SessionBoundaryShutdownTime ?? turn.StartedAt,
                    turn.SessionBoundaryOfflineDuration ?? TimeSpan.Zero,
                    turn.SessionBoundaryStartupTime,
                    turn.SessionBoundaryAppVersion);
            return;
        }

        var view = CreateTranscriptTurnView(
            thread,
            turn.Prompt,
            turn.StartedAt,
            thinkingExpanded: !turn.ThinkingCollapsed,
            isSystemInjected: turn.IsSystemInjected);
        if (!RenderStructuredPersistedNarrative(view, turn, isLastTurn))
            RenderLegacyPersistedNarrative(view, turn, isLastTurn);

        view.ResponseTextBuilder.Clear();
        view.ResponseTextBuilder.Append(turn.ResponseText);
        foreach (var block in view.ThinkingBlocks)
            block.Expander.IsExpanded = !turn.ThinkingCollapsed;

        if (view.ThoughtBlocks.Count > 0 && turn.CompletedAt is not null)
        {
            foreach (var block in view.ThoughtBlocks)
            {
                SetCollapsedBlockHeader(block.HeaderTextBlock, "Thinking...");
                block.Expander.IsExpanded = false;
            }
        }
        else
        {
            foreach (var block in view.ThoughtBlocks)
                block.Expander.IsExpanded = false;
        }

        if (turn.AgentReports is { Count: > 0 } && ReferenceEquals(thread, CoordinatorThread))
        {
            foreach (var report in turn.AgentReports)
                AppendAgentReportButton(report.AgentLabel, report.ReportPath, view);
        }
    }

    private bool RenderStructuredPersistedNarrative(TranscriptTurnView view, TranscriptTurnRecord turn, bool isLastTurn = false)
    {
        var thoughts = turn.GetThoughts().ToArray();
        var responseSegments = turn.GetResponseSegments().ToArray();
        if (thoughts.Length == 0 && turn.Tools.Count == 0 && (turn.ToolsSuppressedCount ?? 0) == 0 && responseSegments.Length == 0)
            return true;

        if (thoughts.Any(thought => !thought.Sequence.HasValue) ||
            turn.Tools.Any(tool => !tool.ThinkingBlockSequence.HasValue) ||
            responseSegments.Any(segment => !segment.Sequence.HasValue))
        {
            return false;
        }

        var toolGroups = turn.Tools
            .GroupBy(tool => tool.ThinkingBlockSequence!.Value)
            .ToDictionary(group => group.Key, group => group.OrderBy(tool => tool.StartedAt).ToArray());

        var sortedGroupKeys = toolGroups.Keys.OrderBy(k => k).ToArray();

        // Pre-compute per-group durations. When tool FinishedAt timestamps are
        // unreliable (same as StartedAt), fall back to the next group's earliest
        // StartedAt as the ceiling — captures actual inter-group wall-clock time.
        var groupDurations = new Dictionary<int, TimeSpan?>();
        for (int i = 0; i < sortedGroupKeys.Length; i++)
        {
            var key = sortedGroupKeys[i];
            var tools = toolGroups[key];
            var groupStart = tools.Min(t => t.StartedAt);
            var groupEnd = tools.Max(t => t.FinishedAt ?? t.StartedAt);

            if (groupEnd <= groupStart && i + 1 < sortedGroupKeys.Length)
                groupEnd = toolGroups[sortedGroupKeys[i + 1]].Min(t => t.StartedAt);

            groupDurations[key] = groupEnd > groupStart ? groupEnd - groupStart : null;
        }

        var lastResponseSequence = responseSegments.Length > 0
            ? responseSegments.Max(s => s.Sequence!.Value)
            : -1;

        var items = new List<(int Sequence, int SortOrder, Action Render)>();
        items.AddRange(thoughts.Select(thought => (
            thought.Sequence!.Value,
            0,
            (Action)(() => RenderPersistedThought(view, thought)))));
        items.AddRange(toolGroups.Select(group => (
            group.Key,
            1,
            (Action)(() =>
            {
                var collapsed = turn.ThinkingCollapsed;
                var block = CreateThinkingBlock(view, sequence: group.Key, isExpanded: !collapsed);
                foreach (var tool in group.Value)
                    RenderPersistedTool(block, tool);
                if (collapsed && groupDurations.TryGetValue(group.Key, out var duration) && duration.HasValue)
                    SetCollapsedBlockHeader(block.HeaderTextBlock, "Tooling...",
                        StatusTimingPresentation.FormatDuration(duration.Value),
                        ComputeBlockEditDiff(block));
            }))));
        if (turn.ToolsSuppressedCount is { } suppressedCount && suppressedCount > 0) {
            // Sequence: place before any response segments, after any thoughts.
            var stubSeq = responseSegments.Length > 0
                ? responseSegments.Min(s => s.Sequence!.Value) - 1
                : (thoughts.Length > 0 ? thoughts.Max(t => t.Sequence!.Value) + 1 : 0);
            items.Add((stubSeq, 1, () => RenderSuppressedToolsStub(view, suppressedCount)));
        }
        items.AddRange(responseSegments.Select(segment => (
            segment.Sequence!.Value,
            2,
            (Action)(() => RenderPersistedResponse(view, segment,
                allowQuickReplies: isLastTurn && segment.Sequence!.Value == lastResponseSequence)))));

        foreach (var item in items.OrderBy(entry => entry.Sequence).ThenBy(entry => entry.SortOrder))
            item.Render();

        return true;
    }

    private void RenderLegacyPersistedNarrative(TranscriptTurnView view, TranscriptTurnRecord turn, bool isLastTurn = false)
    {
        foreach (var thought in turn.GetThoughts().Where(entry => entry.Placement == TranscriptThoughtPlacement.BeforeTools))
            RenderPersistedThought(view, thought);

        if (turn.Tools.Count > 0)
        {
            var block = CreateThinkingBlock(view, isExpanded: !turn.ThinkingCollapsed);
            var orderedTools = turn.Tools.OrderBy(t => t.StartedAt).ToArray();
            foreach (var tool in orderedTools)
                RenderPersistedTool(block, tool);
            if (turn.ThinkingCollapsed)
            {
                var toolStart = orderedTools.First().StartedAt;
                var toolEnd = orderedTools.Max(t => t.FinishedAt ?? t.StartedAt);
                // When timestamps collapse (all same), span across tool StartedAt values
                if (toolEnd <= toolStart)
                    toolEnd = orderedTools.Last().StartedAt;
                if (toolEnd > toolStart)
                    SetCollapsedBlockHeader(block.HeaderTextBlock, "Tooling...",
                        StatusTimingPresentation.FormatDuration(toolEnd - toolStart),
                        ComputeBlockEditDiff(block));
            }
        }
        else if (turn.ToolsSuppressedCount is { } suppressedCount && suppressedCount > 0)
        {
            RenderSuppressedToolsStub(view, suppressedCount);
        }

        foreach (var thought in turn.GetThoughts().Where(entry => entry.Placement == TranscriptThoughtPlacement.AfterTools))
            RenderPersistedThought(view, thought);

        if (!string.IsNullOrWhiteSpace(turn.ResponseText))
            RenderPersistedResponse(view, new TranscriptResponseSegmentRecord(turn.ResponseText)
            {
                Sequence = AllocateNarrativeSequence(view)
            }, allowQuickReplies: isLastTurn);
    }

    private void FinalizeCurrentTurnResponse() =>
        FinalizeCurrentTurnResponse(CoordinatorThread);

    private void FinalizeCurrentTurnResponse(TranscriptThreadState thread)
    {
        if (thread.CurrentTurn is null)
            return;

        FlushPendingThoughtRendersForTurn(thread.CurrentTurn);

        foreach (var entry in thread.CurrentTurn.ResponseEntries)
            FlushResponseEntryRender(entry, force: true);

        // Render host command invocations after response text
        if (thread.CurrentTurn.HostCommandEntries.Count > 0)
            HostCommandTranscriptRenderer.RenderAllEntries(thread.CurrentTurn);
    }

    private bool ShouldRenderResponseEntryImmediately(TranscriptResponseEntry entry, string text)
    {
        if (entry.LastRenderedAt is null)
            return true;

        if (text.IndexOfAny(['\r', '\n']) >= 0 ||
            text.Contains("```", StringComparison.Ordinal))
            return true;

        return DateTimeOffset.Now - entry.LastRenderedAt.Value >= ResponseRenderCadence;
    }

    private void QueueResponseEntryRender(TranscriptResponseEntry entry, bool flushImmediately)
    {
        entry.HasPendingRender = true;
        _pendingResponseEntryRenders.Add(entry);

        if (flushImmediately)
        {
            FlushResponseEntryRender(entry, force: true);
            return;
        }

        ScheduleTranscriptRender(ResponseRenderCadence);
    }

    private void QueueThoughtEntryRender(TranscriptThoughtEntry entry, bool flushImmediately)
    {
        _pendingThoughtEntryRenders.Add(entry);

        if (flushImmediately)
        {
            FlushThoughtEntryRender(entry, force: true);
            return;
        }

        ScheduleTranscriptRender(ResponseRenderCadence);
    }

    private void FlushPendingResponseEntryRenders()
    {
        if (!HasPendingLiveTranscriptRender)
        {
            _responseRenderTimer.Stop();
            _responseRenderTimer.Interval = ResponseRenderCadence;
            return;
        }

        if (ShouldDeferLiveTranscriptRenderForInput(out var inputDelay))
        {
            ScheduleTranscriptRender(inputDelay);
            return;
        }

        _responseRenderTimer.Interval = ResponseRenderCadence;

        var pendingThoughts = _pendingThoughtEntryRenders.ToArray();
        foreach (var entry in pendingThoughts)
            FlushThoughtEntryRender(entry, force: false);

        var pendingEntries = _pendingResponseEntryRenders.ToArray();
        foreach (var entry in pendingEntries)
            FlushResponseEntryRender(entry, force: false);

        if (!HasPendingLiveTranscriptRender)
            _responseRenderTimer.Stop();
    }

    private bool HasPendingLiveTranscriptRender =>
        _pendingResponseEntryRenders.Count > 0 ||
        _pendingThoughtEntryRenders.Count > 0;

    private void ScheduleTranscriptRender(TimeSpan interval)
    {
        if (_responseRenderTimer.Interval != interval)
            _responseRenderTimer.Interval = interval;

        if (!_responseRenderTimer.IsEnabled)
            _responseRenderTimer.Start();
    }

    private bool ShouldDeferLiveTranscriptRenderForInput(out TimeSpan delay)
    {
        delay = TimeSpan.Zero;
        var elapsed = DateTimeOffset.Now - _lastKeyboardInputAt;
        if (elapsed < TimeSpan.Zero || elapsed >= ResponseRenderInputQuietPeriod)
            return false;

        var remaining = ResponseRenderInputQuietPeriod - elapsed;
        if (remaining <= TimeSpan.Zero)
            return false;

        delay = remaining < ResponseRenderInputPollCadence
            ? remaining
            : ResponseRenderInputPollCadence;
        if (delay < TimeSpan.FromMilliseconds(25))
            delay = TimeSpan.FromMilliseconds(25);
        return true;
    }

    private bool ShouldDeferNonEssentialUiWorkForInput(out TimeSpan delay)
    {
        delay = TimeSpan.Zero;
        var elapsed = DateTimeOffset.Now - _lastKeyboardInputAt;
        if (elapsed < TimeSpan.Zero)
            elapsed = TimeSpan.Zero;

        var textEditingActive =
            PromptTextBox?.IsKeyboardFocusWithin == true ||
            DocSourceTextBox?.IsKeyboardFocusWithin == true ||
            _docSourceFindTextBox?.IsKeyboardFocusWithin == true ||
            _intelliSenseOwnerBox?.IsKeyboardFocusWithin == true;

        var quietPeriod = textEditingActive
            ? NonEssentialUiWorkInputQuietPeriod
            : ResponseRenderInputQuietPeriod;
        if (elapsed >= quietPeriod)
            return false;

        var remaining = quietPeriod - elapsed;
        delay = remaining < NonEssentialUiWorkInputPollCadence
            ? remaining
            : NonEssentialUiWorkInputPollCadence;
        if (delay < TimeSpan.FromMilliseconds(25))
            delay = TimeSpan.FromMilliseconds(25);
        return true;
    }

    private void FlushResponseEntryRender(TranscriptResponseEntry entry, bool force)
    {
        if (!entry.HasPendingRender && !force)
            return;

        _pendingResponseEntryRenders.Remove(entry);
        entry.HasPendingRender = false;
        entry.LastRenderedAt = DateTimeOffset.Now;
        RenderResponseEntry(entry);
    }

    private void FlushThoughtEntryRender(TranscriptThoughtEntry entry, bool force)
    {
        if (!_pendingThoughtEntryRenders.Remove(entry) && !force)
            return;

        RenderThoughtEntry(entry);
    }

    private void FlushPendingThoughtRendersForTurn(TranscriptTurnView turn)
    {
        foreach (var entry in turn.ThoughtEntries.ToArray())
            FlushThoughtEntryRender(entry, force: true);
    }

    private void RenderResponseEntry(TranscriptResponseEntry entry)
    {
        var sanitizedText = SanitizeResponseText(entry.RawTextBuilder.ToString());
        var newBlocks = BuildResponseBlocks(entry, sanitizedText, entry.AllowQuickReplies).ToList();
        if (newBlocks.Count == 0)
            newBlocks.Add(CreateTranscriptParagraph(bottomMargin: 18));

        // If the user has a selection inside this specific section, clear it before swapping
        // blocks. When a block is removed, its TextPointers become stale and WPF renders the
        // selection highlight at frozen pixel coordinates (a "ghost" that doesn't scroll or
        // zoom). We only clear when the selection overlaps this entry — selections in any other
        // completed turn are in unrelated blocks and must be left untouched.
        if (!ActiveTranscriptBox.Selection.IsEmpty)
        {
            try
            {
                var selStart = ActiveTranscriptBox.Selection.Start;
                bool inThisSection = entry.Section.ContentStart.CompareTo(selStart) <= 0 &&
                                     entry.Section.ContentEnd.CompareTo(selStart)   >= 0;
                if (inThisSection)
                    ActiveTranscriptBox.Selection.Select(ActiveTranscriptBox.CaretPosition, ActiveTranscriptBox.CaretPosition);
            }
            catch (ArgumentException)
            {
                // entry.Section and the selection's TextPointer belong to different TextTrees.
                // This can happen when a subagent message arrives while a workspace/tab switch
                // is in-flight: the section was added to a prior document instance that has
                // since been replaced, so its ContentStart is no longer in the same tree as
                // OutputTextBox.Selection.Start.  The selection is no longer relevant to this
                // entry, so it is safe to skip the clear.
            }
        }

        // Swap blocks in-place so the section never empties — Blocks.Clear() collapses the
        // document height synchronously, clamping VerticalOffset before the rebuild adds
        // content back. That clamp is what causes the scroll thumb to jump mid-transcript.
        var oldBlocks = entry.Section.Blocks.ToList();
        int shared = Math.Min(oldBlocks.Count, newBlocks.Count);

        for (int i = 0; i < shared; i++)
        {
            entry.Section.Blocks.InsertAfter(oldBlocks[i], newBlocks[i]);
            entry.Section.Blocks.Remove(oldBlocks[i]);
        }
        for (int i = shared; i < newBlocks.Count; i++)
            entry.Section.Blocks.Add(newBlocks[i]);
        for (int i = shared; i < oldBlocks.Count; i++)
            entry.Section.Blocks.Remove(oldBlocks[i]);
    }

    private IEnumerable<Block> BuildResponseBlocks(
        TranscriptResponseEntry entry,
        string responseText,
        bool allowQuickReplies)
    {
        var quickReplyOptions = Array.Empty<QuickReplyOptionMetadata>();
        if (TryExtractQuickReplyOptionMetadata(responseText, out var cleanedResponseText, out var extractedOptions))
        {
            responseText = cleanedResponseText;
            if (allowQuickReplies)
                quickReplyOptions = extractedOptions;
        }

        var normalized = responseText.Replace("\r\n", "\n").Replace('\r', '\n');
        var lines = normalized.Split('\n');
        var paragraphLines = new List<string>();

        for (var index = 0; index < lines.Length; index++)
        {
            var line = lines[index];
            var trimmed = line.TrimStart();

            // Code fence
            if (trimmed.StartsWith("```", StringComparison.Ordinal))
            {
                foreach (var block in BuildParagraphBlocks(paragraphLines))
                    yield return block;
                paragraphLines.Clear();

                index++;
                var codeLines = new List<string>();
                while (index < lines.Length && !lines[index].TrimStart().StartsWith("```", StringComparison.Ordinal))
                {
                    codeLines.Add(lines[index]);
                    index++;
                }

                yield return BuildCodeBlock(string.Join("\n", codeLines));
                continue;
            }

            // Table
            if (_markdownRenderer.TryReadMarkdownTable(lines, index, out var nextIndex, out var tableLines))
            {
                foreach (var block in BuildParagraphBlocks(paragraphLines))
                    yield return block;
                paragraphLines.Clear();
                yield return _markdownRenderer.BuildMarkdownTable(tableLines);
                index = nextIndex;
                continue;
            }

            paragraphLines.Add(line);
        }

        foreach (var block in BuildParagraphBlocks(paragraphLines))
            yield return block;

        if (quickReplyOptions.Length > 0)
        {
            yield return BuildQuickReplyBlock(entry, quickReplyOptions);
            var hintParagraph = CreateTranscriptParagraph(bottomMargin: 6);
            var hintRun = new Run("Press \u201c[\u201d to respond with the keyboard.")
            {
                FontSize = (double)Application.Current.Resources["FontSizeSmall"]
            };
            hintRun.SetResourceReference(TextElement.ForegroundProperty, "KeyboardHintText");
            hintParagraph.Inlines.Add(hintRun);
            yield return hintParagraph;
        }
    }

    private IEnumerable<Paragraph> BuildParagraphBlocks(List<string> lines)
    {
        if (lines.Count == 0)
            yield break;

        var i = 0;
        while (i < lines.Count)
        {
            var line = lines[i];
            var trimmed = line.TrimStart();

            // Blank line — flush nothing, just skip
            if (string.IsNullOrWhiteSpace(trimmed))
            {
                i++;
                continue;
            }

            // Heading
            if (trimmed.StartsWith("#", StringComparison.Ordinal))
            {
                var level = 0;
                while (level < trimmed.Length && trimmed[level] == '#') level++;
                var headingText = trimmed[level..].TrimStart();
                var p = CreateTranscriptParagraph(bottomMargin: level <= 2 ? 6 : 4);
                p.FontSize = MarkdownDocumentRenderer.HeadingFontSize(level, _transcriptFontSize);
                p.Tag = new string('#', level) + " " + headingText;
                p.Inlines.Add(new Run(headingText) { FontWeight = FontWeights.Bold });
                yield return p;
                i++;
                continue;
            }

            // Blockquote
            if (trimmed.StartsWith("> ", StringComparison.Ordinal))
            {
                var quoteLines = new List<string>();
                while (i < lines.Count && lines[i].TrimStart().StartsWith("> ", StringComparison.Ordinal))
                {
                    quoteLines.Add(lines[i].TrimStart()[2..]);
                    i++;
                }
                yield return BuildBlockquote(string.Join("\n", quoteLines));
                continue;
            }

            // Bullet list
            if (trimmed.StartsWith("- ", StringComparison.Ordinal) || trimmed.StartsWith("* ", StringComparison.Ordinal))
            {
                var bullets = new List<string>();
                while (i < lines.Count)
                {
                    var t = lines[i].TrimStart();
                    if (!t.StartsWith("- ", StringComparison.Ordinal) && !t.StartsWith("* ", StringComparison.Ordinal))
                        break;
                    bullets.Add(t[2..]);
                    i++;
                }
                for (var b = 0; b < bullets.Count; b++)
                    yield return BuildBulletParagraph(bullets[b], isLast: b == bullets.Count - 1);
                continue;
            }

            // Numbered list
            if (Regex.IsMatch(trimmed, @"^\d+\. "))
            {
                var items = new List<(int Num, string Text)>();
                while (i < lines.Count)
                {
                    var t = lines[i].TrimStart();
                    var m = Regex.Match(t, @"^(\d+)\. (.*)");
                    if (!m.Success) break;
                    items.Add((int.Parse(m.Groups[1].Value), m.Groups[2].Value));
                    i++;
                }
                for (var n = 0; n < items.Count; n++)
                    yield return BuildNumberedListParagraph(items[n].Num, items[n].Text, isLast: n == items.Count - 1);
                continue;
            }

            // Plain paragraph — collect until blank line or block-level element
            var paragraphLines = new List<string>();
            while (i < lines.Count)
            {
                var t = lines[i].TrimStart();
                if (string.IsNullOrWhiteSpace(t))
                    break;
                if (t.StartsWith("#", StringComparison.Ordinal) ||
                    t.StartsWith("> ", StringComparison.Ordinal) ||
                    t.StartsWith("- ", StringComparison.Ordinal) ||
                    t.StartsWith("* ", StringComparison.Ordinal) ||
                    t.StartsWith("```", StringComparison.Ordinal))
                    break;
                paragraphLines.Add(lines[i]);
                i++;
            }

            if (paragraphLines.Count > 0)
            {
                foreach (var pl in paragraphLines)
                {
                    var p = CreateTranscriptParagraph(bottomMargin: 4);
                    p.Tag = pl;
                    var trimmedPl = pl.TrimStart();
                    if (trimmedPl.StartsWith("Doing this myself because", StringComparison.OrdinalIgnoreCase))
                        p.SetResourceReference(TextElement.ForegroundProperty, "SelfHandledText");
                    _markdownRenderer.AppendInlineMarkdown(p.Inlines, trimmedPl);
                    yield return p;
                }
            }
        }
    }

    private Paragraph BuildNumberedListParagraph(int number, string text, bool isLast = false)
    {
        var p = new Paragraph
        {
            Margin = new Thickness(16, 1, 0, isLast ? 12 : 1),
            TextIndent = -12,
            Tag = $"{number}. {text}"
        };
        var markerRun = new Run($"{number}. ");
        markerRun.SetResourceReference(TextElement.ForegroundProperty, "ListMarkerText");
        p.Inlines.Add(markerRun);
        _markdownRenderer.AppendInlineMarkdown(p.Inlines, text);
        return p;
    }

    private Paragraph BuildBulletParagraph(string text, bool isLast = false)
    {
        var p = new Paragraph
        {
            Margin = new Thickness(16, 1, 0, isLast ? 12 : 1),
            TextIndent = -12,
            Tag = $"- {text}"
        };
        var markerRun = new Run("• ");
        markerRun.SetResourceReference(TextElement.ForegroundProperty, "ListMarkerText");
        p.Inlines.Add(markerRun);
        _markdownRenderer.AppendInlineMarkdown(p.Inlines, text);
        return p;
    }

    private Paragraph BuildBlockquote(string text)
    {
        var p = new Paragraph
        {
            Margin = new Thickness(12, 2, 0, 8),
            Padding = new Thickness(10, 4, 10, 4),
            BorderThickness = new Thickness(3, 0, 0, 0),
            Tag = string.Join("\n", text.Split('\n').Select(l => $"> {l}"))
        };
        p.SetResourceReference(Block.BorderBrushProperty, "QuoteBorder");
        p.SetResourceReference(Block.BackgroundProperty, "QuoteSurface");
        p.SetResourceReference(TextElement.ForegroundProperty, "BlockquoteBodyText");
        _markdownRenderer.AppendInlineMarkdown(p.Inlines, text);
        return p;
    }

    private Block BuildCodeBlock(string code)
    {
        var textBox = new TextBox
        {
            Text = code,
            IsReadOnly = true,
            AcceptsReturn = true,
            TextWrapping = TextWrapping.NoWrap,
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
            FontFamily = new FontFamily("Consolas"),
            FontSize = _transcriptFontSize * 0.9,
            BorderThickness = new Thickness(0),
            Padding = new Thickness(10, 8, 10, 8),
            Tag = "codeblock"
        };
        textBox.SetResourceReference(Control.BackgroundProperty, "CodeSurface");
        textBox.SetResourceReference(Control.ForegroundProperty, "CodeText");

        var copiedTip = new System.Windows.Controls.ToolTip
        {
            Content = "Copied!",
            Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
        };
        var copyBtn = new Button
        {
            Content = "📋",
            ToolTip = copiedTip,
            FontSize = (double)Application.Current.Resources["FontSizeNormal"],
            Width = 26,
            Height = 22,
            Padding = new Thickness(0),
            Margin = new Thickness(4, 2, 4, 2),
            BorderThickness = new Thickness(0),
            Background = Brushes.Transparent,
            Cursor = Cursors.Hand,
            HorizontalAlignment = HorizontalAlignment.Right,
            VerticalAlignment = VerticalAlignment.Center,
        };
        copyBtn.SetResourceReference(Control.StyleProperty, "TranscriptInlineButtonStyle");
        copyBtn.SetResourceReference(Control.ForegroundProperty, "SubtleText");
        copyBtn.Click += (_, _) =>
        {
            try { Clipboard.SetText(code); } catch { }
            copiedTip.PlacementTarget = copyBtn;
            copiedTip.IsOpen = true;
            var timer = new System.Windows.Threading.DispatcherTimer
            {
                Interval = TimeSpan.FromSeconds(1.5)
            };
            timer.Tick += (_, _) => { copiedTip.IsOpen = false; timer.Stop(); };
            timer.Start();
        };

        var header = new DockPanel { LastChildFill = false };
        header.SetResourceReference(DockPanel.BackgroundProperty, "CodeSurface");
        DockPanel.SetDock(copyBtn, Dock.Right);
        header.Children.Add(copyBtn);

        var container = new StackPanel();
        container.Children.Add(header);
        container.Children.Add(textBox);
        return new BlockUIContainer(container) { Margin = new Thickness(0, 2, 0, 10) };
    }

    private sealed record QuickReplyButtonPayload(
        TranscriptResponseEntry Entry,
        string Option,
        string? RoutingInstruction,
        string? ContinuationAgentLabel,
        string? RouteMode,
        string? TargetAgentHandle);

    private sealed class PendingQuickReplyLaunchState
    {
        public PendingQuickReplyLaunchState(
            string routeMode,
            string? expectedAgentHandle,
            string? expectedAgentLabel,
            string selectedOption,
            PromptContextDiagnostics contextDiagnostics,
            string contextDiagnosticsSummary,
            string? sessionIdAtClick)
        {
            RouteMode = routeMode;
            ExpectedAgentHandle = expectedAgentHandle;
            ExpectedAgentLabel = expectedAgentLabel;
            SelectedOption = selectedOption;
            ContextDiagnostics = contextDiagnostics;
            ContextDiagnosticsSummary = contextDiagnosticsSummary;
            SessionIdAtClick = sessionIdAtClick;
        }

        public string RouteMode { get; }
        public string? ExpectedAgentHandle { get; }
        public string? ExpectedAgentLabel { get; }
        public string SelectedOption { get; }
        public PromptContextDiagnostics ContextDiagnostics { get; }
        public string ContextDiagnosticsSummary { get; }
        public string? SessionIdAtClick { get; }
        public bool ExpectedAgentStarted { get; set; }
        public bool CoordinatorRespondedBeforeLaunch { get; set; }
    }

    private sealed record DelegationOutcomeTelemetry(
        bool Succeeded,
        string RiskBand,
        int TotalChars,
        int TotalTurns,
        string SelectedOption,
        string? ExpectedAgentHandle);

    private sealed class CoordinatorToolFailureTraceState
    {
        public int Count { get; set; }
        public DateTimeOffset FirstSeenAt { get; set; }
    }

    private Block BuildQuickReplyBlock(TranscriptResponseEntry entry, IReadOnlyList<QuickReplyOptionMetadata> options)
    {
        _currentQuickReplyOptions = options
            .Select(option => option.Label)
            .ToArray();
        _quickRepliesShownAt = DateTime.UtcNow;
        _lastQuickReplyEntry = entry;
        SoundNotifications.Play(SoundEvent.QuickRepliesShown);
        var routeDecisions = options
            .Select(option => (Option: option, Decision: BuildQuickReplyRouting(entry, option)))
            .ToArray();
        var captionText = QuickReplyRoutePresentation.BuildCaption(
            routeDecisions.Select(item => new QuickReplyRoutePresentation.RouteInfo(
                item.Decision.RouteMode,
                item.Decision.ContinuationAgentLabel,
                item.Decision.Reason)).ToArray());
        var stack = new StackPanel
        {
            Orientation = Orientation.Vertical
        };

        if (!string.IsNullOrWhiteSpace(captionText))
        {
            var caption = new TextBlock
            {
                Text = captionText,
                Margin = new Thickness(0, 0, 0, 6),
                FontSize = (double)Application.Current.Resources["FontSizeBody"]
            };
            caption.SetResourceReference(TextBlock.ForegroundProperty, "AgentRoleText");
            stack.Children.Add(caption);
        }

        var panel = new WrapPanel
        {
            Margin = new Thickness(0, 2, 0, 0),
            Orientation = Orientation.Horizontal
        };

        foreach (var routeDecision in routeDecisions)
        {
            var option = routeDecision.Option.Label;
            var routedQuickReply = routeDecision.Decision;
            var button = new Button
            {
                Content = option,
                Tag = new QuickReplyButtonPayload(
                    entry,
                    option,
                    routedQuickReply.RoutingInstruction,
                    routedQuickReply.ContinuationAgentLabel,
                    routedQuickReply.RouteMode,
                    routedQuickReply.TargetAgentHandle),
                Margin = new Thickness(0, 0, 8, 8),
                Padding = new Thickness(10, 4, 10, 4),
                BorderThickness = new Thickness(1),
                Cursor = Cursors.Hand,
                MinHeight = 28,
                ToolTip = QuickReplyRoutePresentation.BuildButtonToolTip(
                    new QuickReplyRoutePresentation.RouteInfo(
                        routedQuickReply.RouteMode,
                        routedQuickReply.ContinuationAgentLabel,
                        routedQuickReply.Reason))
            };
            if (Application.Current.TryFindResource("QuickReplyButtonStyle") is Style quickReplyStyle)
                button.Style = quickReplyStyle;
            button.SetResourceReference(Control.BackgroundProperty, "QuickReplySurface");
            button.SetResourceReference(Control.ForegroundProperty, "QuickReplyText");
            button.SetResourceReference(Control.BorderBrushProperty, "QuickReplyBorder");
            button.Click += QuickReplyButton_Click;
            panel.Children.Add(button);
        }

        stack.Children.Add(panel);
        var container = new BlockUIContainer(stack) { Margin = new Thickness(0, 2, 0, 10) };
        container.Tag = new QuickReplyCopyData(
            options.Select(o => o.Label).ToArray(),
            captionText);
        return container;
    }

    // ── Agent report button ───────────────────────────────────────────────────

    private void AppendAgentReport(string agentLabel, string header, string body)
    {
        if (_currentWorkspace is null)
        {
            AppendLine(header + Environment.NewLine + Environment.NewLine + body, null);
            return;
        }

        var stateDir    = _conversationManager.ConversationStore.GetWorkspaceStateDirectory(_currentWorkspace.FolderPath);
        var reportsDir  = AgentReportStore.GetReportsDir(stateDir);
        var reportPath  = AgentReportStore.Store(reportsDir, agentLabel, header, body, DateTimeOffset.UtcNow);
        // Always persist immediately so the button survives a crash/shutdown before the turn completes.
        // The UI button is appended separately below, so skipping the in-memory currentTurn add is safe.
        _conversationManager.AppendAgentReportToLastTurn(agentLabel, reportPath);
        AppendAgentReportButton(agentLabel, reportPath);
    }

    private void AppendAgentReportButton(string agentLabel, string reportPath, TranscriptTurnView? view = null)
    {
        var capturedPath  = reportPath;
        var capturedLabel = agentLabel;

        var button = new Button
        {
            Content   = $"📋 {(AgentReportStore.IsKnownAgent(agentLabel) ? $"{agentLabel}'s" : agentLabel)} report",
            ToolTip   = "Click to open the full agent report",
            Cursor    = Cursors.Hand,
            Margin    = new Thickness(0, 0, 0, 0),
        };
        button.SetResourceReference(Control.StyleProperty, "TranscriptActionButtonStyle");
        button.Click += (_, _) =>
        {
            if (File.Exists(capturedPath))
                MarkdownDocumentWindow.Show(
                    CanShowOwnedWindow() ? this : null,
                    AgentReportStore.FormatReportTitle(capturedLabel),
                    capturedPath);
            else
                MessageBox.Show(
                    "This report is no longer available.",
                    "Report not found",
                    MessageBoxButton.OK,
                    MessageBoxImage.Information);
        };

        var para = new Paragraph { Margin = new Thickness(0, 4, 0, 6) };
        para.Inlines.Add(new InlineUIContainer(button) { BaselineAlignment = BaselineAlignment.Center });

        // Scope the link to the owning turn's NarrativeSection so it stays
        // with the turn both during live streaming and on transcript reload.
        var targetView = view ?? CoordinatorThread.CurrentTurn;
        if (targetView is not null)
            targetView.NarrativeSection.Blocks.Add(para);
        else
            CoordinatorThread.Document.Blocks.Add(para);
    }

    /// <summary>
    /// Renders buttons for any agent-report <c>.md</c> files in <paramref name="reportsDir"/>
    /// that are not already referenced by a persisted coordinator turn.  This recovers buttons
    /// that were lost because the app closed before the active turn completed and was saved.
    /// Must be called on the UI thread after the conversation transcript has been rendered.
    /// </summary>
    private void RenderOrphanedAgentReports(string reportsDir)
    {
        if (!Directory.Exists(reportsDir))
            return;

        var referencedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var turn in _conversationManager.ConversationState.Turns)
            if (turn.AgentReports is { Count: > 0 })
                foreach (var r in turn.AgentReports)
                    referencedPaths.Add(r.ReportPath);

        foreach (var file in Directory.EnumerateFiles(reportsDir, "*.md"))
        {
            if (referencedPaths.Contains(file))
                continue;
            var agentLabel = TryReadAgentLabelFromReport(file) ?? Path.GetFileNameWithoutExtension(file);
            AppendAgentReportButton(agentLabel, file);
        }
    }

    private static string? TryReadAgentLabelFromReport(string reportPath)
    {
        try
        {
            var firstLine = File.ReadLines(reportPath).FirstOrDefault();
            const string possessiveSuffix = "'s Report";
            const string plainSuffix      = " Report";
            if (firstLine?.StartsWith("# ") == true)
            {
                var title = firstLine[2..];
                if (title.EndsWith(possessiveSuffix))
                    return title[..^possessiveSuffix.Length];
                if (title.EndsWith(plainSuffix))
                    return title[..^plainSuffix.Length];
            }
        }
        catch { /* best effort */ }
        return null;
    }

    // ── End agent report button ───────────────────────────────────────────────

    private void TranscriptHyperlink_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not Hyperlink { Tag: string target })
                return;

            if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
                target.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
                target.StartsWith("chrome://", StringComparison.OrdinalIgnoreCase) ||
                target.StartsWith("edge://", StringComparison.OrdinalIgnoreCase))
            {
                _squadCliAdapter.OpenExternalLink(target);
                return;
            }

            OpenTranscriptThread(target, scrollToStart: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(TranscriptHyperlink_Click), ex);
        }
    }

    private void OpenToolTranscriptButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { Tag: ToolTranscriptEntry entry } ||
                string.IsNullOrWhiteSpace(entry.TranscriptThreadId))
            {
                return;
            }

            OpenTranscriptThread(entry.TranscriptThreadId, scrollToStart: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenToolTranscriptButton_Click), ex);
        }
    }

    private sealed record QuickReplyRoutingDecision(
        string? RoutingInstruction,
        string? ContinuationAgentLabel,
        string? RouteMode,
        string? TargetAgentHandle,
        string? Reason);

    private QuickReplyRoutingDecision BuildQuickReplyRouting(TranscriptResponseEntry entry, QuickReplyOptionMetadata option)
    {
        var trimmedOption = option.Label.Trim();
        if (string.IsNullOrWhiteSpace(trimmedOption))
            return new QuickReplyRoutingDecision(null, null, null, null, null);

        var routeMode = NormalizeQuickReplyRouteMode(option.RouteMode);
        var reason = string.IsNullOrWhiteSpace(option.Reason)
            ? null
            : option.Reason.Trim();

        if (string.Equals(routeMode, "continue_current_agent", StringComparison.OrdinalIgnoreCase))
        {
            var continuationThread = TryResolveQuickReplyContinuationThread(entry);
            var continuationHandle = GetQuickReplyAgentHandle(continuationThread);
            if (continuationThread is null || string.IsNullOrWhiteSpace(continuationHandle))
                return new QuickReplyRoutingDecision(null, null, routeMode, null, reason);

            var continuationLabel = ResolveQuickReplyAgentLabel(continuationThread);
            var continuationInstruction = "Route this quick-reply follow-up to @" + continuationHandle.Trim() +
                                          ". Have that agent continue from their most recent work on this task, follow their charter, and carry out the user's selected next step: " +
                                          trimmedOption;
            return new QuickReplyRoutingDecision(continuationInstruction, continuationLabel, routeMode, continuationHandle, reason);
        }

        if (string.Equals(routeMode, "start_named_agent", StringComparison.OrdinalIgnoreCase))
        {
            var targetHandle = NormalizeQuickReplyAgentHandle(option.TargetAgent);
            if (!string.IsNullOrWhiteSpace(targetHandle))
            {
                var targetLabel = ResolveQuickReplyAgentLabel(targetHandle);
                var targetInstruction = "Route this quick-reply follow-up to @" + targetHandle +
                                        ". This is a new task owned by that specialist according to the quick-reply metadata. Have them follow their charter and carry out the user's selected next step: " +
                                        trimmedOption;
                return new QuickReplyRoutingDecision(targetInstruction, targetLabel, routeMode, targetHandle, reason);
            }
        }

        if (string.Equals(routeMode, "fanout_team", StringComparison.OrdinalIgnoreCase))
        {
            return new QuickReplyRoutingDecision(
                "Treat this quick-reply follow-up as a coordinator-owned multi-agent task. Use `.squad/team.md` and `.squad/routing.md` to delegate the user's selected next step: " + trimmedOption,
                null,
                routeMode,
                null,
                reason);
        }

        if (string.Equals(routeMode, "start_coordinator", StringComparison.OrdinalIgnoreCase))
        {
            return new QuickReplyRoutingDecision(
                "Keep this quick-reply follow-up with the Coordinator. Carry out the user's selected next step directly: " + trimmedOption,
                null,
                routeMode,
                null,
                reason);
        }

        if (string.Equals(routeMode, "done", StringComparison.OrdinalIgnoreCase))
            return new QuickReplyRoutingDecision(null, null, routeMode, null, reason);

        var targetThread = TryResolveQuickReplyContinuationThread(entry);
        var agentHandle = GetQuickReplyAgentHandle(targetThread);
        if (targetThread is null || string.IsNullOrWhiteSpace(agentHandle))
            return new QuickReplyRoutingDecision(null, null, null, null, reason);

        var agentLabel = ResolveQuickReplyAgentLabel(targetThread);
        var routingInstruction = "Route this quick-reply follow-up to @" + agentHandle.Trim() +
                                 ". Have that agent continue from their most recent work on this task, follow their charter, and carry out the user's selected next step: " +
                                 trimmedOption;
        return new QuickReplyRoutingDecision(routingInstruction, agentLabel, null, agentHandle, reason);
    }

    private PendingQuickReplyLaunchState? CreatePendingQuickReplyLaunch(QuickReplyButtonPayload payload)
    {
        if (!QuickReplyAgentLaunchPolicy.RequiresObservedNamedAgentLaunch(payload.RouteMode, payload.TargetAgentHandle))
            return null;

        var contextDiagnostics = _conversationManager.CapturePromptContextDiagnostics();
        var diagnostics = PromptContextDiagnosticsPresentation.BuildTraceSummary(
            contextDiagnostics,
            DateTimeOffset.UtcNow);
        return new PendingQuickReplyLaunchState(
            payload.RouteMode!.Trim(),
            payload.TargetAgentHandle,
            payload.ContinuationAgentLabel,
            payload.Option.Trim(),
            contextDiagnostics,
            diagnostics,
            _conversationManager.CurrentSessionId);
    }

    private void NotePendingQuickReplyCoordinatorResponse()
    {
        var pendingLaunch = _pendingQuickReplyLaunch;
        if (pendingLaunch is null ||
            pendingLaunch.ExpectedAgentStarted ||
            pendingLaunch.CoordinatorRespondedBeforeLaunch)
        {
            return;
        }

        pendingLaunch.CoordinatorRespondedBeforeLaunch = true;
        SquadDashTrace.Write(
            "Routing",
            $"Named-agent quick reply produced coordinator response text before launch agent={pendingLaunch.ExpectedAgentHandle ?? "(unknown)"} option='{pendingLaunch.SelectedOption}' sessionAtClick={pendingLaunch.SessionIdAtClick ?? "(none)"} {pendingLaunch.ContextDiagnosticsSummary}");
    }

    private void NotePendingQuickReplySubagentStarted(SquadSdkEvent evt)
    {
        var pendingLaunch = _pendingQuickReplyLaunch;
        if (pendingLaunch is null || pendingLaunch.ExpectedAgentStarted)
            return;

        if (!QuickReplyAgentLaunchPolicy.MatchesExpectedAgent(
                pendingLaunch.ExpectedAgentHandle,
                pendingLaunch.ExpectedAgentLabel,
                evt))
        {
            // Generic agent launched for a named-agent request — tag thread for honest labeling.
            if (!string.IsNullOrWhiteSpace(pendingLaunch.ExpectedAgentHandle) &&
                !string.IsNullOrWhiteSpace(evt.ToolCallId) &&
                _agentThreadRegistry.ThreadsByToolCallId.TryGetValue(evt.ToolCallId.Trim(), out var mismatchThread))
            {
                mismatchThread.RequestedAgentHandle = pendingLaunch.ExpectedAgentHandle;
                SyncThreadChip(mismatchThread);
                UpdateAgentCardFromThread(mismatchThread);
            }

            return;
        }

        pendingLaunch.ExpectedAgentStarted = true;
        RecordDelegationOutcome(pendingLaunch, succeeded: true);
        SquadDashTrace.Write(
            "Routing",
            $"Named-agent quick reply observed expected launch agent={pendingLaunch.ExpectedAgentHandle ?? "(unknown)"} option='{pendingLaunch.SelectedOption}' sessionAtClick={pendingLaunch.SessionIdAtClick ?? "(none)"} {pendingLaunch.ContextDiagnosticsSummary}");
    }

    private bool HasPendingDirectQuickReplyAgentFollowUp() =>
        _directQuickReplyAgentThreadIds.Count > 0;

    private void TrackDirectQuickReplyAgentThread(TranscriptThreadState thread)
    {
        if (string.IsNullOrWhiteSpace(thread.ThreadId))
            return;

        if (_directQuickReplyAgentThreadIds.Add(thread.ThreadId))
        {
            SquadDashTrace.Write(
                "Routing",
                $"Direct named-agent quick reply is blocking queued dispatch until coordinator follow-up thread={thread.ThreadId} agent={thread.Title ?? "(unknown)"} pending={_directQuickReplyAgentThreadIds.Count}");
        }
    }

    private bool CompleteDirectQuickReplyAgentThread(TranscriptThreadState thread, string reason)
    {
        if (string.IsNullOrWhiteSpace(thread.ThreadId))
            return false;

        var removed = _directQuickReplyAgentThreadIds.Remove(thread.ThreadId);
        if (removed)
        {
            SquadDashTrace.Write(
                "Routing",
                $"Direct named-agent quick reply unblocked by {reason} thread={thread.ThreadId} agent={thread.Title ?? "(unknown)"} pending={_directQuickReplyAgentThreadIds.Count}");
        }

        return removed;
    }

    private void EnqueueDirectQuickReplyAgentFollowUp(TranscriptThreadState thread)
    {
        var reportText = !string.IsNullOrWhiteSpace(thread.LastCoordinatorAnnouncedResponse)
            ? thread.LastCoordinatorAnnouncedResponse
            : thread.LatestResponse;
        var prompt = SilentCompletionFollowUpPromptBuilder.Build([
            new SilentCompletionFollowUpReport(
                string.IsNullOrWhiteSpace(thread.Title) ? "Background Agent" : thread.Title,
                string.IsNullOrWhiteSpace(thread.ThreadId) ? null : thread.ThreadId,
                thread.CompletedAt,
                thread.Prompt,
                reportText)
        ]);

        if (string.IsNullOrWhiteSpace(prompt))
            return;

        EnqueuePrompt(prompt, isSystemInjected: true);
        SquadDashTrace.Write(
            "PromptHealth",
            $"DirectQuickReplyCompletion: queued coordinator follow-up thread={thread.ThreadId} agent={thread.Title ?? "(unknown)"}");
    }

    private void MaybeReportPendingQuickReplyLaunchFailure(PendingQuickReplyLaunchState? pendingLaunch)
    {
        if (pendingLaunch is null || pendingLaunch.ExpectedAgentStarted)
            return;

        var message = QuickReplyAgentLaunchPolicy.BuildLaunchFailureMessage(
            pendingLaunch.SelectedOption,
            pendingLaunch.ExpectedAgentLabel,
            pendingLaunch.ExpectedAgentHandle);
        RecordDelegationOutcome(pendingLaunch, succeeded: false);
        SquadDashTrace.Write(
            "Routing",
            $"Named-agent quick reply failed to launch expected agent={pendingLaunch.ExpectedAgentHandle ?? "(unknown)"} option='{pendingLaunch.SelectedOption}' sessionAtClick={pendingLaunch.SessionIdAtClick ?? "(none)"} {pendingLaunch.ContextDiagnosticsSummary} detail=\"{message}\"");
    }

    private void RecordDelegationOutcome(PendingQuickReplyLaunchState pendingLaunch, bool succeeded)
    {
        var riskBand = PromptContextDiagnosticsPresentation.GetRiskBand(
            pendingLaunch.ContextDiagnostics,
            DateTimeOffset.UtcNow);
        var totalTurns = pendingLaunch.ContextDiagnostics.CoordinatorTurnCount +
                         pendingLaunch.ContextDiagnostics.AgentThreadTurnCount;
        _recentDelegationOutcomes.Enqueue(
            new DelegationOutcomeTelemetry(
                succeeded,
                riskBand,
                pendingLaunch.ContextDiagnostics.TotalChars,
                totalTurns,
                pendingLaunch.SelectedOption,
                pendingLaunch.ExpectedAgentHandle));

        while (_recentDelegationOutcomes.Count > DelegationOutcomeRollupWindow)
            _recentDelegationOutcomes.Dequeue();

        SquadDashTrace.Write(
            "Routing",
            BuildDelegationOutcomeRollupTrace(succeeded, riskBand, pendingLaunch.SelectedOption, pendingLaunch.ExpectedAgentHandle));
    }

    private string BuildDelegationOutcomeRollupTrace(
        bool latestSucceeded,
        string latestRiskBand,
        string selectedOption,
        string? expectedAgentHandle)
    {
        var outcomes = _recentDelegationOutcomes.ToArray();
        var failures = outcomes.Count(outcome => !outcome.Succeeded);
        var successes = outcomes.Length - failures;
        var failureRate = outcomes.Length == 0
            ? 0
            : (int)Math.Round(failures * 100d / outcomes.Length, MidpointRounding.AwayFromZero);
        var highRiskFailures = outcomes.Count(outcome => !outcome.Succeeded && string.Equals(outcome.RiskBand, "high", StringComparison.OrdinalIgnoreCase));
        var mediumOrHighFailures = outcomes.Count(outcome =>
            !outcome.Succeeded &&
            !string.Equals(outcome.RiskBand, "low", StringComparison.OrdinalIgnoreCase));
        var clusterHint =
            failures >= 3 && highRiskFailures >= 2
                ? "high-risk-failure-cluster"
                : failures >= 3 && mediumOrHighFailures >= 2
                    ? "possible-session-growth-cluster"
                    : failures >= 2 && successes == 0
                        ? "back-to-back-failures"
                        : "none";

        return
            $"Delegation outcome rollup latest={(latestSucceeded ? "success" : "failure")} latestRisk={latestRiskBand} " +
            $"agent={expectedAgentHandle ?? "(unknown)"} option='{selectedOption}' recent={outcomes.Length} successes={successes} failures={failures} " +
            $"failureRatePct={failureRate} highRiskFailures={highRiskFailures} mediumOrHighFailures={mediumOrHighFailures} clusterHint={clusterHint}";
    }

    private TranscriptThreadState? TryResolveQuickReplyContinuationThread(TranscriptResponseEntry entry)
    {
        var ownerThread = entry.Turn.OwnerThread;
        if (CanRouteQuickReplyToAgent(ownerThread))
            return ownerThread;

        if (ownerThread.Kind != TranscriptThreadKind.Coordinator)
            return null;

        var now = DateTimeOffset.Now;
        var candidates = _agentThreadRegistry.ThreadOrder
            .Where(CanRouteQuickReplyToAgent)
            .Select(thread => new
            {
                Thread = thread,
                ActivityAt = thread.LastObservedActivityAt ?? thread.CompletedAt
            })
            .Where(candidate => candidate.ActivityAt is { } activityAt &&
                                now - activityAt <= MarkdownDocumentRenderer.QuickReplyAgentContinuationWindow)
            .OrderByDescending(candidate => candidate.ActivityAt)
            .ToArray();

        if (candidates.Length != 1)
            return null;

        return candidates[0].Thread;
    }

    private static bool CanRouteQuickReplyToAgent(TranscriptThreadState? thread)
    {
        if (thread is null || thread.Kind != TranscriptThreadKind.Agent || thread.IsPlaceholderThread)
            return false;

        return !string.IsNullOrWhiteSpace(thread.AgentName) ||
               !string.IsNullOrWhiteSpace(thread.AgentId);
    }

    private static string? GetQuickReplyAgentHandle(TranscriptThreadState? thread)
    {
        if (thread is null)
            return null;

        if (!string.IsNullOrWhiteSpace(thread.AgentName))
            return thread.AgentName.Trim();
        if (!string.IsNullOrWhiteSpace(thread.AgentId))
            return thread.AgentId.Trim();

        return null;
    }

    private static string? ResolveQuickReplyAgentLabel(TranscriptThreadState? thread)
    {
        if (thread is null)
            return null;

        if (!string.IsNullOrWhiteSpace(thread.AgentDisplayName))
            return thread.AgentDisplayName.Trim();
        if (!string.IsNullOrWhiteSpace(thread.AgentName))
            return AgentThreadRegistry.HumanizeAgentName(thread.AgentName);
        if (!string.IsNullOrWhiteSpace(thread.AgentId))
            return AgentThreadRegistry.HumanizeAgentName(thread.AgentId);

        return null;
    }

    private string? ResolveQuickReplyAgentLabel(string? handle)
    {
        var normalizedHandle = NormalizeQuickReplyAgentHandle(handle);
        if (string.IsNullOrWhiteSpace(normalizedHandle))
            return null;

        var matchingThread = _agentThreadRegistry.ThreadOrder.FirstOrDefault(thread =>
            string.Equals(NormalizeQuickReplyAgentHandle(thread.AgentName), normalizedHandle, StringComparison.OrdinalIgnoreCase) ||
            string.Equals(NormalizeQuickReplyAgentHandle(thread.AgentId), normalizedHandle, StringComparison.OrdinalIgnoreCase));
        var threadLabel = ResolveQuickReplyAgentLabel(matchingThread);
        if (!string.IsNullOrWhiteSpace(threadLabel))
            return threadLabel;

        if (_currentWorkspace is not null)
        {
            var rosterMatch = _teamRosterLoader.Load(_currentWorkspace.FolderPath)
                .FirstOrDefault(member => string.Equals(
                    NormalizeQuickReplyAgentHandle(DeriveQuickReplyAgentHandle(member.Name, member.CharterPath)),
                    normalizedHandle,
                    StringComparison.OrdinalIgnoreCase));
            if (rosterMatch is not null)
                return rosterMatch.Name;
        }

        return AgentThreadRegistry.HumanizeAgentName(normalizedHandle);
    }

    private static string? NormalizeQuickReplyRouteMode(string? routeMode)
    {
        if (string.IsNullOrWhiteSpace(routeMode))
            return null;

        return routeMode.Trim().ToLowerInvariant();
    }

    private static string? NormalizeQuickReplyAgentHandle(string? handle)
    {
        if (string.IsNullOrWhiteSpace(handle))
            return null;

        return handle.Trim().TrimStart('@').ToLowerInvariant();
    }

    private static string DeriveQuickReplyAgentHandle(string? name, string? charterPath)
    {
        if (!string.IsNullOrWhiteSpace(charterPath))
        {
            var normalized = charterPath.Replace('\\', '/');
            const string marker = "agents/";
            var markerIndex = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
            if (markerIndex >= 0)
            {
                var afterMarker = normalized[(markerIndex + marker.Length)..];
                var slashIndex = afterMarker.IndexOf('/');
                if (slashIndex > 0)
                    return afterMarker[..slashIndex].Trim().ToLowerInvariant();
            }
        }

        if (string.IsNullOrWhiteSpace(name))
            return string.Empty;

        var builder = new StringBuilder();
        var previousWasSeparator = false;
        foreach (var ch in name.Trim().ToLowerInvariant())
        {
            if (char.IsLetterOrDigit(ch))
            {
                builder.Append(ch);
                previousWasSeparator = false;
                continue;
            }

            if (previousWasSeparator)
                continue;

            builder.Append('-');
            previousWasSeparator = true;
        }

        return builder.ToString().Trim('-');
    }

    private bool IsRoutingIssueQuickReply(QuickReplyButtonPayload payload)
    {
        return _routingIssueQuickReplyEntry is not null &&
               ReferenceEquals(payload.Entry, _routingIssueQuickReplyEntry);
    }

    private void HandleRoutingIgnoreQuickReply(TranscriptResponseEntry entry)
    {
        _pec.DisableQuickReplies(entry);
        ClearActiveQuickReplyState(entry);
        _routingIssueQuickReplyEntry = null;

        if (!string.IsNullOrWhiteSpace(_currentRoutingAssessment?.IssueFingerprint))
            SetIgnoredRoutingIssueFingerprintForCurrentWorkspace(_currentRoutingAssessment.IssueFingerprint);

        ShowSystemTranscriptEntry(RoutingIssueWorkflow.BuildIgnoredMessage());
    }

    private async Task HandleRoutingRepairQuickReplyAsync(TranscriptResponseEntry entry)
    {
        if (!CanRunRoutingRepairPrompt())
        {
            ShowSystemTranscriptEntry(RoutingIssueWorkflow.BuildRepairBlockedMessage());
            return;
        }

        _pec.DisableQuickReplies(entry);
        ClearActiveQuickReplyState(entry);
        _routingIssueQuickReplyEntry = null;

        var backupPath = _currentWorkspace is null
            ? null
            : _routingDocumentService.BackupExistingRoutingFile(_currentWorkspace.FolderPath);
        ShowSystemTranscriptEntry(RoutingIssueWorkflow.BuildRepairQueuedMessage(backupPath));
        _pendingSupplementalPromptInstruction = RoutingIssueWorkflow.BuildRepairInstruction();
        _pendingRoutingRepairRecheck = true;

        await _pec.ExecutePromptAsync(string.Empty, addToHistory: false, clearPromptBox: false);
    }

    private async Task HandleResendLastPromptAsync()
    {
        var lastPrompt = _conversationManager.PromptHistory.LastOrDefault();
        if (string.IsNullOrWhiteSpace(lastPrompt))
        {
            AppendLine("[info] No previous prompt found to resend.", null);
            return;
        }

        SquadDashTrace.Write("UI", $"Interrupt recovery: resending last prompt ({lastPrompt.Length} chars).");
        ResetQueuePausedState();
        await _pec.ExecutePromptAsync(lastPrompt, addToHistory: false, clearPromptBox: false);
    }

    private async Task HandleCheckGitDiffAsync()
    {
        var workspacePath = _currentWorkspace?.FolderPath;
        if (string.IsNullOrWhiteSpace(workspacePath))
        {
            AppendLine("[info] No workspace folder — cannot run git diff.", null);
            return;
        }

        string diffOutput;
        try
        {
            var psi = new System.Diagnostics.ProcessStartInfo("git", "diff --stat")
            {
                WorkingDirectory           = workspacePath,
                RedirectStandardOutput     = true,
                RedirectStandardError      = true,
                UseShellExecute            = false,
                CreateNoWindow             = true,
            };
            using var proc = System.Diagnostics.Process.Start(psi)!;
            var stdout = await proc.StandardOutput.ReadToEndAsync();
            var stderr = await proc.StandardError.ReadToEndAsync();
            await proc.WaitForExitAsync();
            diffOutput = string.IsNullOrWhiteSpace(stdout)
                ? (string.IsNullOrWhiteSpace(stderr) ? "(no changes)" : stderr.Trim())
                : stdout.Trim();
        }
        catch (Exception ex)
        {
            AppendLine($"[info] Could not run git diff: {ex.Message}", null);
            return;
        }

        SquadDashTrace.Write("UI", $"Interrupt recovery: git diff captured ({diffOutput.Length} chars), injecting as prompt.");
        ResetQueuePausedState();
        var prompt = $"The prompt was interrupted. Here is the current git diff — please review for partial changes and advise:\n\n```\n{diffOutput}\n```";
        await _pec.ExecutePromptAsync(prompt, addToHistory: false, clearPromptBox: false);
    }

    private void ClearActiveQuickReplyState(TranscriptResponseEntry? entry = null)
    {
        if (entry is not null && !ReferenceEquals(entry, _lastQuickReplyEntry))
            return;

        _lastQuickReplyEntry = null;
        _currentQuickReplyOptions = [];
        _quickRepliesShownAt = DateTime.MinValue;
    }

    private async void QuickReplyButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is not FrameworkElement { Tag: QuickReplyButtonPayload payload } ||
            string.IsNullOrWhiteSpace(payload.Option))
            return;

        if (_isPromptRunning || _currentWorkspace is null)
            return;

        try
        {
            if (IsRoutingIssueQuickReply(payload))
            {
                var option = payload.Option.Trim();
                if (string.Equals(option, RoutingIssueWorkflow.IgnoreQuickReply, StringComparison.OrdinalIgnoreCase))
                {
                    HandleRoutingIgnoreQuickReply(payload.Entry);
                    return;
                }

                if (string.Equals(option, RoutingIssueWorkflow.RepairQuickReply, StringComparison.OrdinalIgnoreCase))
                {
                    await HandleRoutingRepairQuickReplyAsync(payload.Entry);
                    return;
                }
            }

            // Programmatic interrupt recovery quick replies — handled locally, no AI dispatch.
            var trimmedOption = payload.Option.Trim();
            if (string.Equals(trimmedOption, PromptExecutionController.ResendLastPromptQuickReply, StringComparison.OrdinalIgnoreCase))
            {
                _pec.DisableQuickReplies(payload.Entry);
                ClearActiveQuickReplyState(payload.Entry);
                await HandleResendLastPromptAsync();
                return;
            }

            if (string.Equals(trimmedOption, PromptExecutionController.CheckGitDiffQuickReply, StringComparison.OrdinalIgnoreCase))
            {
                _pec.DisableQuickReplies(payload.Entry);
                ClearActiveQuickReplyState(payload.Entry);
                await HandleCheckGitDiffAsync();
                return;
            }

            // Sim quick replies (routeMode="sim") exercise the quick reply UI without
            // dispatching a real prompt to the AI — used by /test-queue.
            if (string.Equals(payload.RouteMode, "sim", StringComparison.OrdinalIgnoreCase))
            {
                _pec.DisableQuickReplies(payload.Entry);
                ClearActiveQuickReplyState(payload.Entry);
                SquadDashTrace.Write("UI", $"Quick reply sim button clicked: '{payload.Option.Trim()}' — no AI dispatch.");
                return;
            }

            _pec.DisableQuickReplies(payload.Entry);
            ClearActiveQuickReplyState(payload.Entry);
            ResetQueuePausedState();
            var pendingLaunch = CreatePendingQuickReplyLaunch(payload);
            _pendingQuickReplyLaunch = pendingLaunch;
            var promptText = payload.Option.Trim();
            var requiresNamedAgentDelegation = QuickReplyAgentLaunchPolicy.RequiresObservedNamedAgentLaunch(
                payload.RouteMode,
                payload.TargetAgentHandle);
            _pendingSupplementalPromptInstruction = null;
            _pendingQuickReplyRoutingInstruction = requiresNamedAgentDelegation || string.IsNullOrWhiteSpace(payload.RoutingInstruction)
                ? null
                : payload.RoutingInstruction.Trim();
            SquadDashTrace.Write(
                "UI",
                string.IsNullOrWhiteSpace(payload.ContinuationAgentLabel)
                    ? $"Quick reply selected option='{payload.Option.Trim()}' routed=coordinator mode={payload.RouteMode ?? "(legacy)"}"
                    : $"Quick reply selected option='{payload.Option.Trim()}' routed={payload.ContinuationAgentLabel} mode={payload.RouteMode ?? "(legacy)"}");
            SquadDashTrace.Write("UI", "Prompt sent: quick reply button");
            if (requiresNamedAgentDelegation)
            {
                SquadDashTrace.Write(
                    "Routing",
                    $"Quick reply entering named-agent direct launch target={payload.TargetAgentHandle?.Trim().TrimStart('@') ?? "(unknown)"} option='{promptText}'");
                var handoffContext = _conversationManager.BuildQuickReplyHandoffContext(
                    payload.Entry,
                    promptText,
                    payload.ContinuationAgentLabel,
                    payload.RouteMode,
                    payload.TargetAgentHandle);
                await _pec.ExecuteNamedAgentDirectAsync(
                    payload.TargetAgentHandle!,
                    promptText,
                    handoffContext,
                    addToHistory: true,
                    clearPromptBox: false);
            }
            else
            {
                await _pec.ExecutePromptAsync(promptText, addToHistory: true, clearPromptBox: false);
            }

            MaybeReportPendingQuickReplyLaunchFailure(pendingLaunch);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException("Quick Reply", ex);
        }
        finally
        {
            _pendingQuickReplyLaunch = null;
            _pendingQuickReplyRoutingInstruction = null;
            _pendingSupplementalPromptInstruction = null;
            // If a loop iteration is waiting for user follow-up, signal that input was received.
            // The loop will re-check LastTurnNeedsInput() to decide whether to wait again.
            _loopFollowUpTcs?.TrySetResult(true);
        }
    }

    private void AppendTextRuns(InlineCollection inlines, string? text)
    {
        if (string.IsNullOrEmpty(text))
            return;

        _markdownRenderer.AppendInlineMarkdown(inlines, text);
    }

    private void EnsureCurrentTurnThinkingVisible() =>
        EnsureCurrentTurnThinkingVisible(CoordinatorThread);

    private void EnsureCurrentTurnThinkingVisible(TranscriptThreadState thread)
    {
        if (thread.CurrentTurn is null)
            return;

        CollapseCurrentTurnThoughts(thread);
        var latestBlock = GetLatestThinkingBlock(thread.CurrentTurn);
        if (latestBlock is not null)
            SetExpanderOpen(latestBlock.Expander, true);
    }

    private void AppendThinkingText(string text, string? speaker) =>
        AppendThinkingText(CoordinatorThread, text, speaker);

    private void AppendThinkingText(TranscriptThreadState thread, string text, string? speaker)
    {
        if (thread.CurrentTurn is null || string.IsNullOrWhiteSpace(text))
            return;

        var thoughtEntry = GetOrCreateThoughtEntry(thread.CurrentTurn, speaker);
        AppendThoughtChunk(thoughtEntry.RawTextBuilder, text);

        if (thread.CurrentTurn.ThoughtBlocks.LastOrDefault() is { } thoughtBlock)
            thoughtBlock.LastUpdatedAt = DateTimeOffset.Now;

        QueueThoughtEntryRender(thoughtEntry, flushImmediately: false);
        ScrollToEndIfAtBottom(thread);
    }

    private static void AppendThoughtChunk(StringBuilder builder, string text)
    {
        var chunk = NormalizeThinkingChunk(text);
        if (string.IsNullOrWhiteSpace(chunk))
            return;

        builder.Append(chunk);
    }

    private void ScrollToEndIfAtBottom() =>
        ScrollToEndIfAtBottom(CoordinatorThread);

    private void ScrollToEndIfAtBottom(TranscriptThreadState thread)
    {
        if (!ReferenceEquals(_selectedTranscriptThread ?? CoordinatorThread, thread))
            return;

        EnsureThreadFooterAtEnd(thread);
        ActiveScrollController.RequestScrollToEnd();
    }

    private void ScrollToPromptParagraph(Paragraph paragraph)
    {
        _ = Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () =>
        {
            var activeBox = ActiveTranscriptBox;
            var sv = activeBox.Template?.FindName("PART_ContentHost", activeBox) as ScrollViewer;
            if (sv is null) return;

            var tp = paragraph.ContentStart;
            var rect = tp.GetCharacterRect(System.Windows.Documents.LogicalDirection.Forward);

            // rect is in coordinates relative to the scroll viewer's visible area.
            // Always scroll to place this prompt at the viewport top.
            double targetOffset = sv.VerticalOffset + rect.Top;
            ActiveScrollController.ScrollToOffset(targetOffset);

            SyncPromptNavButtons();
        });
    }

    private void RefreshActiveTranscriptScrollViewer()
    {
        var activeBox = ActiveTranscriptBox;
        var newScrollViewer = FindScrollViewer(activeBox);
        if (ReferenceEquals(newScrollViewer, _transcriptScrollViewer))
            return;

        if (_transcriptScrollViewer is not null)
            _transcriptScrollViewer.ScrollChanged -= TranscriptScrollViewer_ScrollChanged;

        _transcriptScrollViewer = newScrollViewer;

        if (_transcriptScrollViewer is not null)
            _transcriptScrollViewer.ScrollChanged += TranscriptScrollViewer_ScrollChanged;
    }

    private void TranscriptScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        // Re-evaluate enabled state whenever the scroll position, content height,
        // or viewport height changes — the last two cover window resize, maximize,
        // and F11 full-transcript mode toggling.
        if (e.VerticalChange != 0 || e.ExtentHeightChange != 0 || e.ViewportHeightChange != 0)
            SyncPromptNavButtons();

        // Ensure search highlights repaint on every scroll.  The SearchHighlightAdorner
        // already subscribes to the same ScrollViewer, but that subscription can miss
        // events when focus is on a sibling panel.  This is the authoritative path that
        // fires for all scroll causes (keyboard, scrollbar drag, touch, programmatic).
        if (e.VerticalChange != 0)
            _searchAdorner?.InvalidateHighlights();

        // When the document grows (content streamed in), the scrollbar marker fractions
        // were computed with the old ExtentHeight and are now stale.  Recompute them
        // using the cached pointers so the tick marks stay anchored to the correct
        // document positions without requiring a full adorner rebuild.
        if (e.ExtentHeightChange != 0)
            RefreshScrollbarMarkerPositions();

        // Inactive transcript selection is rendered by TranscriptInactiveSelectionAdorner
        // so it can recompute geometry after scroll instead of using WPF's stale cache.
    }

    /// <summary>
    /// Returns scroll-based nav state for the active thread.
    /// All indices are into <c>thread.PromptParagraphs</c>.
    /// </summary>
    /// <param name="nearestAboveIdx">
    ///   Index of the prompt closest to (and strictly above) the viewport top.
    ///   -1 if no prompt is above the viewport.
    /// </param>
    /// <param name="nearestBelowIdx">
    ///   Index of the nearest prompt below the viewport top that can actually be
    ///   scrolled to the top of the viewport (i.e. enough document content exists
    ///   below it to fill the remaining viewport height).
    ///   -1 if no such prompt exists.
    /// </param>
    private void GetScrollBasedNavState(
        out bool canGoUp, out bool canGoDown,
        out int nearestAboveIdx, out int nearestBelowIdx)
    {
        canGoUp       = false;
        canGoDown     = false;
        nearestAboveIdx = -1;
        nearestBelowIdx = -1;

        var thread = _selectedTranscriptThread ?? CoordinatorThread;
        if (_transcriptScrollViewer is null || thread.PromptParagraphs.Count == 0)
            return;

        // A prompt is "above" (UP target) when it's scrolled above the viewport top.
        // A prompt is a "below" (DOWN target) when it is:
        //   (a) below the viewport top by more than the dead-zone, AND
        //   (b) can actually be scrolled to the viewport top — i.e. its absolute Y is
        //       within the scrollable range (≤ ScrollableHeight). Prompts near the very
        //       bottom of the document cannot be placed at the top because the scroll
        //       viewer hits its maximum offset; navigating to them would produce no
        //       meaningful movement, so the ↓ button should be disabled for them.
        const double DeadZone = 50.0;
        var sv         = _transcriptScrollViewer;
        var viewportTop    = sv.VerticalOffset;
        var scrollableMax  = sv.ScrollableHeight; // ExtentHeight − ViewportHeight

        double bestAboveY = double.MinValue;
        double bestBelowY = double.MaxValue;

        for (int i = 0; i < thread.PromptParagraphs.Count; i++)
        {
            var para = thread.PromptParagraphs[i].Paragraph;
            var rect = para.ContentStart.GetCharacterRect(System.Windows.Documents.LogicalDirection.Forward);
            if (rect.IsEmpty) continue;

            // Absolute Y in the document.
            var absY = viewportTop + rect.Top;

            if (absY < viewportTop - DeadZone)
            {
                // Prompt is above the viewport — candidate for ↑ (go up/back).
                // Pick the one with the LARGEST absoluteY (nearest from above).
                if (absY > bestAboveY) { bestAboveY = absY; nearestAboveIdx = i; }
            }
            else if (absY > viewportTop + DeadZone && absY <= scrollableMax)
            {
                // Prompt is below the viewport top AND can be brought to the top
                // (there is enough content below it to fill the viewport).
                // Pick the one with the SMALLEST absoluteY (nearest from below).
                if (absY < bestBelowY) { bestBelowY = absY; nearestBelowIdx = i; }
            }
        }

        canGoUp   = nearestAboveIdx >= 0;
        canGoDown = nearestBelowIdx >= 0;
    }

    private void SchedulePromptNavGeometryRefresh()
    {
        _ = Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, () =>
        {
            try { SyncPromptNavButtons(allowGeometry: true); }
            catch (Exception ex) { HandleUiCallbackException(nameof(SchedulePromptNavGeometryRefresh), ex); }
        });
    }

    private void SyncPromptNavButtons(bool allowGeometry = true)
    {
        var thread = _selectedTranscriptThread ?? CoordinatorThread;
        var count = thread.PromptParagraphs.Count;
        var idx = thread.PromptNavIndex;

        var isCoordinatorThread = ReferenceEquals(thread, CoordinatorThread);
        PromptNavButtonsPanel.Visibility = isCoordinatorThread || count > 1
            ? Visibility.Visible
            : Visibility.Collapsed;

        bool canGoUp;
        bool canGoDown;
        if (allowGeometry && _transcriptScrollViewer is not null)
        {
            // Base enabled state on scroll position so that manual scrolling keeps
            // both buttons in sync — not just on which button was last clicked.
            GetScrollBasedNavState(out canGoUp, out canGoDown, out _, out _);
        }
        else
        {
            canGoUp   = count > 0 && (idx == -1 || idx > 0);
            canGoDown = count > 0 && idx != -1 && idx < count - 1;
        }

        PromptNavUpButton.IsEnabled   = canGoUp;
        PromptNavDownButton.IsEnabled = canGoDown;

        if (idx == -1)
        {
            HidePromptNavHint();
        }
        else
        {
            PromptNavHintTextBlock.Text = FormatRelativeTime(thread.PromptParagraphs[idx].Timestamp);
            ShowPromptNavHintWithFadeOut();
        }
    }

    private static string FormatRelativeTime(DateTimeOffset timestamp)
    {
        return StatusTimingPresentation.FormatRelativeTimestamp(timestamp);
    }

    private void ShowPromptNavHintWithFadeOut()
    {
        // Cancel any in-flight fade and restart the hold timer
        PromptNavHintTextBlock.BeginAnimation(OpacityProperty, null);
        PromptNavHintTextBlock.Opacity = 1;
        PromptNavHintTextBlock.Visibility = Visibility.Visible;

        if (_promptNavHintTimer is null)
        {
            _promptNavHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(7) };
            _promptNavHintTimer.Tick += (_, _) =>
            {
                _promptNavHintTimer.Stop();
                var fade = new DoubleAnimation(1.0, 0.0, new Duration(TimeSpan.FromSeconds(2)))
                {
                    FillBehavior = FillBehavior.Stop
                };
                fade.Completed += (_, _) => HidePromptNavHint();
                PromptNavHintTextBlock.BeginAnimation(OpacityProperty, fade);
            };
        }

        _promptNavHintTimer.Stop();
        _promptNavHintTimer.Start();
    }

    private void HidePromptNavHint()
    {
        _promptNavHintTimer?.Stop();
        PromptNavHintTextBlock.BeginAnimation(OpacityProperty, null);
        PromptNavHintTextBlock.Opacity = 1;
        PromptNavHintTextBlock.Visibility = Visibility.Collapsed;
    }

    private void PromptNavUpButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var thread = _selectedTranscriptThread ?? CoordinatorThread;
            if (thread.PromptParagraphs.Count == 0) return;

            // Shift+click jumps to the very first prompt.
            if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                thread.PromptNavIndex = 0;
                ScrollToPromptParagraph(thread.PromptParagraphs[0].Paragraph);
                return;
            }

            // Alt+click: find the nearest prompt above that contains a question mark.
            if ((Keyboard.Modifiers & ModifierKeys.Alt) != 0)
            {
                GetScrollBasedNavState(out _, out _, out int nearestAboveIdx, out _);
                var startIdx = nearestAboveIdx >= 0 ? nearestAboveIdx : thread.PromptParagraphs.Count - 1;
                for (int i = startIdx; i >= 0; i--)
                {
                    var text = new System.Windows.Documents.TextRange(
                        thread.PromptParagraphs[i].Paragraph.ContentStart,
                        thread.PromptParagraphs[i].Paragraph.ContentEnd).Text;
                    if (text.Contains('?'))
                    {
                        thread.PromptNavIndex = i;
                        ScrollToPromptParagraph(thread.PromptParagraphs[i].Paragraph);
                        return;
                    }
                }
                return;
            }

            GetScrollBasedNavState(out _, out _, out int nearestAboveIdx2, out _);

            int target;
            if (nearestAboveIdx2 >= 0)
            {
                // Jump to the nearest prompt above the viewport.
                target = nearestAboveIdx2;
            }
            else
            {
                // No prompt above the viewport yet (e.g. first press from the bottom):
                // jump to the most-recent prompt, falling back to index-based if already at one.
                var count = thread.PromptParagraphs.Count;
                target = thread.PromptNavIndex == -1
                    ? count - 1
                    : Math.Max(0, thread.PromptNavIndex - 1);
            }

            thread.PromptNavIndex = target;
            ScrollToPromptParagraph(thread.PromptParagraphs[target].Paragraph);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptNavUpButton_Click), ex);
        }
    }

    private void PromptNavDownButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var thread = _selectedTranscriptThread ?? CoordinatorThread;
            if (thread.PromptParagraphs.Count == 0) return;

            // Shift+click jumps to the very last prompt.
            if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                var last = thread.PromptParagraphs.Count - 1;
                thread.PromptNavIndex = last;
                ScrollToPromptParagraph(thread.PromptParagraphs[last].Paragraph);
                return;
            }

            // Alt+click: find the nearest prompt below that contains a question mark.
            if ((Keyboard.Modifiers & ModifierKeys.Alt) != 0)
            {
                GetScrollBasedNavState(out _, out _, out _, out int nearestBelowIdx);
                if (nearestBelowIdx < 0) return;
                for (int i = nearestBelowIdx; i < thread.PromptParagraphs.Count; i++)
                {
                    var text = new System.Windows.Documents.TextRange(
                        thread.PromptParagraphs[i].Paragraph.ContentStart,
                        thread.PromptParagraphs[i].Paragraph.ContentEnd).Text;
                    if (text.Contains('?'))
                    {
                        thread.PromptNavIndex = i;
                        ScrollToPromptParagraph(thread.PromptParagraphs[i].Paragraph);
                        return;
                    }
                }
                return;
            }

            GetScrollBasedNavState(out _, out _, out _, out int nearestBelowIdx2);
            if (nearestBelowIdx2 < 0) return;

            thread.PromptNavIndex = nearestBelowIdx2;
            ScrollToPromptParagraph(thread.PromptParagraphs[nearestBelowIdx2].Paragraph);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(PromptNavDownButton_Click), ex);
        }
    }

    private TranscriptThoughtEntry GetOrCreateThoughtEntry(
        TranscriptTurnView turn,
        string? speaker)
    {
        var normalizedSpeaker = string.IsNullOrWhiteSpace(speaker)
            ? "Coordinator"
            : AgentThreadRegistry.HumanizeAgentName(speaker);
        var existing = GetLatestThoughtEntry(turn);
        if (existing is not null &&
            string.Equals(existing.Speaker, normalizedSpeaker, StringComparison.OrdinalIgnoreCase))
        {
            return existing;
        }

        return CreateThoughtEntry(turn, normalizedSpeaker);
    }

    private TranscriptThoughtEntry CreateThoughtEntry(
        TranscriptTurnView turn,
        string speaker,
        int? sequence = null)
    {
        var block = GetOrCreateThoughtBlock(turn);

        var textBlock = new TextBlock
        {
            TextWrapping = TextWrapping.Wrap,
            Margin = new Thickness(0, 0, 0, 4)
        };
        block.ContentPanel.Children.Add(textBlock);

        var entry = new TranscriptThoughtEntry(
            turn,
            AllocateNarrativeSequence(turn, sequence),
            speaker,
            textBlock);
        turn.ThoughtEntries.Add(entry);
        block.ThoughtEntries.Add(entry);
        return entry;
    }

    private TranscriptThoughtBlockView GetOrCreateThoughtBlock(TranscriptTurnView turn)
    {
        var latestBlock = turn.ThoughtBlocks.LastOrDefault();
        if (latestBlock is not null)
        {
            var latestSeq = latestBlock.ThoughtEntries.LastOrDefault()?.Sequence
                            ?? latestBlock.Sequence;
            if (latestSeq > Math.Max(GetLatestThinkingBlockSequence(turn), GetLatestResponseSequence(turn)))
                return latestBlock;
        }

        return CreateThoughtBlock(turn);
    }

    private TranscriptThoughtBlockView CreateThoughtBlock(TranscriptTurnView turn, int? sequence = null)
    {
        var header = new TextBlock
        {
            Text = "Thinking...",
            FontWeight = FontWeights.SemiBold
        };
        header.SetResourceReference(TextBlock.ForegroundProperty, "ThinkingText");

        var contentPanel = new StackPanel
        {
            Margin = new Thickness(18, 4, 0, 4)
        };

        var expander = new Expander
        {
            Header = header,
            Content = contentPanel,
            IsExpanded = true,
            Background = Brushes.Transparent,
            BorderBrush = Brushes.Transparent,
            Margin = new Thickness(0, 0, 0, 6)
        };
        if (TryFindResource("TranscriptExpanderStyle") is Style expanderStyle)
            expander.Style = expanderStyle;

        var container = new BlockUIContainer(expander);
        turn.NarrativeSection.Blocks.Add(container);

        var block = new TranscriptThoughtBlockView(
            turn,
            AllocateNarrativeSequence(turn, sequence),
            header,
            expander,
            contentPanel);
        container.Tag = block;
        block.StartedAt = DateTimeOffset.Now;
        turn.ThoughtBlocks.Add(block);
        expander.Expanded += (_, _) => { if (!_programmaticExpanderChange) block.UserPinnedOpen = true; };
        expander.Collapsed += (_, _) => { if (!_programmaticExpanderChange) block.UserPinnedOpen = false; };
        expander.ContextMenu = CreateThinkingContextMenu(turn);
        return block;
    }

    private void RenderPersistedThought(TranscriptTurnView turn, TranscriptThoughtRecord thought)
    {
        var entry = CreateThoughtEntry(turn, AgentThreadRegistry.HumanizeAgentName(thought.Speaker), thought.Sequence);
        entry.RawTextBuilder.Append(thought.Text);
        RenderThoughtEntry(entry);
    }

    private void RenderPersistedResponse(TranscriptTurnView turn, TranscriptResponseSegmentRecord responseSegment, bool allowQuickReplies = false)
    {
        var entry = CreateResponseEntry(turn, responseSegment.Sequence);
        entry.AllowQuickReplies = allowQuickReplies;
        entry.RawTextBuilder.Append(responseSegment.Text);
        RenderResponseEntry(entry);
    }

    private static TranscriptThoughtEntry? GetLatestThoughtEntry(TranscriptTurnView turn)
    {
        var latestThought = turn.ThoughtEntries.LastOrDefault();
        if (latestThought is null)
            return null;

        return latestThought.Sequence > Math.Max(GetLatestThinkingBlockSequence(turn), GetLatestResponseSequence(turn))
            ? latestThought
            : null;
    }

    private static TranscriptThinkingBlockView? GetLatestThinkingBlock(TranscriptTurnView turn)
    {
        var latestBlock = turn.ThinkingBlocks.LastOrDefault();
        if (latestBlock is null)
            return null;

        return latestBlock.Sequence > Math.Max(GetLatestThoughtSequence(turn), GetLatestResponseSequence(turn))
            ? latestBlock
            : null;
    }

    private static TranscriptResponseEntry? GetLatestResponseEntry(TranscriptTurnView turn)
    {
        var latestResponse = turn.ResponseEntries.LastOrDefault();
        if (latestResponse is null)
            return null;

        return latestResponse.Sequence > Math.Max(GetLatestThoughtSequence(turn), GetLatestThinkingBlockSequence(turn))
            ? latestResponse
            : null;
    }

    private static int GetLatestThoughtSequence(TranscriptTurnView turn)
    {
        return turn.ThoughtEntries.LastOrDefault()?.Sequence ?? 0;
    }

    private static int GetLatestThinkingBlockSequence(TranscriptTurnView turn)
    {
        return turn.ThinkingBlocks.LastOrDefault()?.Sequence ?? 0;
    }

    private static int GetLatestResponseSequence(TranscriptTurnView turn)
    {
        return turn.ResponseEntries.LastOrDefault()?.Sequence ?? 0;
    }

    private static int AllocateNarrativeSequence(TranscriptTurnView turn, int? explicitSequence = null)
    {
        if (explicitSequence is { } sequence && sequence > 0)
        {
            turn.NextNarrativeSequence = Math.Max(turn.NextNarrativeSequence, sequence + 1);
            return sequence;
        }

        return turn.NextNarrativeSequence++;
    }

    private TranscriptResponseEntry GetOrCreateResponseEntry(TranscriptTurnView turn)
    {
        var latest = GetLatestResponseEntry(turn);
        if (latest is not null)
            return latest;

        return CreateResponseEntry(turn);
    }

    private TranscriptResponseEntry CreateResponseEntry(TranscriptTurnView turn, int? sequence = null)
    {
        var section = new Section();
        turn.NarrativeSection.Blocks.Add(section);
        var entry = new TranscriptResponseEntry(turn, AllocateNarrativeSequence(turn, sequence), section);
        turn.ResponseEntries.Add(entry);
        return entry;
    }

    private void RenderThoughtEntry(TranscriptThoughtEntry thought)
    {
        var text = FormatThinkingText(thought.RawTextBuilder.ToString());
        if (string.IsNullOrWhiteSpace(text))
        {
            thought.TextBlock.Inlines.Clear();
            return;
        }

        thought.TextBlock.Inlines.Clear();
        var prefixRun = new Run($"{thought.Speaker}: ") { FontWeight = FontWeights.SemiBold };
        prefixRun.SetResourceReference(TextElement.ForegroundProperty, "ThinkingText");
        thought.TextBlock.Inlines.Add(prefixRun);
        var bodyRun = new Run(text);
        bodyRun.SetResourceReference(TextElement.ForegroundProperty, "ThinkingText");
        thought.TextBlock.Inlines.Add(new Italic(bodyRun));
    }

    private static string NormalizeThinkingChunk(string? text)
    {
        return string.IsNullOrEmpty(text)
            ? string.Empty
            : text.Replace("\r\n", "\n").Replace('\r', '\n');
    }

    internal static string FormatThinkingText(string? text) =>
        TranscriptTextUtilities.FormatThinkingText(text);

    private Brush ResolveThoughtBrush(string speaker)
    {
        if (string.Equals(speaker, "Coordinator", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(speaker, "Squad", StringComparison.OrdinalIgnoreCase))
        {
            return FindAgentAccentBrush("Squad") ?? Brushes.DarkGoldenrod;
        }

        return FindAgentAccentBrush(speaker) ?? Brushes.DarkGoldenrod;
    }

    private Brush? FindAgentAccentBrush(string speaker)
    {
        var card = _agents.FirstOrDefault(agent =>
            string.Equals(agent.Name, speaker, StringComparison.OrdinalIgnoreCase) ||
            string.Equals(agent.AccentStorageKey, speaker, StringComparison.OrdinalIgnoreCase));
        return card?.EffectiveAccentBrush;
    }

    private void CollapseCurrentTurnThinking() =>
        CollapseCurrentTurnThinking(CoordinatorThread);

    private void CollapseCurrentTurnThinking(TranscriptThreadState thread)
    {
        if (thread.CurrentTurn is null)
            return;

        foreach (var block in thread.CurrentTurn.ThinkingBlocks)
        {
            if (block.UserPinnedOpen)
                continue;

            if (block.Expander.IsExpanded &&
                block.LastUpdatedAt is { } lastUpdatedAt &&
                lastUpdatedAt > block.StartedAt)
            {
                SetCollapsedBlockHeader(block.HeaderTextBlock, "Tooling...",
                    StatusTimingPresentation.FormatDuration(lastUpdatedAt - block.StartedAt),
                    ComputeBlockEditDiff(block));
            }

            SetExpanderOpen(block.Expander, false);
        }

        CollapseCurrentTurnThoughts(thread);
    }

    private void CollapseCurrentTurnThoughts(TranscriptThreadState thread)
    {
        if (thread.CurrentTurn is null)
            return;

        FlushPendingThoughtRendersForTurn(thread.CurrentTurn);

        foreach (var block in thread.CurrentTurn.ThoughtBlocks)
        {
            if (block.UserPinnedOpen)
                continue;

            if (block.Expander.IsExpanded &&
                block.LastUpdatedAt is { } lastUpdatedAt &&
                lastUpdatedAt > block.StartedAt)
            {
                SetCollapsedBlockHeader(block.HeaderTextBlock, "Thinking...",
                    StatusTimingPresentation.FormatDuration(lastUpdatedAt - block.StartedAt));
            }

            SetExpanderOpen(block.Expander, false);
        }
    }

    private static void SetCollapsedBlockHeader(
        TextBlock header,
        string label,
        string? duration = null,
        (int added, int removed)? diffAggregate = null)
    {
        header.Text = string.Empty;
        header.Inlines.Clear();
        var labelRun = new Run(label) { FontWeight = FontWeights.SemiBold };
        header.Inlines.Add(labelRun);
        if (diffAggregate is { } diff && (diff.added > 0 || diff.removed > 0))
        {
            if (diff.added > 0)
            {
                var addedRun = new Run($" +{diff.added}") { FontWeight = FontWeights.SemiBold };
                addedRun.SetResourceReference(TextElement.ForegroundProperty, "DiffAddedSummary");
                header.Inlines.Add(addedRun);
            }
            if (diff.removed > 0)
            {
                var removedRun = new Run($" -{diff.removed}") { FontWeight = FontWeights.SemiBold };
                removedRun.SetResourceReference(TextElement.ForegroundProperty, "DiffRemovedSummary");
                header.Inlines.Add(removedRun);
            }
        }
        if (!string.IsNullOrWhiteSpace(duration))
        {
            var durationRun = new Run($" {duration}") { FontWeight = FontWeights.Normal };
            durationRun.SetResourceReference(TextElement.ForegroundProperty, "ThinkingMetaText");
            header.Inlines.Add(durationRun);
        }
    }

    private static (int added, int removed) ComputeBlockEditDiff(TranscriptThinkingBlockView block)
    {
        var added   = 0;
        var removed = 0;
        foreach (var entry in block.ToolEntries)
        {
            if (!entry.IsCompleted || !entry.Success) continue;
            var diff = ToolTranscriptFormatter.TryBuildEditDiffSummary(entry.Descriptor, entry.OutputText);
            if (diff is null) continue;
            added   += diff.AddedLineCount;
            removed += diff.RemovedLineCount;
        }
        return (added, removed);
    }

    private void SetExpanderOpen(Expander expander, bool open)
    {
        _programmaticExpanderChange = true;
        try { expander.IsExpanded = open; }
        finally { _programmaticExpanderChange = false; }
    }
    private void StartToolExecution(SquadSdkEvent evt) =>
        StartToolExecution(CoordinatorThread, evt);

    private void StartToolExecution(TranscriptThreadState thread, SquadSdkEvent evt)
    {
        if (!TryGetOrCreateToolEntry(thread, evt, out var entry))
            return;

        _agentThreadRegistry.CaptureBackgroundAgentLaunchInfo(evt);

        if (!string.IsNullOrWhiteSpace(evt.ProgressMessage))
            entry.ProgressText = evt.ProgressMessage;

        EnsureCurrentTurnThinkingVisible(thread);
        RenderToolEntry(entry);
        UpdateToolSpinnerState();
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            SyncActiveToolName();
            UpdateLeadAgent(
                "Tooling",
                string.Empty,
                ToolTranscriptFormatter.BuildRunningText(entry.Descriptor, entry.ProgressText));
            UpdateSessionState("Using tool");
        }

        ScrollToEndIfAtBottom(thread);
    }

    private void UpdateToolExecution(SquadSdkEvent evt) =>
        UpdateToolExecution(CoordinatorThread, evt);

    private void UpdateToolExecution(TranscriptThreadState thread, SquadSdkEvent evt)
    {
        if (!TryGetOrCreateToolEntry(thread, evt, out var entry))
            return;

        if (!string.IsNullOrWhiteSpace(evt.ProgressMessage))
            entry.ProgressText = evt.ProgressMessage;
        if (!string.IsNullOrWhiteSpace(evt.PartialOutput))
            entry.OutputText = ToolTranscriptOutputLimiter.TrimForLiveTranscript(
                entry.Descriptor,
                MergeToolOutput(entry.OutputText, evt.PartialOutput));

        EnsureCurrentTurnThinkingVisible(thread);
        RenderToolEntry(entry);
        UpdateToolSpinnerState();
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            SyncActiveToolName();
            UpdateLeadAgent(
                "Tooling",
                string.Empty,
                ToolTranscriptFormatter.BuildRunningText(entry.Descriptor, entry.ProgressText));
            UpdateSessionState("Using tool");
        }

        ScrollToEndIfAtBottom(thread);
    }

    private void CompleteToolExecution(SquadSdkEvent evt) =>
        CompleteToolExecution(CoordinatorThread, evt);

    private void CompleteToolExecution(TranscriptThreadState thread, SquadSdkEvent evt)
    {
        if (!TryGetOrCreateToolEntry(thread, evt, out var entry))
            return;

        _agentThreadRegistry.CaptureBackgroundAgentLaunchInfo(evt);

        entry.IsCompleted = true;
        entry.Success = evt.Success ?? true;
        entry.FinishedAt = ParseTimestamp(evt.FinishedAt);

        if (entry.FinishedAt is { } finishedAt)
            entry.ThinkingBlock.LastUpdatedAt = finishedAt;

        if (!string.IsNullOrWhiteSpace(evt.OutputText))
            entry.OutputText = ToolTranscriptOutputLimiter.TrimForLiveTranscript(
                entry.Descriptor,
                evt.OutputText);

        if (thread.Kind == TranscriptThreadKind.Coordinator)
            TraceCoordinatorToolCompletion(evt, entry);

        entry.DetailContent = ToolTranscriptFormatter.BuildDetailContent(new ToolTranscriptDetail(
            entry.Descriptor,
            entry.ArgsJson,
            entry.OutputText,
            entry.StartedAt,
            entry.FinishedAt,
            entry.ProgressText,
            entry.IsCompleted,
            entry.Success));

        RenderToolEntry(entry);
        UpdateToolSpinnerState();
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            SyncActiveToolName();
            if (string.IsNullOrWhiteSpace(_pec.ActiveToolName))
                UpdateSessionState("Running");
            else
                UpdateSessionState("Using tool");
        }

        ScrollToEndIfAtBottom(thread);
    }

    private void TraceCoordinatorToolCompletion(SquadSdkEvent evt, ToolTranscriptEntry entry)
    {
        var toolName = entry.Descriptor.ToolName;
        if (string.Equals(toolName, "read_agent", StringComparison.OrdinalIgnoreCase))
        {
            TraceCoordinatorReadAgentCompletion(evt, entry);
            return;
        }

        if (string.Equals(toolName, "task", StringComparison.OrdinalIgnoreCase) &&
            entry.Success == false &&
            IsMaximumConcurrentAgentLimit(entry.OutputText ?? evt.OutputText))
        {
            var requestedName = TryGetJsonString(evt.Args, "name") ?? "(unknown)";
            SquadDashTrace.Write(
                "Agents",
                $"TaskLaunch.Blocked reason=max_concurrent requested={requestedName} toolCallId={evt.ToolCallId ?? "(none)"} active={BuildActiveBackgroundAgentTraceList()} outputChars={evt.OutputText?.Length ?? 0}");
        }
    }

    private void TraceCoordinatorReadAgentCompletion(SquadSdkEvent evt, ToolTranscriptEntry entry)
    {
        var requestedAgentId = TryGetJsonString(evt.Args, "agent_id") ?? "(unknown)";
        var wait = TryGetJsonBool(evt.Args, "wait");
        var output = entry.OutputText ?? evt.OutputText ?? string.Empty;
        var outcome = BuildReadAgentOutcome(entry.Success, output);
        var local = BuildReadAgentLocalState(requestedAgentId);
        var message =
            $"ReadAgent.Resolve requested={requestedAgentId} wait={wait?.ToString() ?? "(unknown)"} outcome={outcome} success={entry.Success} toolCallId={evt.ToolCallId ?? "(none)"} " +
            $"rawOutputChars={evt.OutputText?.Length ?? 0} transcriptOutputChars={entry.OutputText?.Length ?? 0} placeholder={IsReadAgentPlaceholder(output)} {local}";

        if (string.Equals(outcome, "not_found", StringComparison.OrdinalIgnoreCase))
        {
            NoteCoordinatorToolFailure(
                "read_agent",
                requestedAgentId,
                "Agent not found",
                message);
            return;
        }

        SquadDashTrace.Write("Agents", message);
    }

    private string BuildReadAgentLocalState(string requestedAgentId)
    {
        var matchingAgent = _backgroundTaskPresenter.BackgroundAgents.FirstOrDefault(agent =>
            MatchesAgentReadId(agent, requestedAgentId));
        var snapshotStatus = matchingAgent?.Status?.Trim();
        var snapshotLive = matchingAgent is not null && !AgentThreadRegistry.IsTerminalBackgroundStatus(snapshotStatus);

        var thread = string.Equals(requestedAgentId, "(unknown)", StringComparison.Ordinal)
            ? null
            : _agentThreadRegistry.FindExistingAgentThread(
                toolCallId: null,
                agentId: requestedAgentId,
                agentName: null,
                agentDisplayName: null);
        var threadTerminal = thread is not null && AgentThreadRegistry.IsTerminalBackgroundStatus(thread.StatusText);
        return
            $"snapshotMatch={matchingAgent is not null} snapshotLive={snapshotLive} snapshotStatus={snapshotStatus ?? "(none)"} " +
            $"threadMatch={thread is not null} threadId={thread?.ThreadId ?? "(none)"} threadStatus={thread?.StatusText ?? "(none)"} threadTerminal={threadTerminal} " +
            $"threadResponseChars={thread?.LatestResponse?.Length ?? 0} threadLastAnnouncedChars={thread?.LastCoordinatorAnnouncedResponse?.Length ?? 0}";
    }

    private static bool MatchesAgentReadId(SquadBackgroundAgentInfo agent, string requestedAgentId)
    {
        if (string.IsNullOrWhiteSpace(requestedAgentId) || string.Equals(requestedAgentId, "(unknown)", StringComparison.Ordinal))
            return false;

        return string.Equals(agent.AgentId?.Trim(), requestedAgentId, StringComparison.OrdinalIgnoreCase) ||
               string.Equals(agent.ToolCallId?.Trim(), requestedAgentId, StringComparison.OrdinalIgnoreCase) ||
               string.Equals(agent.AgentName?.Trim(), requestedAgentId, StringComparison.OrdinalIgnoreCase) ||
               string.Equals(agent.AgentDisplayName?.Trim(), requestedAgentId, StringComparison.OrdinalIgnoreCase);
    }

    private string BuildActiveBackgroundAgentTraceList()
    {
        var active = _backgroundTaskPresenter.BackgroundAgents
            .Where(agent => !AgentThreadRegistry.IsTerminalBackgroundStatus(agent.Status))
            .Take(8)
            .Select(agent =>
                $"{agent.AgentId ?? "(no-id)"}/{agent.ToolCallId ?? "(no-tool)"}/{agent.Status ?? "(no-status)"}")
            .ToArray();

        return active.Length == 0 ? "[]" : "[" + string.Join(",", active) + "]";
    }

    private void NoteCoordinatorToolFailure(
        string toolName,
        string identity,
        string error,
        string traceMessage)
    {
        var key = toolName + "\n" + identity + "\n" + error;
        var now = DateTimeOffset.Now;
        if (!_coordinatorToolFailureTraces.TryGetValue(key, out var state))
        {
            state = new CoordinatorToolFailureTraceState {
                Count = 0,
                FirstSeenAt = now
            };
            _coordinatorToolFailureTraces[key] = state;
        }

        state.Count++;
        var shouldTrace = state.Count == 1 ||
                          state.Count == CoordinatorToolFailureTraceThreshold ||
                          state.Count % CoordinatorToolFailureTraceEvery == 0;
        if (!shouldTrace)
            return;

        var elapsedSeconds = Math.Max(0, (now - state.FirstSeenAt).TotalSeconds);
        SquadDashTrace.Write(
            "Agents",
            $"{traceMessage} repeatedFailureCount={state.Count} repeatedFailureElapsedSec={elapsedSeconds:0}");
    }

    private static string BuildReadAgentOutcome(bool success, string output)
    {
        if (success)
            return "success";

        if (output.Contains("Agent not found", StringComparison.OrdinalIgnoreCase))
            return "not_found";

        return output.Contains("Code: failure", StringComparison.OrdinalIgnoreCase)
            ? "failure"
            : "unknown_failure";
    }

    private static bool IsMaximumConcurrentAgentLimit(string? output) =>
        !string.IsNullOrWhiteSpace(output) &&
        output.Contains("Maximum concurrent agent limit", StringComparison.OrdinalIgnoreCase);

    private static bool IsReadAgentPlaceholder(string output) =>
        output.Contains("(Full response provided to agent)", StringComparison.OrdinalIgnoreCase);

    private bool TryGetOrCreateToolEntry(
        TranscriptThreadState thread,
        SquadSdkEvent evt,
        out ToolTranscriptEntry entry)
    {
        if (thread.CurrentTurn is null || string.IsNullOrWhiteSpace(evt.ToolCallId))
        {
            entry = null!;
            return false;
        }

        if (_agentThreadRegistry.TryGetToolEntry(evt.ToolCallId, out entry))
        {
            SyncTaskToolTranscriptLink(entry);
            return true;
        }

        entry = CreateToolEntry(
            GetOrCreateThinkingBlockForNewTool(thread.CurrentTurn),
            evt.ToolCallId,
            CreateToolDescriptor(evt),
            TryFormatJson(evt.Args),
            ParseTimestamp(evt.StartedAt));
        _agentThreadRegistry.SetToolEntry(evt.ToolCallId, entry);
        return true;
    }

    private void SyncTaskToolTranscriptLink(TranscriptThreadState thread)
    {
        if (string.IsNullOrWhiteSpace(thread.ToolCallId) ||
            !_agentThreadRegistry.TryGetToolEntry(thread.ToolCallId, out var entry))
        {
            return;
        }

        entry.TranscriptThreadId = thread.ThreadId;
        SyncTaskToolTranscriptLink(entry);
    }

    private void SyncTaskToolTranscriptLink(ToolTranscriptEntry entry)
    {
        if (!string.Equals(entry.Descriptor.ToolName, "task", StringComparison.OrdinalIgnoreCase))
        {
            entry.TranscriptThreadId = null;
            entry.TranscriptButton.Visibility = Visibility.Collapsed;
            entry.TranscriptButton.ToolTip = null;
            return;
        }

        if (string.IsNullOrWhiteSpace(entry.TranscriptThreadId) &&
            _agentThreadRegistry.ThreadsByToolCallId.TryGetValue(entry.ToolCallId, out var thread))
        {
            entry.TranscriptThreadId = thread.ThreadId;
        }

        var isVisible = !string.IsNullOrWhiteSpace(entry.TranscriptThreadId);
        entry.TranscriptButton.Visibility = isVisible ? Visibility.Visible : Visibility.Collapsed;
        entry.TranscriptButton.ToolTip = isVisible ? "Open transcript" : null;
    }

    private ToolTranscriptEntry CreateToolEntry(
        TranscriptThinkingBlockView block,
        string toolCallId,
        ToolTranscriptDescriptor descriptor,
        string? argsJson,
        DateTimeOffset startedAt)
    {
        var iconTextBlock = new TextBlock
        {
            Text = ToolSpinnerFrames[_toolSpinnerFrame],
            Width = 24,
            FontFamily = new FontFamily("Segoe UI Symbol"),
            FontWeight = FontWeights.SemiBold,
            VerticalAlignment = VerticalAlignment.Center
        };
        iconTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "ToolRunningIcon");

        var iconSize = ToolIconSizeForFontSize(_transcriptFontSize);
        var emojiImage = new Image
        {
            Width = iconSize,
            Height = iconSize,
            VerticalAlignment = VerticalAlignment.Center,
            Margin = new Thickness(2, 0, 4, 0)
        };
        System.Windows.Media.RenderOptions.SetBitmapScalingMode(emojiImage, System.Windows.Media.BitmapScalingMode.HighQuality);
        _toolIconImages.Add(emojiImage);

        var messageTextBlock = new TextBlock
        {
            TextWrapping = TextWrapping.Wrap,
            VerticalAlignment = VerticalAlignment.Center
        };
        messageTextBlock.SetResourceReference(TextBlock.ForegroundProperty, "ToolBodyText");

        var headerPanel = new StackPanel
        {
            Orientation = Orientation.Horizontal
        };
        headerPanel.Children.Add(iconTextBlock);
        headerPanel.Children.Add(emojiImage);
        headerPanel.Children.Add(messageTextBlock);

        var transcriptButton = new Button
        {
            Content = "Transcript",
            Visibility = Visibility.Collapsed,
            Margin = new Thickness(10, 0, 0, 0),
            Padding = new Thickness(8, 2, 8, 2),
            HorizontalAlignment = HorizontalAlignment.Left,
            VerticalAlignment = VerticalAlignment.Center,
        };
        transcriptButton.SetResourceReference(Control.StyleProperty, "TranscriptActionButtonStyle");
        headerPanel.Children.Add(transcriptButton);

        var detailTextBox = new TextBox
        {
            IsReadOnly = true,
            AcceptsReturn = true,
            TextWrapping = TextWrapping.Wrap,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            FontFamily = new FontFamily("Consolas"),
            MinHeight = 140,
            MaxHeight = 260
        };
        detailTextBox.SetResourceReference(TextBox.BackgroundProperty, "CodeSurface");
        detailTextBox.SetResourceReference(TextBox.BorderBrushProperty, "InputBorder");
        detailTextBox.SetResourceReference(TextBox.ForegroundProperty, "LabelText");

        var detailPanel = new StackPanel
        {
            Margin = new Thickness(8, 6, 0, 2)
        };

        var expander = new Expander
        {
            Header = headerPanel,
            IsExpanded = false,
            Background = Brushes.Transparent,
            BorderBrush = Brushes.Transparent,
            Margin = new Thickness(0, 2, 0, 2)
        };
        if (TryFindResource("TranscriptExpanderStyle") is Style expanderStyle)
            expander.Style = expanderStyle;

        var entry = new ToolTranscriptEntry(
            toolCallId,
            block.Turn,
            block,
            descriptor,
            argsJson,
            startedAt,
            expander,
            iconTextBlock,
            emojiImage,
            messageTextBlock,
            detailTextBox,
            transcriptButton);
        block.Turn.ToolEntries.Add(entry);
        block.ToolEntries.Add(entry);

        expander.ContextMenu = CreateThinkingContextMenu(block.Turn);
        headerPanel.ContextMenu = CreateThinkingContextMenu(block.Turn);
        iconTextBlock.ContextMenu = CreateThinkingContextMenu(block.Turn);
        emojiImage.ContextMenu = CreateThinkingContextMenu(block.Turn);
        messageTextBlock.ContextMenu = CreateThinkingContextMenu(block.Turn);
        transcriptButton.ContextMenu = CreateThinkingContextMenu(block.Turn);
        transcriptButton.Tag = entry;
        transcriptButton.Click += OpenToolTranscriptButton_Click;
        SyncTaskToolTranscriptLink(entry);

        var openButton = new Button
        {
            Content = "Open Details Window",
            HorizontalAlignment = HorizontalAlignment.Left,
            Margin = new Thickness(0, 0, 0, 8),
            Padding = new Thickness(8, 2, 8, 2),
            Tag = entry
        };
        openButton.SetResourceReference(Control.StyleProperty, "ThemedButtonStyle");
        openButton.Click += OpenToolDetailsButton_Click;

        detailPanel.Children.Add(openButton);
        detailPanel.Children.Add(detailTextBox);
        expander.Content = detailPanel;

        // Wire up diff hover popup for edit tool entries
        if (string.Equals(descriptor.ToolName, "edit", StringComparison.OrdinalIgnoreCase)) {
            DiffHoverPopup? diffPopup = null;
            headerPanel.MouseEnter += (sender, _) => {
                if (!entry.IsCompleted || string.IsNullOrWhiteSpace(entry.OutputText))
                    return;

                var diffLines = DiffHoverPopup.ParseDiff(entry.OutputText);
                if (diffLines.Count == 0)
                    return;

                // Compute above-vs-below placement using work-area-aware screen geometry.
                // Above:  bottom of popup flush with top of entry (preferred — shows the entry
                //         and everything below it unobscured).
                // Below:  popup top starts 1.5 row-heights below the entry top so the hovered
                //         entry and the first half-row of the next entry are both visible.
                var physTopLeft    = headerPanel.PointToScreen(new Point(0, 0));
                var physBottomLeft = headerPanel.PointToScreen(new Point(0, headerPanel.ActualHeight));
                var physWa         = NativeMethods.GetWorkAreaForPhysicalPoint((int)physTopLeft.X, (int)physTopLeft.Y);

                var logTopLeft  = DpiHelper.PhysicalToLogical(headerPanel, physTopLeft);
                var logWaTop    = DpiHelper.PhysicalToLogical(headerPanel, new Point(physWa.Left, physWa.Top));
                var logWaBottom = DpiHelper.PhysicalToLogical(headerPanel, new Point(physWa.Left, physWa.Bottom));

                double entryTopY    = logTopLeft.Y;
                double rowHeight    = DpiHelper.PhysicalToLogical(headerPanel, physBottomLeft).Y - entryTopY;
                double estimatedH   = DiffHoverPopup.EstimateHeight(diffLines.Count);
                double waTop        = logWaTop.Y;
                double waBottom     = logWaBottom.Y;

                // Prefer above: bottom of popup = top of entry (2px gap).
                double aboveTop = entryTopY - estimatedH - 2;
                double popupTop = aboveTop >= waTop
                    ? aboveTop
                    : Math.Min(entryTopY + 1.5 * rowHeight, waBottom - estimatedH);

                double popupLeft = logTopLeft.X + 12;

                diffPopup = new DiffHoverPopup {
                    PlacementTarget  = headerPanel,
                    HorizontalOffset = popupLeft,
                    VerticalOffset   = popupTop
                };
                diffPopup.MouseLeave += (_, _) => {
                    diffPopup.IsOpen = false;
                    diffPopup = null;
                };
                diffPopup.ShowDiff(diffLines);
            };

            headerPanel.MouseLeave += (_, e) => {
                // Defer one Render frame so WPF has time to propagate IsMouseOver
                // to the popup border before we decide whether to close.
                Dispatcher.InvokeAsync(() =>
                {
                    if (diffPopup != null && !diffPopup.IsMouseOver && !headerPanel.IsMouseOver)
                    {
                        diffPopup.IsOpen = false;
                        diffPopup = null;
                    }
                }, System.Windows.Threading.DispatcherPriority.Render);
            };
        }

        block.ContentPanel.Children.Add(expander);
        return entry;
    }

    private void RenderPersistedTool(TranscriptThinkingBlockView block, TranscriptToolRecord tool)
    {
        var toolCallId = string.IsNullOrWhiteSpace(tool.ToolCallId)
            ? Guid.NewGuid().ToString("N")
            : tool.ToolCallId;
        var entry = CreateToolEntry(
            block,
            toolCallId,
            tool.Descriptor,
            tool.ArgsJson,
            tool.StartedAt);
        _agentThreadRegistry.SetToolEntry(toolCallId, entry);

        entry.FinishedAt = tool.FinishedAt;
        entry.ProgressText = tool.ProgressText;
        entry.OutputText = ToolTranscriptOutputLimiter.TrimForLiveTranscript(entry.Descriptor, tool.OutputText);
        entry.DetailContent = ToolTranscriptOutputLimiter.TrimForLiveTranscript(entry.Descriptor, tool.DetailContent);
        entry.IsCompleted = tool.IsCompleted;
        entry.Success = tool.Success;
        RenderToolEntry(entry);
    }

    private void RenderSuppressedToolsStub(TranscriptTurnView view, int count)
    {
        var label = count == 1 ? "Tooling... (1 tool)" : $"Tooling... ({count} tools)";
        var tb = new TextBlock
        {
            Text = label,
            FontWeight = FontWeights.SemiBold,
            Margin = new Thickness(0, 0, 0, 6),
            ToolTip = "Tooling output removed for transcript optimization"
        };
        tb.SetResourceReference(TextBlock.ForegroundProperty, "ThinkingText");
        view.NarrativeSection.Blocks.Add(new BlockUIContainer(tb));
    }

    private TranscriptThinkingBlockView GetOrCreateThinkingBlockForNewTool(TranscriptTurnView turn)
    {
        var latestBlock = GetLatestThinkingBlock(turn);
        if (latestBlock is not null)
        {
            latestBlock.Expander.IsExpanded = true;
            return latestBlock;
        }

        return CreateThinkingBlock(turn, isExpanded: true);
    }

    private TranscriptThinkingBlockView CreateThinkingBlock(
        TranscriptTurnView turn,
        int? sequence = null,
        bool isExpanded = true)
    {
        var header = new TextBlock
        {
            Text = "Tooling...",
            FontWeight = FontWeights.SemiBold
        };
        header.SetResourceReference(TextBlock.ForegroundProperty, "ThinkingText");

        var contentPanel = new StackPanel
        {
            Margin = new Thickness(18, 4, 0, 2)
        };

        var expander = new Expander
        {
            Header = header,
            Content = contentPanel,
            IsExpanded = isExpanded,
            Background = Brushes.Transparent,
            BorderBrush = Brushes.Transparent,
            Margin = new Thickness(0, 0, 0, 6)
        };
        if (TryFindResource("TranscriptExpanderStyle") is Style expanderStyle)
            expander.Style = expanderStyle;

        var container = new BlockUIContainer(expander);
        turn.NarrativeSection.Blocks.Add(container);

        var block = new TranscriptThinkingBlockView(
            turn,
            AllocateNarrativeSequence(turn, sequence),
            header,
            expander,
            contentPanel);
        container.Tag = block;
        block.StartedAt = DateTimeOffset.Now;
        turn.ThinkingBlocks.Add(block);
        expander.Expanded += (_, _) =>
        {
            if (!_programmaticExpanderChange) block.UserPinnedOpen = true;
            // When re-opened, hide the collapsed diff aggregate — only shown when closed.
            block.HeaderTextBlock.Inlines.Clear();
            block.HeaderTextBlock.Inlines.Add(new Run("Tooling...") { FontWeight = FontWeights.SemiBold });
        };
        expander.Collapsed += (_, _) =>
        {
            if (!_programmaticExpanderChange) block.UserPinnedOpen = false;
            var duration = block.LastUpdatedAt is { } lastUpdated && lastUpdated > block.StartedAt
                ? StatusTimingPresentation.FormatDuration(lastUpdated - block.StartedAt)
                : null;
            SetCollapsedBlockHeader(block.HeaderTextBlock, "Tooling...", duration, ComputeBlockEditDiff(block));
        };
        header.ContextMenu = CreateThinkingContextMenu(turn);
        expander.ContextMenu = CreateThinkingContextMenu(turn);
        return block;
    }

    private void RenderToolEntry(ToolTranscriptEntry entry)
    {
        SyncTaskToolTranscriptLink(entry);
        entry.IconTextBlock.Text = entry.IsCompleted
            ? entry.Success ? "✔️" : "⚠"
            : ToolSpinnerFrames[_toolSpinnerFrame];
        entry.IconTextBlock.SetResourceReference(TextBlock.ForegroundProperty,
            entry.IsCompleted
                ? (entry.Success ? "ToolSuccessIcon" : "ToolFailureIcon")
                : "ToolRunningIcon");
        entry.MessageTextBlock.SetResourceReference(TextBlock.ForegroundProperty,
            entry.IsCompleted && !entry.Success ? "ToolFailureText" : "ToolBodyText");
        entry.MessageTextBlock.Inlines.Clear();
        RenderToolMessage(entry);
        entry.DetailTextBox.Text = entry.DetailContent ?? ToolTranscriptFormatter.BuildDetailContent(new ToolTranscriptDetail(
            entry.Descriptor,
            entry.ArgsJson,
            entry.OutputText,
            entry.StartedAt,
            entry.FinishedAt,
            entry.ProgressText,
            entry.IsCompleted,
            entry.Success));
    }

    private void RenderToolMessage(ToolTranscriptEntry entry)
    {
        var iconKey = $"ToolIcon_{entry.Descriptor.ToolName.Trim()}";
        if (entry.Descriptor.ToolName.Trim() == "read_powershell" && entry.ArgsJson is { } argsJsonForIcon)
        {
            try
            {
                using var doc = JsonDocument.Parse(argsJsonForIcon);
                if (doc.RootElement.TryGetProperty("delay", out var delayEl) &&
                    delayEl.TryGetDouble(out var delayVal) &&
                    delayVal >= 30)
                {
                    iconKey = "ToolIcon_read_powershell_waiting";
                }
            }
            catch { }
        }
        entry.EmojiImage.Source = (TryFindResource(iconKey) ?? TryFindResource("ToolIcon_default"))
            as System.Windows.Media.ImageSource;
        entry.EmojiImage.Visibility = entry.EmojiImage.Source is not null
            ? Visibility.Visible : Visibility.Collapsed;

        if (entry.IsCompleted &&
            entry.Success &&
            ToolTranscriptFormatter.TryBuildEditDiffSummary(entry.Descriptor, entry.OutputText) is { } diffSummary)
        {
            var fileRun = new Run(diffSummary.DisplayName);
            fileRun.SetResourceReference(TextElement.ForegroundProperty,
                diffSummary.IsDeletedFile ? "DiffDeletedFileText" : "TableCellText");

            if (diffSummary.IsDeletedFile)
                fileRun.TextDecorations = TextDecorations.Strikethrough;

            entry.MessageTextBlock.Inlines.Add(fileRun);
            entry.MessageTextBlock.Inlines.Add(new Run(" "));
            var addedRun = new Run($"+{diffSummary.AddedLineCount}")
            {
                FontWeight = FontWeights.SemiBold
            };
            addedRun.SetResourceReference(TextElement.ForegroundProperty, "DiffAddedSummary");
            entry.MessageTextBlock.Inlines.Add(addedRun);
            entry.MessageTextBlock.Inlines.Add(new Run(" "));
            var removedRun = new Run($"-{diffSummary.RemovedLineCount}")
            {
                FontWeight = FontWeights.SemiBold
            };
            removedRun.SetResourceReference(TextElement.ForegroundProperty, "DiffRemovedSummary");
            entry.MessageTextBlock.Inlines.Add(removedRun);

            if (diffSummary.IsNewFile)
                entry.MessageTextBlock.Inlines.Add(new Run(" ➕"));

            return;
        }

        var rawText = entry.IsCompleted
            ? ToolTranscriptFormatter.BuildCompletedText(entry.Descriptor, entry.Success, entry.ProgressText, entry.OutputText)
            : ToolTranscriptFormatter.BuildRunningText(entry.Descriptor, entry.ProgressText);

        // Strip any emoji prefix the formatter prepended — the icon is now shown as a DrawingImage
        var toolEmoji = ToolTranscriptFormatter.GetToolEmoji(entry.Descriptor);
        entry.MessageTextBlock.Text = !string.IsNullOrEmpty(toolEmoji) && rawText.StartsWith(toolEmoji, StringComparison.Ordinal)
            ? rawText[toolEmoji.Length..].TrimStart(' ')
            : rawText;
    }

    private void AdvanceToolSpinner()
    {
        _toolSpinnerFrame = (_toolSpinnerFrame + 1) % ToolSpinnerFrames.Length;

        var runningEntries = _agentThreadRegistry.ToolEntries.Values.Where(item => !item.IsCompleted).ToList();

        foreach (var entry in runningEntries)
            RenderToolEntry(entry);

        // Update the elapsed time label on each ThinkingBlock that has running tools
        var activeBlocks = runningEntries
            .Select(e => e.ThinkingBlock)
            .Distinct();
        var now = DateTimeOffset.Now;
        foreach (var block in activeBlocks)
        {
            var elapsed = now - block.StartedAt;
            if (elapsed > TimeSpan.Zero)
            {
                block.HeaderTextBlock.Inlines.Clear();
                var liveLabel = new Run("Tooling...") { FontWeight = FontWeights.SemiBold };
                var liveDuration = new Run($" {StatusTimingPresentation.FormatDuration(elapsed)}") { FontWeight = FontWeights.Normal };
                liveDuration.SetResourceReference(TextElement.ForegroundProperty, "ThinkingMetaText");
                block.HeaderTextBlock.Inlines.Add(liveLabel);
                block.HeaderTextBlock.Inlines.Add(liveDuration);
            }
        }
    }

    private void UpdateToolSpinnerState()
    {
        if (_agentThreadRegistry.ToolEntries.Values.Any(entry => !entry.IsCompleted))
        {
            if (!_toolSpinnerTimer.IsEnabled)
                _toolSpinnerTimer.Start();
            return;
        }

        _toolSpinnerTimer.Stop();
        _toolSpinnerFrame = 0;
    }

    private void SyncActiveToolName()
    {
        _pec.ActiveToolName = _agentThreadRegistry.ToolEntries.Values
            .Where(entry => !entry.IsCompleted && ReferenceEquals(entry.Turn, CoordinatorThread.CurrentTurn))
            .Select(entry => ToolTranscriptFormatter.HumanizeToolName(entry.Descriptor.ToolName))
            .LastOrDefault();
    }

    private void OpenToolDetailsButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { Tag: ToolTranscriptEntry entry })
                return;

            ShowTextWindow(
                $"{ToolTranscriptFormatter.HumanizeToolName(entry.Descriptor.ToolName)} Tool Details",
                entry.DetailContent ?? ToolTranscriptFormatter.BuildDetailContent(new ToolTranscriptDetail(
                    entry.Descriptor,
                    entry.ArgsJson,
                    entry.OutputText,
                    entry.StartedAt,
                    entry.FinishedAt,
                    entry.ProgressText,
                    entry.IsCompleted,
                    entry.Success)));
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenToolDetailsButton_Click), ex);
        }
    }

    private ToolTranscriptDescriptor CreateToolDescriptor(SquadSdkEvent evt)
    {
        return new ToolTranscriptDescriptor(
            evt.ToolName ?? "tool",
            evt.Description,
            evt.Command,
            evt.Path,
            evt.Intent,
            evt.Skill,
            BuildToolDisplayText(evt));
    }

    private static SpinnerActivityKind GetToolActivityKind(string? toolName)
    {
        if (string.IsNullOrWhiteSpace(toolName))
            return SpinnerActivityKind.Thinking;

        var name = toolName.Trim();

        if (name.Contains("edit",        StringComparison.OrdinalIgnoreCase) ||
            name.Contains("create",      StringComparison.OrdinalIgnoreCase) ||
            name.Contains("write",       StringComparison.OrdinalIgnoreCase) ||
            name.Contains("delete",      StringComparison.OrdinalIgnoreCase) ||
            name.Contains("move",        StringComparison.OrdinalIgnoreCase) ||
            name.Contains("rename",      StringComparison.OrdinalIgnoreCase) ||
            name.Contains("patch",       StringComparison.OrdinalIgnoreCase) ||
            name.Contains("insert",      StringComparison.OrdinalIgnoreCase) ||
            name.Contains("replace",     StringComparison.OrdinalIgnoreCase) ||
            name.Contains("remove_file", StringComparison.OrdinalIgnoreCase) ||
            name.Contains("overwrite",   StringComparison.OrdinalIgnoreCase))
            return SpinnerActivityKind.Writing;

        if (name.Contains("read",   StringComparison.OrdinalIgnoreCase) ||
            name.Contains("view",   StringComparison.OrdinalIgnoreCase) ||
            name.Contains("glob",   StringComparison.OrdinalIgnoreCase) ||
            name.Contains("grep",   StringComparison.OrdinalIgnoreCase) ||
            name.Contains("search", StringComparison.OrdinalIgnoreCase) ||
            name.Contains("fetch",  StringComparison.OrdinalIgnoreCase) ||
            name.Contains("list",   StringComparison.OrdinalIgnoreCase) ||
            name.Contains("get",    StringComparison.OrdinalIgnoreCase))
            return SpinnerActivityKind.Reading;

        return SpinnerActivityKind.Thinking;
    }

    private string? BuildToolDisplayText(SquadSdkEvent evt)
    {
        if (string.IsNullOrWhiteSpace(evt.ToolName))
            return null;

        return evt.ToolName.Trim() switch
        {
            "glob" => TryGetJsonString(evt.Args, "pattern"),
            "grep" => BuildRelativeToolPathLabel(TryGetJsonString(evt.Args, "path") ?? evt.Path)
                      ?? TryGetJsonString(evt.Args, "pattern"),
            "view" => BuildRelativeToolPathLabel(TryGetJsonString(evt.Args, "path") ?? evt.Path),
            "edit" => BuildRelativeToolPathLabel(TryGetJsonString(evt.Args, "path") ?? evt.Path),
            "create" => BuildRelativeToolPathLabel(TryGetJsonString(evt.Args, "path") ?? evt.Path),
            "web_fetch" => StripUrlScheme(TryGetJsonString(evt.Args, "url")),
            "task" => TryGetJsonString(evt.Args, "description"),
            "skill" => TryGetJsonString(evt.Args, "skill") ?? evt.Skill,
            "store_memory" => TryGetJsonString(evt.Args, "subject") ?? TryGetJsonString(evt.Args, "fact"),
            "report_intent" => TryGetJsonString(evt.Args, "intent") ?? evt.Intent,
            "sql" => TryGetJsonString(evt.Args, "description"),
            "powershell" => TryGetJsonString(evt.Args, "description") ?? TryGetJsonString(evt.Args, "command") ?? evt.Command,
            "read_powershell"  => LookupShellLabel(TryGetJsonString(evt.Args, "shellId")),
            "write_powershell" => LookupShellLabel(TryGetJsonString(evt.Args, "shellId")),
            "stop_powershell"  => LookupShellLabel(TryGetJsonString(evt.Args, "shellId")),
            "list_powershell"  => null,
            "read_agent"       => TryGetJsonString(evt.Args, "agent_id"),
            "list_agents"      => null,
            "fetch_copilot_cli_documentation" => null,
            _ => null
        };
    }

    private string? LookupShellLabel(string? shellId)
    {
        if (string.IsNullOrWhiteSpace(shellId)) return null;
        return _shellDescriptions.TryGetValue(shellId, out var desc) && !string.IsNullOrWhiteSpace(desc)
            ? desc
            : $"Shell {shellId}";
    }

    private string? BuildRelativeToolPathLabel(string? path)
    {
        if (string.IsNullOrWhiteSpace(path))
            return null;

        var normalized = Path.GetFullPath(path);
        if (_currentWorkspace is null)
            return Path.GetFileName(normalized) is { Length: > 0 } name ? name : normalized;

        try
        {
            var rel = Path.GetRelativePath(_currentWorkspace.FolderPath, normalized);
            if (rel == ".")
                rel = Path.GetFileName(normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
            return string.IsNullOrWhiteSpace(rel) ? normalized : rel;
        }
        catch
        {
            return normalized;
        }
    }

    private TeamAgentDescriptor[] GetKnownTeamAgentDescriptors()
    {
        return _agents
            .Where(card => !card.IsLeadAgent && !card.IsDynamicAgent)
            .Select(card => new TeamAgentDescriptor(card.Name, card.AccentStorageKey, card.RoleText))
            .ToArray();
    }

    private static string? StripUrlScheme(string? url)
    {
        if (string.IsNullOrWhiteSpace(url))
            return null;

        var trimmed = url.Trim();
        foreach (var scheme in new[] { "https://", "http://" })
        {
            if (trimmed.StartsWith(scheme, StringComparison.OrdinalIgnoreCase))
                return trimmed[scheme.Length..];
        }

        return trimmed;
    }

    private static string? TryGetJsonString(JsonElement element, string propertyName)
    {
        if (element.ValueKind != JsonValueKind.Object)
            return null;

        return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
            ? property.GetString()
            : null;
    }

    private static bool? TryGetJsonBool(JsonElement element, string propertyName)
    {
        if (element.ValueKind != JsonValueKind.Object)
            return null;

        return element.TryGetProperty(propertyName, out var property) && property.ValueKind is JsonValueKind.True or JsonValueKind.False
            ? property.GetBoolean()
            : null;
    }

    private static DateTimeOffset ParseTimestamp(string? value)
    {
        return DateTimeOffset.TryParse(value, out var parsed)
            ? parsed
            : DateTimeOffset.Now;
    }

    private static DateTimeOffset? TryParseTimestamp(string? value)
    {
        return DateTimeOffset.TryParse(value, out var parsed)
            ? parsed
            : null;
    }

    internal static string BuildTimedStatusText(
        string? statusText,
        DateTimeOffset? startedAt,
        DateTimeOffset? completedAt,
        DateTimeOffset now) =>
        TranscriptTextUtilities.BuildTimedStatusText(statusText, startedAt, completedAt, now);

    private static string? TryFormatJson(JsonElement element)
    {
        return element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
            ? null
            : FormatJson(element);
    }

    private static string? MergeToolOutput(string? existingOutput, string? newOutput)
    {
        if (string.IsNullOrWhiteSpace(newOutput))
            return existingOutput;
        if (string.IsNullOrWhiteSpace(existingOutput))
            return newOutput.TrimEnd();
        if (existingOutput.Contains(newOutput, StringComparison.Ordinal))
            return existingOutput;

        return existingOutput.TrimEnd() + Environment.NewLine + newOutput.TrimEnd();
    }

    private void ClearSessionView()
    {
        DisposeInboxWatcher();
        DisposeTeamFileWatcher();
        DisposeMaintenanceMdWatcher();
        _conversationManager.CurrentSessionId = null;
        SquadDashTrace.Write("Persistence", $"Coordinator CurrentTurn cleared reason=ClearSessionView currentTurnWasPresent={_currentTurn is not null}");
        _currentTurn = null;
        // Clear all stored agent reports for this workspace when the conversation is cleared.
        if (_currentWorkspace is not null)
        {
            var stateDir   = _conversationManager.ConversationStore.GetWorkspaceStateDirectory(_currentWorkspace.FolderPath);
            var reportsDir = AgentReportStore.GetReportsDir(stateDir);
            AgentReportStore.ClearAll(reportsDir);
        }
        // Reset completion-footer state on all threads before clearing documents so that
        // UpdateCompletedTimeFooters() (called indirectly by SelectTranscriptThread below)
        // does not re-inject the "Completed N minutes ago" paragraph into the now-empty doc.
        foreach (var thread in EnumerateTranscriptThreads())
        {
            thread.CompletedAt = null;
            thread.CompletedTimeParagraph = null;
        }
        CoordinatorThread.Document.Blocks.Clear();
        RemovePrimaryAgentTranscriptHosts(_primaryAgentTranscriptHosts.Keys.ToArray());
        _transcriptSnapshots.Clear();
        TranscriptSnapshotBackdrop.Visibility = Visibility.Collapsed;
        TranscriptSnapshotImage.Visibility = Visibility.Collapsed;
        TranscriptSnapshotImage.Source = null;
        _snapshotThread = null;
        _agentThreadRegistry.ClearAll();
        _backgroundTaskPresenter.ClearState();
        _routingIssueQuickReplyEntry = null;
        ClearActiveQuickReplyState();
        _directQuickReplyAgentThreadIds.Clear();
        _announcedRoutingIssueFingerprint = null;
        _pendingSupplementalPromptInstruction = null;
        _pendingRoutingRepairRecheck = false;
        _conversationManager.ConversationState = WorkspaceConversationState.Empty;
        _conversationManager.ResetVirtualWindow();
        _toolSpinnerTimer.Stop();
        _toolSpinnerFrame = 0;
        SelectTranscriptThread(CoordinatorThread);
    }

    private string? GetIgnoredRoutingIssueFingerprintForCurrentWorkspace()
    {
        if (_currentWorkspace is null)
            return null;

        return _settingsSnapshot.IgnoredRoutingIssueFingerprintsByWorkspace.TryGetValue(
            _currentWorkspace.FolderPath,
            out var fingerprint)
            ? fingerprint
            : null;
    }

    private void SetIgnoredRoutingIssueFingerprintForCurrentWorkspace(string? fingerprint)
    {
        if (_currentWorkspace is null)
            return;

        _settingsSnapshot = _settingsStore.SaveIgnoredRoutingIssueFingerprint(
            _currentWorkspace.FolderPath,
            fingerprint);
    }

    private void ClearIgnoredRoutingIssueFingerprintIfResolved()
    {
        if (_currentWorkspace is null ||
            _currentRoutingAssessment is { NeedsRepair: true })
        {
            return;
        }

        if (!string.IsNullOrWhiteSpace(GetIgnoredRoutingIssueFingerprintForCurrentWorkspace()))
            SetIgnoredRoutingIssueFingerprintForCurrentWorkspace(null);
    }

    private bool CanRunRoutingRepairPrompt()
    {
        return _currentWorkspace is not null &&
               !_isInstallingSquad &&
               _currentInstallationState?.IsSquadInstalledForActiveDirectory == true &&
               _startupIssue is null;
    }

    private TranscriptResponseEntry? ShowSystemTranscriptEntry(string text)
    {
        if (_isClosing || string.IsNullOrWhiteSpace(text))
            return null;

        SelectTranscriptThread(CoordinatorThread);
        var turn = BeginTranscriptTurn(string.Empty);
        AppendLine(text);
        FinalizeCurrentTurnResponse();
        SquadDashTrace.Write("Persistence", "Coordinator CurrentTurn cleared reason=system-transcript-entry");
        _currentTurn = null;
        return turn.ResponseEntries.LastOrDefault();
    }

    private void MaybePublishRoutingIssueSystemEntry(string reason, bool force = false)
    {
        if (_isClosing || _isPromptRunning || _currentWorkspace is null)
            return;

        var assessment = _currentRoutingAssessment;
        if (assessment is null)
        {
            _routingIssueQuickReplyEntry = null;
            _announcedRoutingIssueFingerprint = null;
            return;
        }

        if (!assessment.NeedsRepair || string.IsNullOrWhiteSpace(assessment.IssueFingerprint))
        {
            _routingIssueQuickReplyEntry = null;
            _announcedRoutingIssueFingerprint = null;
            return;
        }

        if (string.Equals(
                assessment.IssueFingerprint,
                GetIgnoredRoutingIssueFingerprintForCurrentWorkspace(),
                StringComparison.OrdinalIgnoreCase))
        {
            _routingIssueQuickReplyEntry = null;
            return;
        }

        if (!force &&
            string.Equals(
                assessment.IssueFingerprint,
                _announcedRoutingIssueFingerprint,
                StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        _routingIssueQuickReplyEntry = ShowSystemTranscriptEntry(
            RoutingIssueWorkflow.BuildSystemEntry(assessment));
        _announcedRoutingIssueFingerprint = assessment.IssueFingerprint;
        SquadDashTrace.Write(
            "Routing",
            $"Published routing issue entry reason={reason} status={assessment.Status} fingerprint={assessment.IssueFingerprint}");
    }

    private async Task WaitForRoutingRepairStateToSettleAsync()
    {
        if (_currentWorkspace is null)
            return;

        var attempts = 6;
        for (var attempt = 0; attempt < attempts; attempt++)
        {
            RefreshInstallationState();
            if (_currentRoutingAssessment is null || !_currentRoutingAssessment.NeedsRepair)
                return;

            await Task.Delay(250);
        }
    }

    private void RefreshInstallationState()
    {
        _currentInstallationState = _currentWorkspace is null
            ? null
            : _installationStateService.GetState(_currentWorkspace.FolderPath);
        _currentRoutingAssessment = _currentWorkspace is not null &&
                                    _settingsSnapshot.GetStartupIssueSimulation(_currentWorkspace.FolderPath) == DeveloperStartupIssueSimulation.None
            ? _routingDocumentService.Assess(_currentWorkspace.FolderPath)
            : null;
        ClearIgnoredRoutingIssueFingerprintIfResolved();

        _startupIssue = WorkspaceIssueFactory.CreateStartupIssue(
            _currentInstallationState,
            _settingsSnapshot.GetStartupIssueSimulation(_currentWorkspace?.FolderPath ?? ""));
        _installSquadButtonPressed = false;
        UpdateWorkspaceIssuePanel();
        UpdateInteractiveControlState();
    }

    private void SetInstallUiState(bool isInstalling, string statusText)
    {
        _isInstallingSquad = isInstalling;
        SetInstallStatus(statusText);
        UpdateInteractiveControlState();
    }

    private void SetInstallStatus(string statusText)
    {
        InstallStatusTextBlock.Text = statusText;
        InstallStatusTextBlock.Visibility = string.IsNullOrWhiteSpace(statusText)
            ? Visibility.Collapsed
            : Visibility.Visible;
    }

    private void UpdateWorkspaceIssuePanel()
    {
        var issue = _runtimeIssue ?? _startupIssue;
        var issueKey = WorkspaceIssuePanelState.BuildDismissalKey(issue);
        var isDismissed = issue is not null &&
                          string.Equals(_dismissedWorkspaceIssueKey, issueKey, StringComparison.Ordinal);
        WorkspaceIssuePanelBorder.Visibility = issue is null || isDismissed
            ? Visibility.Collapsed
            : Visibility.Visible;

        if (issue is null)
        {
            _dismissedWorkspaceIssueKey = null;
            WorkspaceIssueTitleTextBlock.Text = string.Empty;
            WorkspaceIssueDetailTextBlock.Text = string.Empty;
            WorkspaceIssueDetailTextBlock.Visibility = Visibility.Collapsed;
            SetInstallStatus(string.Empty);
            InstallSquadButton.Visibility = Visibility.Collapsed;
            IssueHelpButton.Visibility = Visibility.Collapsed;
            IssueActionButton.Visibility = Visibility.Collapsed;
            IssueSecondaryActionButton.Visibility = Visibility.Collapsed;
            IssuePrimaryLinkButton.Visibility = Visibility.Collapsed;
            IssueSecondaryLinkButton.Visibility = Visibility.Collapsed;
            IssueDismissButton.Visibility = Visibility.Collapsed;
            return;
        }

        WorkspaceIssueTitleTextBlock.Text = issue.Title;
        SetInstallStatus(issue.Message);
        WorkspaceIssueDetailTextBlock.Text = issue.DetailText ?? string.Empty;
        WorkspaceIssueDetailTextBlock.Visibility = string.IsNullOrWhiteSpace(issue.DetailText)
            ? Visibility.Collapsed
            : Visibility.Visible;

        InstallSquadButton.Visibility = issue.ShowInstallButton && _currentWorkspace is not null
            ? Visibility.Visible
            : Visibility.Collapsed;

        IssueHelpButton.Content = string.IsNullOrWhiteSpace(issue.HelpButtonLabel)
            ? "View Fix Steps"
            : issue.HelpButtonLabel;
        IssueHelpButton.Visibility = string.IsNullOrWhiteSpace(issue.HelpWindowContent)
            ? Visibility.Collapsed
            : Visibility.Visible;

        ConfigureIssueActionButton(IssueActionButton, issue.Action);
        ConfigureIssueActionButton(IssueSecondaryActionButton, issue.SecondaryAction);
        ConfigureIssueLinkButton(IssuePrimaryLinkButton, issue.PrimaryLink);
        ConfigureIssueLinkButton(IssueSecondaryLinkButton, issue.SecondaryLink);
        IssueDismissButton.Visibility = Visibility.Visible;
    }

    private static void ConfigureIssueActionButton(Button button, WorkspaceIssueAction? action)
    {
        if (action is null)
        {
            button.Visibility = Visibility.Collapsed;
            button.Tag = null;
            button.Content = string.Empty;
            return;
        }

        button.Visibility = Visibility.Visible;
        button.Tag = action;
        button.Content = action.Label;
    }

    private static void ConfigureIssueLinkButton(Button button, WorkspaceIssueExternalLink? link)
    {
        if (link is null)
        {
            button.Visibility = Visibility.Collapsed;
            button.Tag = null;
            button.Content = string.Empty;
            return;
        }

        button.Visibility = Visibility.Visible;
        button.Tag = link.Url;
        button.Content = link.Label;
    }

    private WorkspaceIssuePresentation ShowRuntimeIssue(string errorMessage)
    {
        _runtimeIssue = _settingsSnapshot.GetRuntimeIssueSimulation(_currentWorkspace?.FolderPath ?? "") == DeveloperRuntimeIssueSimulation.None
            ? WorkspaceIssueFactory.CreateRuntimeIssue(errorMessage, _currentInstallationState)
            : WorkspaceIssueFactory.CreateSimulatedRuntimeIssue(
                _settingsSnapshot.GetRuntimeIssueSimulation(_currentWorkspace?.FolderPath ?? ""),
                _currentInstallationState);
        _dismissedWorkspaceIssueKey = null;
        UpdateWorkspaceIssuePanel();
        return _runtimeIssue;
    }

    private void ClearRuntimeIssue()
    {
        if (_runtimeIssue is null)
            return;

        _runtimeIssue = null;
        UpdateWorkspaceIssuePanel();
    }

    private void RefreshDeveloperRuntimeIssuePreview()
    {
        if (_currentWorkspace is null || !File.Exists(Path.Combine(_currentWorkspace.FolderPath, "squad-dash.slnx")))
        {
            ClearRuntimeIssue();
            return;
        }

        if (_settingsSnapshot.GetRuntimeIssueSimulation(_currentWorkspace.FolderPath) == DeveloperRuntimeIssueSimulation.None)
        {
            ClearRuntimeIssue();
            return;
        }

        _runtimeIssue = WorkspaceIssueFactory.CreateSimulatedRuntimeIssue(
            _settingsSnapshot.GetRuntimeIssueSimulation(_currentWorkspace.FolderPath),
            _currentInstallationState);
        _dismissedWorkspaceIssueKey = null;
        UpdateWorkspaceIssuePanel();
    }

    private void IssueDismissButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var issue = _runtimeIssue ?? _startupIssue;
            if (issue is null)
                return;

            // If the panel shows an install button but the user hasn't pressed it yet,
            // warn before dismissing — SquadDash won't work without Squad installed.
            if (issue.ShowInstallButton && !_installSquadButtonPressed)
            {
                var result = MessageBox.Show(
                    "Are you sure you want to close this panel? SquadDash cannot run without having Squad installed first.",
                    "Close Panel",
                    MessageBoxButton.YesNo,
                    MessageBoxImage.Warning);
                if (result != MessageBoxResult.Yes)
                    return;
            }

            _dismissedWorkspaceIssueKey = WorkspaceIssuePanelState.BuildDismissalKey(issue);
            UpdateWorkspaceIssuePanel();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(IssueDismissButton_Click), ex);
        }
    }

    private bool IsDeveloperSimulationActive()
    {
        var folder = _currentWorkspace?.FolderPath ?? "";
        return _settingsSnapshot.GetStartupIssueSimulation(folder) != DeveloperStartupIssueSimulation.None ||
               _settingsSnapshot.GetRuntimeIssueSimulation(folder) != DeveloperRuntimeIssueSimulation.None;
    }

    private void IssueHelpButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var issue = _runtimeIssue ?? _startupIssue;
            if (issue is null || string.IsNullOrWhiteSpace(issue.HelpWindowContent))
                return;

            ShowTextWindow(
                issue.HelpWindowTitle ?? "Squad Help",
                issue.HelpWindowContent);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(IssueHelpButton_Click), ex);
        }
    }

    private void IssueLinkButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (sender is not FrameworkElement { Tag: string target })
                return;

            _squadCliAdapter.OpenExternalLink(target);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(IssueLinkButton_Click), ex);
        }
    }

    private void IssueActionButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ExecuteIssueAction(sender);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(IssueActionButton_Click), ex);
        }
    }

    private void IssueSecondaryActionButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ExecuteIssueAction(sender);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(IssueSecondaryActionButton_Click), ex);
        }
    }

    private void ExecuteIssueAction(object sender)
    {
        if (sender is not FrameworkElement { Tag: WorkspaceIssueAction action })
            return;

        switch (action.Kind)
        {
            case WorkspaceIssueActionKind.CopyText:
                if (string.IsNullOrWhiteSpace(action.Argument))
                    return;
                Clipboard.SetText(action.Argument);
                SetInstallStatus(BuildIssueActionStatusMessage(action, launched: false));
                break;

            case WorkspaceIssueActionKind.LaunchPowerShellCommand:
                if (string.IsNullOrWhiteSpace(action.Argument))
                    return;
                if (string.Equals(action.Label, "Install PowerShell 7", StringComparison.OrdinalIgnoreCase))
                    _pendingPowerShellInstallRecheck = true;
                _squadCliAdapter.LaunchPowerShellCommandWindow(action);
                SetInstallStatus(BuildIssueActionStatusMessage(action, launched: true));
                break;
        }
    }

    private static string BuildIssueActionStatusMessage(WorkspaceIssueAction action, bool launched)
    {
        if (action.Kind == WorkspaceIssueActionKind.CopyText)
            return "Copied the command to the clipboard.";

        return action.Label switch
        {
            "Run Build in PowerShell" => "Opened a PowerShell window to run the build check.",
            "Install PowerShell 7" => "Opened a PowerShell window to install PowerShell 7.",
            _ => launched
                ? $"Opened a PowerShell window for {action.Label.ToLowerInvariant()}."
                : $"Completed {action.Label.ToLowerInvariant()}."
        };
    }

    private void UpdateInteractiveControlState(bool promptTextOnly = false)
    {
        var canAbortBackgroundTask = promptTextOnly && _lastInteractiveControlState is not null
            ? _lastCanAbortBackgroundTask
            : _backgroundTaskPresenter.GetAbortTargets().Count > 0;
        if (!promptTextOnly || _lastInteractiveControlState is null)
            _lastCanAbortBackgroundTask = canAbortBackgroundTask;

        var state = InteractiveControlStateCalculator.Calculate(
            hasWorkspace: _currentWorkspace is not null,
            squadInstalled: _currentInstallationState?.IsSquadInstalledForActiveDirectory == true,
            isInstallingSquad: _isInstallingSquad,
            isPromptRunning: _isPromptRunning,
            canAbortBackgroundTask: canAbortBackgroundTask,
            currentPromptText: PromptTextBox.Text);
        var previous = _lastInteractiveControlState;
        _lastInteractiveControlState = state;

        if (previous?.AgentItemsEnabled != state.AgentItemsEnabled)
        {
            SetIsEnabledIfChanged(StatusAgentPanelsGrid, state.AgentItemsEnabled);
            SetIsEnabledIfChanged(ActiveAgentItemsControl, state.AgentItemsEnabled);
            SetIsEnabledIfChanged(InactiveAgentItemsControl, state.AgentItemsEnabled);
        }

        if (previous?.OutputEnabled != state.OutputEnabled)
        {
            SetIsEnabledIfChanged(OutputTextBox, state.OutputEnabled);
            foreach (var entry in _primaryAgentTranscriptHosts.Values)
                SetIsEnabledIfChanged(entry.TranscriptBox, state.OutputEnabled);
        }

        SetIsEnabledIfChanged(PromptTextBox, state.PromptEnabled);
        SetIsEnabledIfChanged(RunButton, state.RunEnabled
            || ((_isPromptRunning || IsLoopRunning) && _currentWorkspace is not null));
        SetIsEnabledIfChanged(AbortButton, state.AbortEnabled);
        if (RunDoctorMenuItem is not null)
            SetIsEnabledIfChanged(RunDoctorMenuItem, state.RunDoctorEnabled);
        SetIsEnabledIfChanged(InstallSquadButton, state.InstallSquadEnabled);
        SetIsEnabledIfChanged(IssueHelpButton, true);
        SetIsEnabledIfChanged(IssueActionButton, true);
        SetIsEnabledIfChanged(IssueSecondaryActionButton, true);
        SetIsEnabledIfChanged(IssuePrimaryLinkButton, true);
        SetIsEnabledIfChanged(IssueSecondaryLinkButton, true);
    }

    private static void SetIsEnabledIfChanged(UIElement element, bool isEnabled)
    {
        if (element.IsEnabled != isEnabled)
            element.IsEnabled = isEnabled;
    }

    private void UpdateWindowTitle()
    {
        var solutionDisplay = _currentSolutionName is { Length: > 0 }
            ? Path.GetFileNameWithoutExtension(_currentSolutionName)
            : null;
        Title = solutionDisplay is { Length: > 0 }
            ? $"SquadDash - {solutionDisplay}"
            : _currentWorkspace is { FolderPath.Length: > 0 }
                ? $"SquadDash - {Path.GetFileName(_currentWorkspace.FolderPath)}"
                : "SquadDash";
        // Keep the titlebar workspace label in sync
        WorkspaceTitleDisplay = solutionDisplay is { Length: > 0 }
            ? solutionDisplay
            : _currentWorkspace is { FolderPath.Length: > 0 }
                ? Path.GetFileName(_currentWorkspace.FolderPath)
                : "Squad Dash";
        SyncNoWorkspaceHintOverlay();
    }

    private void SyncNoWorkspaceHintOverlay()
    {
        var show = _currentWorkspace is null;
        NoWorkspaceHintOverlay.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
        // When no workspace is loaded there is no transcript to load, so hide the
        // "Loading transcript..." overlay that starts visible at startup.
        if (show)
            LoadingTranscriptOverlay.Visibility = Visibility.Collapsed;
    }

    // -----------------------------------------------------------------------
    // Custom titlebar — WorkspaceTitleDisplay dependency property
    // -----------------------------------------------------------------------

    public static readonly DependencyProperty WorkspaceTitleDisplayProperty =
        DependencyProperty.Register(
            nameof(WorkspaceTitleDisplay),
            typeof(string),
            typeof(MainWindow),
            new PropertyMetadata("Squad Dash"));

    public string WorkspaceTitleDisplay
    {
        get => (string)GetValue(WorkspaceTitleDisplayProperty);
        set => SetValue(WorkspaceTitleDisplayProperty, value);
    }

    // -----------------------------------------------------------------------
    // Custom titlebar — caption button handlers
    // -----------------------------------------------------------------------

    private void WorkspaceTitleText_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        try
        {
            var path = _currentSolutionPath;
            if (string.IsNullOrEmpty(path) || !File.Exists(path))
            {
                // Fall back to folder if no solution file
                var folder = _currentWorkspace?.FolderPath;
                if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder))
                    Process.Start("explorer.exe", folder);
                return;
            }
            Process.Start("explorer.exe", $"/select,\"{path}\"");
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(WorkspaceTitleText_MouseLeftButtonUp), ex);
        }
    }

    private void WorkspaceTitleText_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
    {
        var menu = MakeMenu();
        var showItem = MakeItem("Show in Explorer");
        showItem.Click += (_, _) => WorkspaceTitleText_MouseLeftButtonUp(sender, e);
        menu.Items.Add(showItem);
        menu.IsOpen = true;
        e.Handled = true;
    }

    private void VersionTextBlock_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        ShowVersionContextMenu();
        e.Handled = true;
    }

    private void VersionTextBlock_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
    {
        ShowVersionContextMenu();
        e.Handled = true;
    }

    private void SquadUpdateBadge_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        ShowVersionContextMenu();
        e.Handled = true;
    }

    private void ShowVersionContextMenu()
    {
        var menu = MakeMenu();
        var copyItem = MakeItem("Copy Squad system info");
        copyItem.Click += (_, _) => CopySquadSystemInfoToClipboard();
        menu.Items.Add(copyItem);

        var latestVersion = _squadCliAdapter.LatestSquadVersion;
        if (!string.IsNullOrWhiteSpace(latestVersion) && IsNewerSquadVersion(latestVersion, _squadCliAdapter.SquadVersion))
        {
            menu.Items.Add(MakeSep());
            var updateItem = MakeItem($"Update Squad CLI to v{latestVersion}");
            updateItem.Click += (_, _) => RunSquadCliUpdate(latestVersion);
            menu.Items.Add(updateItem);
        }

        menu.IsOpen = true;
    }

    private void UpdateSquadUpdateBadge()
    {
        if (SquadUpdateBadge is null)
            return;
        var latestVersion = _squadCliAdapter.LatestSquadVersion;
        var installedVersion = _squadCliAdapter.SquadVersion;
        var hasUpdate = !string.IsNullOrWhiteSpace(latestVersion) && IsNewerSquadVersion(latestVersion, installedVersion);
        SquadUpdateBadge.Visibility = hasUpdate ? Visibility.Visible : Visibility.Collapsed;
        if (hasUpdate)
            SquadUpdateBadge.ToolTip = $"Squad CLI v{latestVersion} available — click to update";
        UpdateTitleBarResponsiveLayout();
    }

    private void TitlebarGrid_SizeChanged(object sender, SizeChangedEventArgs e) =>
        UpdateTitleBarResponsiveLayout();

    private void UpdateTitleBarResponsiveLayout()
    {
        if (TitlebarGrid is null)
            return;

        double w = ActualWidth;

        // Priority 6 — drop first: SquadDash version + Squad version panel
        bool showVersions = w >= 900;
        SquadDashVersionTextBlock.Visibility = showVersions ? Visibility.Visible : Visibility.Collapsed;
        SquadVersionPanel.Visibility = showVersions ? Visibility.Visible : Visibility.Collapsed;

        // Priority 5 — drop second: workspace folder name
        WorkspaceTitleText.Visibility = w >= 700 ? Visibility.Visible : Visibility.Collapsed;

        // Priority 4 — drop third: search panel (only when no search is active)
        if (w < 550)
        {
            bool searchActive = SearchBox.Text.Length > 0 || FindNextButton.Visibility == Visibility.Visible;
            if (!searchActive)
                SearchStackPanel.Visibility = Visibility.Collapsed;
        }
        else
        {
            SearchStackPanel.Visibility = Visibility.Visible;
        }
    }

    private void RunSquadCliUpdate(string targetVersion)
    {
        var action = new WorkspaceIssueAction(
            "Update Squad CLI",
            WorkspaceIssueActionKind.LaunchPowerShellCommand,
            $"npm install @bradygaster/squad-cli@{targetVersion}");
        _squadCliAdapter.LaunchPowerShellCommandWindow(action);
    }

    /// <summary>
    /// Returns <c>true</c> if the installed Squad CLI version supports <c>squad loop</c>.
    /// The <c>--agent-cmd</c> flag required for Windows CLI looping was added in 0.9.5.
    /// When the version is unknown (null/empty) we allow it so we don't block users whose
    /// version resolution is still pending or failed.
    /// </summary>
    private static bool SquadCliSupportsLoop(string? version)
    {
        if (string.IsNullOrWhiteSpace(version)) return true;
        var v = ParseSimpleVersion(version);
        if (v is null) return true;
        // Minimum: 0.9.5
        if (v[0] > 0) return true;
        if (v[0] < 0) return false;
        if (v[1] > 9) return true;
        if (v[1] < 9) return false;
        return v[2] >= 5;
    }

    private static bool IsNewerSquadVersion(string candidate, string? current) =>
        DocumentUtilities.IsNewerSquadVersion(candidate, current);

    private static int[]? ParseSimpleVersion(string v) =>
        DocumentUtilities.ParseSimpleVersion(v);

    private void CopySquadSystemInfoToClipboard()
    {
        try
        {
            var squadVersion = _squadCliAdapter.SquadVersion;
            var sb = new System.Text.StringBuilder();
            sb.AppendLine($"SquadDash version: {AppVersion.Full}");
            sb.AppendLine($"Squad version:     {(string.IsNullOrWhiteSpace(squadVersion) ? "(unknown)" : squadVersion)}");
            sb.AppendLine($"Workspace folder:  {_currentWorkspace?.FolderPath ?? "(none)"}");
            if (!string.IsNullOrWhiteSpace(_currentSolutionPath))
                sb.AppendLine($"Solution file:     {_currentSolutionPath}");

            // Workspace-specific paths (only when a workspace is open).
            if (_currentWorkspace is not null)
            {
                var stateDir = _conversationManager.ConversationStore.GetWorkspaceStateDirectory(_currentWorkspace.FolderPath);
                sb.AppendLine($"Workspace state:   {stateDir}");
                sb.AppendLine($"Conversation file: {System.IO.Path.Combine(stateDir, "conversation.json")}");
            }

            sb.AppendLine($"Trace log:         {SquadDashTrace.CurrentLogPath}");
            sb.AppendLine($"OS:                {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
            sb.AppendLine($".NET runtime:      {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
            sb.AppendLine($"Architecture:      {System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}");
            Clipboard.SetText(sb.ToString());
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(CopySquadSystemInfoToClipboard), ex);
        }
    }

    private void MinimizeButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SystemCommands.MinimizeWindow(this);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MinimizeButton_Click), ex);
        }
    }

    private void MaximizeRestoreButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (WindowState == WindowState.Maximized)
                SystemCommands.RestoreWindow(this);
            else
                SystemCommands.MaximizeWindow(this);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MaximizeRestoreButton_Click), ex);
        }
    }

    private void CloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SystemCommands.CloseWindow(this);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(CloseButton_Click), ex);
        }
    }

    private void TitlebarGrid_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        try
        {
            // WindowChrome handles native drag for non-interactive areas (CaptionHeight region).
            // This handler catches double-click on the background to maximize/restore.
            if (e.ClickCount == 2 && e.OriginalSource is Grid)
            {
                if (_transcriptFullScreenEnabled)
                {
                    SetTranscriptFullScreen(false);
                }
                else if (WindowState == WindowState.Maximized)
                {
                    SystemCommands.RestoreWindow(this);
                }
                else
                {
                    SystemCommands.MaximizeWindow(this);
                }
                e.Handled = true;
            }
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(TitlebarGrid_MouseLeftButtonDown), ex);
        }
    }

    private void UpdateMaximizeRestoreIcon()
    {
        if (MaximizeIconCanvas is null) return;
        if (WindowState == WindowState.Maximized)
        {
            MaximizeIconCanvas.Visibility = Visibility.Collapsed;
            RestoreIconCanvas.Visibility = Visibility.Visible;
            MaximizeRestoreButton.ToolTip = "Restore";
        }
        else
        {
            MaximizeIconCanvas.Visibility = Visibility.Visible;
            RestoreIconCanvas.Visibility = Visibility.Collapsed;
            MaximizeRestoreButton.ToolTip = "Maximize";
        }
    }

    private void ActivateOwnedWindow()
    {
        if (_isClosing)
            return;

        try
        {
            if (WindowState == WindowState.Minimized)
                WindowState = WindowState.Normal;

            if (!IsVisible)
                Show();

            Activate();

            var wasTopmost = Topmost;
            if (!wasTopmost)
            {
                Topmost = true;
                Topmost = false;
            }

            var handle = new WindowInteropHelper(this).Handle;
            if (handle != nint.Zero)
                NativeMethods.TryActivateWindow(handle);

            Focus();
            if (PromptTextBox.IsEnabled)
                PromptTextBox.Focus();
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Workspace", $"Window activation failed: {ex.Message}");
        }
    }

    private async void MainWindow_Closed(object? sender, EventArgs e)
    {
        try
        {
            Microsoft.Win32.SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
            Microsoft.Win32.SystemEvents.PowerModeChanged -= OnPowerModeChanged;
            InputManager.Current.PreProcessInput -= MainWindow_PreProcessInput;
            DetachDispatcherDiagnostics();
            _isClosing = true;
            StopPttCtrlPollTimer();
            _promptHealthTimer.Stop();
            _statusPresentationTimer.Stop();
            _responseRenderTimer.Stop();
            _uiResponsivenessTimer.Stop();
            _teamRefreshDebounceTimer.Stop();
            _speechService?.Dispose();
            _speechService = null;
            var pendingPlacement = _pendingWindowPlacement;
            var pendingUtilityWindowState = _pendingUtilityWindowState;
            var pendingDocsPanelState = _pendingDocsPanelState;
            var pendingConversation = _conversationManager.PendingConversationSave;
            var closedSw = Stopwatch.StartNew();
            SquadDashTrace.Write(TraceCategory.Shutdown, "MainWindow_Closed: begin async dispose (WhenAll).");
            await Task.WhenAll(
                Task.Run(() =>
                {
                    var sw = Stopwatch.StartNew();
                    RemoveRunningInstanceRegistration();
                    SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: RemoveRunningInstanceRegistration {sw.ElapsedMilliseconds}ms.");
                }),
                // All three settings saves share the same named mutex
                // (Local\SquadDash.ApplicationSettings).  Running them in separate
                // concurrent Tasks causes the re-entrant guard in MutexLease to reject
                // the second and third acquisition, silently dropping those saves.
                // Serialize them inside a single Task.Run to eliminate the contention.
                Task.Run(() =>
                {
                    var sw = Stopwatch.StartNew();
                    if (pendingPlacement is { } p)
                        _settingsStore.SaveWindowPlacement(p.FolderPath, p.Placement);
                    if (pendingUtilityWindowState is { } u)
                        _settingsStore.SaveUtilityWindowState(u.TasksOpen, u.TraceOpen);
                    if (pendingDocsPanelState is { } docs)
                        _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, new WorkspaceDocsPanelState
                        {
                            Open = docs.Open ? null : false,
                            ExpandedNodes = docs.ExpandedNodes,
                            SelectedTopic = docs.SelectedTopic,
                            PanelWidth = docs.DocsPanelWidth,
                            PanelWidthFraction = docs.DocsPanelWidthFraction,
                            SourceOpen = docs.DocsSourceOpen,
                            SourceWidth = docs.DocsSourceWidth,
                        });
                    SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: settings saves {sw.ElapsedMilliseconds}ms.");
                }),
                Task.Run(() =>
                {
                    var sw = Stopwatch.StartNew();
                    if (pendingConversation is { } c)
                        _conversationManager.ConversationStore.Save(c.FolderPath, c.State);
                    SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: conversation save {sw.ElapsedMilliseconds}ms.");
                }),
                _bridge.DisposeAsync().AsTask().ContinueWith(t =>
                    SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: bridge dispose {closedSw.ElapsedMilliseconds}ms elapsed (status={t.Status}).")),
                _instanceActivationChannel.DisposeAsync().AsTask().ContinueWith(t =>
                    SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: activationChannel dispose {closedSw.ElapsedMilliseconds}ms elapsed (status={t.Status}).")));
            SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: WhenAll complete {closedSw.ElapsedMilliseconds}ms.");
            _workspaceOwnershipLease?.Dispose();
            _workspaceOwnershipLease = null;
            _startupWorkspaceLease?.Dispose();
            _startupWorkspaceLease = null;
            DisposeInboxWatcher();
            DisposeTeamFileWatcher();
            DisposeRestartRequestWatcher();
            DisposeDocsWatcher();
            DisposeMaintenanceMdWatcher();
            _toolSpinnerTimer.Stop();
            SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closed: complete {closedSw.ElapsedMilliseconds}ms total.");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException("Shutdown", ex, showDialog: false);
        }
    }

    private void MainWindow_Closing(object? sender, CancelEventArgs e)
    {
        try
        {
            _mainWindowClosingInProgress = true;

            // If the restart-request watcher has not fired yet, adopt the persisted request
            // here so an automated launcher close cannot fall through to the manual-close dialog.
            TryAdoptPendingRestartRequestForClose();

            // If a deferred shutdown was already scheduled and has now fired, honour it — skip dialog.
            // Also skip dialog for rebuild-triggered restarts: the launcher wants to reload the new
            // binary immediately; queue items will resume in the reloaded instance.
            bool isDeferredClose = _deferredShutdown != DeferredShutdownMode.None || _restartPending;

            if (_pttState == PttState.Active || _pttDraining)
            {
                e.Cancel = true;
                _restartPending = true;
                _mainWindowClosingInProgress = false;
                SquadDashTrace.Write("Shutdown", "Close requested while PTT active or draining. Deferring restart until final phrase is received.");
                EmergencySaveAfterDrainingBridgeEvents("close-while-ptt-active");
                return;
            }

            // If the clipboard image editor is open, block the close and let the user finish
            // (or dismiss) all editors first.  Once the last one closes, OnClipboardEditorClosed
            // will call Close() again.
            if (_clipboardEditorOpen)
            {
                e.Cancel = true;
                _pendingShutdown = true;
                HideRestartingOverlay();
                if (_restartPending)
                {
                    SetInstallStatus("Build finished. Restart will happen after the image editor closes.");
                    UpdateSessionState("Restart pending");
                }
                // Bring the editor to the foreground so the user sees why the close was blocked.
                foreach (Window w in Application.Current.Windows)
                {
                    if (w is ClipboardImageEditorWindow editorWin)
                    {
                        editorWin.Activate();
                        break;
                    }
                }
                SquadDashTrace.Write("Shutdown", "Close requested while ClipboardImageEditorWindow is open. Deferring until editor closes.");
                _mainWindowClosingInProgress = false;
                return;
            }

            if (_restartPending && DeferPendingRestartIfBlocked("window-closing"))
            {
                e.Cancel = true;
                EmergencySaveAfterDrainingBridgeEvents("restart-close-deferred");
                _mainWindowClosingInProgress = false;
                return;
            }

            _backgroundTaskPresenter.RecoverStaleBackgroundThreads(DateTimeOffset.Now, "window-closing");

            // Queued items are only a blocking concern when the queue is actively running.
            // If the user manually paused the queue and no agent is in flight, the items
            // are dormant and closing immediately is safe — skip the confirmation dialog.
            // Background work entries are treated the same way: if the queue is manually
            // paused and no prompt is actively running, stale background entries should not
            // block the close dialog.
            bool queueBlocking = _promptQueue.Count > 0 && !_queueManuallyPaused;
            bool backgroundWorkBlocking = (_isPromptRunning || !_queueManuallyPaused) &&
                                          _backgroundTaskPresenter.HasRestartBlockingBackgroundWork();
            bool isBusy = _isPromptRunning ||
                          IsNativeLoopRunning ||
                          backgroundWorkBlocking ||
                          HasPendingDirectQuickReplyAgentFollowUp() ||
                          queueBlocking;
            if (isBusy && !isDeferredClose)
            {
                e.Cancel = true;
                EmergencySaveAfterDrainingBridgeEvents("busy-close-dialog");

                var dialog = new ShutdownProtectionWindow(
                    isRunning: _isPromptRunning,
                    hasQueue: _promptQueue.Count > 0,
                    isLoopRunning: IsNativeLoopRunning)
                {
                    Owner = this
                };

                if (dialog.ShowDialog() != true)
                {
                    SquadDashTrace.Write("Shutdown", "Close cancelled by user.");
                    _mainWindowClosingInProgress = false;
                    return;
                }

                switch (dialog.Choice)
                {
                    case ShutdownChoice.CloseNow:
                        SquadDashTrace.Write("Shutdown", "User chose Close Now — proceeding immediately.");
                        e.Cancel = false;
                        break; // fall through to cleanup below

                    case ShutdownChoice.AfterCurrentTurn:
                        _deferredShutdown = DeferredShutdownMode.AfterCurrentTurn;
                        SquadDashTrace.Write("Shutdown", "Deferred shutdown scheduled: after current turn.");
                        _mainWindowClosingInProgress = false;
                        return;

                    case ShutdownChoice.AfterAllQueued:
                        _deferredShutdown = DeferredShutdownMode.AfterAllQueued;
                        SquadDashTrace.Write("Shutdown", "Deferred shutdown scheduled: after all queued items.");
                        _mainWindowClosingInProgress = false;
                        return;

                    default:
                        _mainWindowClosingInProgress = false;
                        return;
                }
            }

            if (e.Cancel)
            {
                _mainWindowClosingInProgress = false;
                return;
            }

            // Flush any pending doc-source edits to disk BEFORE _isClosing is set.
            // This prevents two data-loss scenarios on build-triggered restarts:
            //   1. Unsaved edits in the 400 ms debounce window being silently dropped.
            //   2. WPF firing TextChanged on the RichTextBox during visual-tree teardown
            //      (template detachment / document reset) after _isClosing=true, which would
            //      restart the save timer with an empty document and then write
            //      frontmatter + "" to disk — wiping the file to frontmatter-only.
            _docSourceSaveTimer?.Stop();
            _docPreviewRefreshTimer?.Stop();
            SaveDocSourceToDisk();

            _deferredShutdown = DeferredShutdownMode.None;
            _isClosing = true;
            var closingSw = Stopwatch.StartNew();
            SquadDashTrace.Write(TraceCategory.Shutdown, "MainWindow_Closing: begin clean shutdown.");
            // Clear the loop resume flag on a user-initiated close so we don't auto-resume next
            // launch. On a build-triggered restart (_restartPending) we preserve the flag so the
            // new instance picks up where we left off and shows Stop/Abort instead of Start Loop.
            // A crash or kill signal never reaches this handler, so the flag also stays true there.
            if (_settingsSnapshot.LoopActiveOnExit && !_restartPending)
                _settingsSnapshot = _settingsStore.SaveLoopActive(false);
            // RC is intentionally NOT cleared on clean shutdown — we always want RC to auto-resume
            // on the next launch with the same token so the phone's saved link keeps working.
            // Users who want to stop RC should use "Stop Remote Access" explicitly (which clears
            // RemoteAccessActiveOnExit via HandleRcStopped).
            _instanceActivationChannel.Stop();
            SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: InstanceActivationChannel stopped {closingSw.ElapsedMilliseconds}ms.");
            // If a queued item is selected, the prompt box shows that item's text while the real
            // draft is stashed in _queuePreEditDraft. Switch back to Active Draft first so that
            // CaptureWorkspaceInputState reads the actual draft — not the queue item's text —
            // preventing a duplicate queue entry on the next launch.
            bool queueWasRightmostHeld = IsRightmostQueueTabActive();
            if (_activeTabId is not null)
                OnQueueTabClicked(null);
            // Re-apply the hold flag after OnQueueTabClicked: SyncQueuePanel() inside that
            // call always writes queueRightmostHeld=false (because _activeTabId is now null),
            // so we must re-persist the true value after the switch completes.
            if (queueWasRightmostHeld)
                _conversationManager.UpdateQueuedPromptsState(_promptQueue.Items, _followUpAttachments, queueRightmostHeld: true, activeDraftSimEntry: _pec.ActiveDraftSimEntry, activeTabIndex: GetActiveQueueTabIndex());
            SquadDashTrace.Write("Queue", $"Shutdown save: count={_promptQueue.Count} changedAt={QueueLastChangedForTrace()} wasHeld={queueWasRightmostHeld} restartPending={_restartPending} items={DescribeQueueForTrace(_promptQueue.Items)}");
            _conversationManager.CaptureWorkspaceInputState();
            CaptureWindowPlacement();
            _pendingUtilityWindowState = (
                _tasksStatusWindow is { IsVisible: true },
                _traceWindow is { IsVisible: true });
            // Capture docs panel state (only when panel is open; closed state is already
            // written by SetDocumentationMode when the user toggles it off).
            if (_documentationModeEnabled)
            {
                double? docsPanelWidth = null;
                double? docsPanelWidthFraction = null;
                if (DocsPanelColumn is not null && DocsPanelColumn.ActualWidth > 0)
                {
                    docsPanelWidth = DocsPanelColumn.ActualWidth;
                    if (MainGrid is not null && MainGrid.ActualWidth > 0)
                        docsPanelWidthFraction = DocsPanelColumn.ActualWidth / MainGrid.ActualWidth;
                }
                bool? docsSourceOpen = IsDocSourceVisible() ? true : (bool?)null;
                double? docsSourceWidth = IsDocSourceVisible() ? GetDocSourceSize() : (double?)null;

                _pendingDocsPanelState = (
                    Open: true,
                    ExpandedNodes: DocTopicsTreeView is not null
                        ? CollectExpandedDocNodes(DocTopicsTreeView.Items)
                        : null,
                    SelectedTopic: (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Tag as string,
                    DocsPanelWidth: docsPanelWidth,
                    DocsTopicsWidth: null,
                    DocsPanelWidthFraction: docsPanelWidthFraction,
                    DocsTopicsWidthFraction: null,
                    DocsSourceOpen: docsSourceOpen,
                    DocsSourceWidth: docsSourceWidth);

                // Save synchronously here — MainWindow_Closed is async void and may not
                // complete before the process exits.
                var s = _pendingDocsPanelState.Value;
                var docsPanelSaveSw = Stopwatch.StartNew();
                _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, new WorkspaceDocsPanelState
                {
                    Open = s.Open ? null : false,
                    ExpandedNodes = s.ExpandedNodes,
                    SelectedTopic = s.SelectedTopic,
                    PanelWidth = s.DocsPanelWidth,
                    PanelWidthFraction = s.DocsPanelWidthFraction,
                    SourceOpen = s.DocsSourceOpen,
                    SourceWidth = s.DocsSourceWidth,
                });
                SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: SaveDocsPanelState(docs-open) {docsPanelSaveSw.ElapsedMilliseconds}ms elapsed={closingSw.ElapsedMilliseconds}ms.");
            }
            // Write synchronously so state is on disk before the process exits.
            if (_pendingDocsPanelState is { } docs)
            {
                var pendingDocsSaveSw = Stopwatch.StartNew();
                _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, new WorkspaceDocsPanelState
                {
                    Open = docs.Open ? null : false,
                    ExpandedNodes = docs.ExpandedNodes,
                    SelectedTopic = docs.SelectedTopic,
                    PanelWidth = docs.DocsPanelWidth,
                    PanelWidthFraction = docs.DocsPanelWidthFraction,
                    SourceOpen = docs.DocsSourceOpen,
                    SourceWidth = docs.DocsSourceWidth,
                });
                SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: SaveDocsPanelState(pending) {pendingDocsSaveSw.ElapsedMilliseconds}ms elapsed={closingSw.ElapsedMilliseconds}ms.");
            }
            var emergencySaveSw = Stopwatch.StartNew();
            if (_currentWorkspace is not null)
            {
                SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: SaveWorkspaceShutdownTime path={_currentWorkspace.FolderPath}");
                _settingsStore.SaveWorkspaceShutdownTime(_currentWorkspace.FolderPath, DateTimeOffset.UtcNow);
                SquadDashTrace.Write(TraceCategory.Shutdown, "MainWindow_Closing: SaveWorkspaceShutdownTime done.");
            }
            else
            {
                SquadDashTrace.Write(TraceCategory.Shutdown, "MainWindow_Closing: _currentWorkspace is null — skipping SaveWorkspaceShutdownTime.");
            }
            EmergencySaveAfterDrainingBridgeEvents("main-window-closing");
            SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: EmergencySave {emergencySaveSw.ElapsedMilliseconds}ms elapsed={closingSw.ElapsedMilliseconds}ms.");
            SquadDashTrace.Write(TraceCategory.Shutdown, $"MainWindow_Closing: complete {closingSw.ElapsedMilliseconds}ms.");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(MainWindow_Closing), ex, showDialog: false);
        }
    }

    /// <summary>
    /// Synchronously flushes all conversation state (including any in-flight turn) to disk.
    /// Safe to call from Closing, unhandled exception handlers, or any crash path.
    /// </summary>

    private void UpdateRunningInstanceRegistration()
    {
        try
        {
            var launchFolder = _currentWorkspace?.FolderPath;
            if (string.IsNullOrWhiteSpace(launchFolder))
            {
                launchFolder = StartupWorkspaceResolver.Resolve(
                    null,
                    _settingsSnapshot.LastOpenedFolder,
                    TryGetApplicationRoot()) ?? Environment.CurrentDirectory;
            }

            _instanceRegistry.Upsert(new RunningInstanceRecord(
                _workspacePaths.ApplicationRoot,
                launchFolder,
                Environment.ProcessId,
                _processStartedAtUtcTicks,
                DateTimeOffset.UtcNow.Ticks)
            {
                ActiveWorkspaceFolder = _currentWorkspace?.FolderPath
            });
        }
        catch
        {
        }
    }

    private void CaptureWindowPlacement()
    {
        if (_currentWorkspace is null)
            return;

        try
        {
            var bounds = WindowState == WindowState.Normal ? new Rect(Left, Top, Width, Height) : RestoreBounds;
            if (bounds.Width <= 0 || bounds.Height <= 0)
                return;

            _pendingWindowPlacement = (
                _currentWorkspace.FolderPath,
                new WorkspaceWindowPlacement(
                    bounds.Left,
                    bounds.Top,
                    bounds.Width,
                    bounds.Height,
                    WindowState == WindowState.Maximized));
        }
        catch
        {
        }
    }

    private void SaveWorkspaceWindowPlacement()
    {
        if (_currentWorkspace is null)
            return;

        try
        {
            var bounds = WindowState == WindowState.Normal ? new Rect(Left, Top, Width, Height) : RestoreBounds;
            if (bounds.Width <= 0 || bounds.Height <= 0)
                return;

            _settingsSnapshot = _settingsStore.SaveWindowPlacement(
                _currentWorkspace.FolderPath,
                new WorkspaceWindowPlacement(
                    bounds.Left,
                    bounds.Top,
                    bounds.Width,
                    bounds.Height,
                    WindowState == WindowState.Maximized));
        }
        catch
        {
        }
    }

    private void RestoreWorkspaceWindowPlacement()
    {
        if (_currentWorkspace is null)
            return;

        if (!_settingsSnapshot.WindowPlacementByWorkspace.TryGetValue(_currentWorkspace.FolderPath, out var placement))
            return;

        if (!placement.IsUsable)
            return;

        WindowState = WindowState.Normal;
        Left = placement.Left;
        Top = placement.Top;
        Width = placement.Width;
        Height = placement.Height;

        if (!IsPlacementOnScreen(placement))
        {
            Left = SystemParameters.WorkArea.Left;
            Top = SystemParameters.WorkArea.Top;
        }
        else
        {
            // Validate that the restored bounds fit within the work area of the
            // nearest monitor. IsPlacementOnScreen only checks the title-bar strip
            // (Left .. Left+200, Top .. Top+30), so it passes even when a stale
            // narrow Width (saved from a remote/phone session) causes Left + Width
            // to overflow the monitor's right edge. When that happens, Windows
            // keeps the snapped HWND at the full docked width while WPF's layout
            // commits to the narrow saved Width — producing a solid black void to
            // the right of the visible content area. Clamping here prevents that.
            ClampRestoredBoundsToMonitor();
        }

        if (placement.IsMaximized)
            WindowState = WindowState.Maximized;
    }

    /// <summary>
    /// After applying a persisted placement, verifies that the window's right
    /// and bottom edges lie within the work area of the nearest monitor.
    /// If either edge overflows — which happens when a placement saved on a
    /// narrow display (e.g. an iPhone remote session) is restored on a wider
    /// one while the window is snapped/docked — the window is reset to a safe
    /// default size centred on that monitor's work area.
    /// </summary>
    private void ClampRestoredBoundsToMonitor()
    {
        try
        {
            // Convert WPF logical origin to physical pixels for the monitor query.
            var dpi   = System.Windows.Media.VisualTreeHelper.GetDpi(this);
            int physX = (int)(Left * dpi.DpiScaleX);
            int physY = (int)(Top  * dpi.DpiScaleY);

            var physWa  = NativeMethods.GetWorkAreaForPhysicalPoint(physX, physY);

            // Convert physical work-area edges back to WPF logical units.
            double waLeft   = physWa.Left   / dpi.DpiScaleX;
            double waTop    = physWa.Top    / dpi.DpiScaleY;
            double waRight  = physWa.Right  / dpi.DpiScaleX;
            double waBottom = physWa.Bottom / dpi.DpiScaleY;
            double waWidth  = waRight  - waLeft;
            double waHeight = waBottom - waTop;

            // Allow 1 logical pixel of rounding slack.
            bool overflowsRight  = Left + Width  > waRight  + 1.0;
            bool overflowsBottom = Top  + Height > waBottom + 1.0;

            if (overflowsRight || overflowsBottom)
            {
                // The persisted dimensions don't fit on the current monitor — most
                // likely the window was saved on a narrower display. Reset to a
                // comfortable default, centred on this monitor's work area.
                double safeWidth  = Math.Min(1200.0, waWidth);
                double safeHeight = Math.Min( 820.0, waHeight);

                // Never shrink below the window's declared minimum dimensions.
                if (safeWidth  < MinWidth)  safeWidth  = MinWidth;
                if (safeHeight < MinHeight) safeHeight = MinHeight;

                Width  = safeWidth;
                Height = safeHeight;
                Left   = waLeft + (waWidth  - safeWidth)  / 2.0;
                Top    = waTop  + (waHeight - safeHeight) / 2.0;
            }
        }
        catch
        {
            // Non-critical: if anything goes wrong the already-applied placement
            // is left as-is. The window may not be perfectly positioned but it
            // will still be functional.
        }
    }

    private static bool IsPlacementOnScreen(WorkspaceWindowPlacement placement)
    {
        // Check that a usable strip across the top of the window (title bar area) intersects
        // at least one monitor's working area, so the user can always grab and move the window.
        var titleBarLeft = (int)placement.Left;
        var titleBarTop = (int)placement.Top;
        var titleBarRight = (int)(placement.Left + Math.Min(placement.Width, 200));
        var titleBarBottom = (int)(placement.Top + 30);

        return NativeMethods.IsRectOnAnyMonitor(titleBarLeft, titleBarTop, titleBarRight, titleBarBottom);
    }

    private void RemoveRunningInstanceRegistration()
    {
        try
        {
            _instanceRegistry.Remove(
                _workspacePaths.ApplicationRoot,
                Environment.ProcessId,
                _processStartedAtUtcTicks);
        }
        catch
        {
        }
    }

    internal void ReportUnhandledUiException(string operation, Exception ex, bool showPanel = true)
    {
        if (Dispatcher.CheckAccess())
        {
            HandleUiCallbackException(operation, ex, showDialog: showPanel);
            return;
        }

        TryPostToUi(
            () => HandleUiCallbackException(operation, ex, showDialog: showPanel),
            $"Unhandled.{operation}");
    }

    private void HandleUiCallbackException(string operation, Exception ex, bool showDialog = true)
    {
        SquadDashTrace.Write("UI", $"{operation} callback failed: {ex}");

        if (!showDialog || _isClosing || Dispatcher.HasShutdownStarted || Dispatcher.HasShutdownFinished)
            return;

        try
        {
            ShowExceptionPanel(operation, ex);
        }
        catch (Exception panelEx)
        {
            SquadDashTrace.Write("UI", $"Failed to show exception panel for {operation}: {panelEx}");

            if (!CanShowOwnedWindow())
                return;

            try
            {
                MessageBox.Show(
                    this,
                    $"{operation} failed.{Environment.NewLine}{Environment.NewLine}{ex.Message}",
                    operation,
                    MessageBoxButton.OK,
                    MessageBoxImage.Error);
            }
            catch
            {
            }
        }
    }

    private void ShowExceptionPanel(string operation, Exception ex)
    {
        var title = $"{operation} failed";
        var summary = string.IsNullOrWhiteSpace(ex.Message)
            ? "An unexpected error occurred."
            : ex.Message.Trim();
        var details = BuildExceptionPanelDetails(operation, ex);

        _activeUiException = new UiExceptionPanelState(title, summary, details);
        ExceptionPanelTitleTextBlock.Text = title;
        ExceptionPanelSummaryTextBlock.Text = summary;
        ExceptionPanelTextBox.Text = details;
        ExceptionPanelTextBox.ScrollToHome();
        ExceptionPanelBorder.Visibility = Visibility.Visible;
        UpdateLeadAgent("Error", string.Empty, summary);
        UpdateSessionState("Error");
    }

    private static string BuildExceptionPanelDetails(string operation, Exception ex)
    {
        var builder = new StringBuilder();
        builder.AppendLine($"Operation: {operation}");
        builder.AppendLine($"Occurred: {DateTimeOffset.Now:O}");
        builder.AppendLine();
        builder.AppendLine(ex.ToString());
        return builder.ToString().TrimEnd();
    }

    private void CopyExceptionDetailsButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (_activeUiException is null || string.IsNullOrWhiteSpace(_activeUiException.Details))
                return;

            Clipboard.SetText(_activeUiException.Details);
            SquadDashTrace.Write("UI", "Copied exception details to clipboard.");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(CopyExceptionDetailsButton_Click), ex);
        }
    }

    private void DismissExceptionPanelButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            DismissExceptionPanel();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DismissExceptionPanelButton_Click), ex);
        }
    }

    private void DismissExceptionPanel()
    {
        _activeUiException = null;
        ExceptionPanelTextBox.Clear();
        ExceptionPanelSummaryTextBlock.Text = string.Empty;
        ExceptionPanelTitleTextBlock.Text = "Unexpected error";
        ExceptionPanelBorder.Visibility = Visibility.Collapsed;
    }

    private static PromptInputKey MapPromptInputKey(Key key)
    {
        return key switch
        {
            Key.Return or Key.Enter => PromptInputKey.Enter,
            Key.Up => PromptInputKey.Up,
            Key.Down => PromptInputKey.Down,
            Key.Tab => PromptInputKey.Tab,
            Key.Escape => PromptInputKey.Escape,
            Key.Q => PromptInputKey.Q,
            _ => PromptInputKey.Other
        };
    }

    private bool IsMultiLinePrompt()
    {
        return PromptTextBox.LineCount > 1 || PromptTextBox.Text.Contains('\n');
    }

    private void HideHistoryHint()
    {
        _historyHintTimer.Stop();
        HistoryHintBorder.Visibility = Visibility.Collapsed;
    }

    private MenuItem? OpenSquadFolderMenuItem;
    // Temporary accumulator used while building the sorted top group in RefreshSidebar.
    private readonly List<(string Header, string SortKey, Action ClickAction)> _squadFileMenuEntries = [];

    private void RefreshSidebar()
    {
        ClearWorkspaceMenuFileItems();
        DisposeInboxWatcher();

        if (_currentWorkspace is null)
        {
            OpenSquadFolderMenuItem?.IsEnabled = false;
            UpdateInteractiveControlState();
            SyncTasksPanel();
            return;
        }

        RefreshInstallationState();

        var squadRoot = _currentWorkspace.SquadFolderPath;
        var squadFolderExists = Directory.Exists(squadRoot);
        ConfigureTeamFileWatcher();
        InitMaintenanceMdWatcher(squadRoot);

        var loopMdPath        = Path.Combine(squadRoot, "loop.md");
        var maintenanceMdPath = Path.Combine(squadRoot, "maintenance.md");
        var tasksMdPath       = Path.Combine(squadRoot, "tasks.md");

        // Regular squad files: added only when they exist.
        foreach (var relativePath in new[] {
                     "ceremonies.md",
                     "decisions.md",
                     "history.md",
                     Path.Combine("identity", "now.md"),
                     "routing.md",
                     Path.Combine("skills", "project-conventions", "SKILL.md"),
                     "team.md",
                     Path.Combine("identity", "wisdom.md")
                 })
        {
            var fullPath = Path.Combine(squadRoot, relativePath);
            if (File.Exists(fullPath))
            {
                var e = new SidebarEntry("📄" + Path.GetFileName(relativePath), string.Empty, fullPath, true, SidebarEntryKind.File);
                _squadFileMenuEntries.Add(("📄" + Path.GetFileName(relativePath), Path.GetFileName(relativePath), () => OpenSidebarEntry(e)));
            }
        }

        // loop.md, maintenance.md, and tasks.md always appear; clicking creates the file when missing.
        _squadFileMenuEntries.Add(("🔁 loop.md",        "loop.md",        () => OpenOrCreateLoopMd(loopMdPath)));
        _squadFileMenuEntries.Add(("🔧 maintenance.md", "maintenance.md", () => OpenOrCreateMaintenanceMd(maintenanceMdPath)));
        _squadFileMenuEntries.Add(("📋 tasks.md",       "tasks.md",       () => OpenOrCreateTasksMd(tasksMdPath)));

        foreach (var (header, _, clickAction) in _squadFileMenuEntries.OrderBy(x => x.SortKey, StringComparer.OrdinalIgnoreCase))
        {
            var menuItem = new MenuItem { Header = header, Style = (Style)FindResource("ThemedMenuItemStyle") };
            menuItem.Click += (_, _) => clickAction();
            WorkspaceMenuItem.Items.Add(menuItem);
        }
        _squadFileMenuEntries.Clear();

        PopulateLoopFilePicker();
        RefreshLoopOptionsPanel();
        UpdateLoopPanelButtonStates();
        _pec.TasksFilePath = tasksMdPath;

        AddWorkspaceMenuSeparator();

        var hireMenuItem = new MenuItem
        {
            Header = "👤 _Hire New Agent",
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        hireMenuItem.Click += (_, _) =>
        {
            var submission = ShowHireAgentWindow();
            if (submission is null)
                return;
            EnqueuePrompt(submission.PromptText, isSystemInjected: false);
        };
        WorkspaceMenuItem.Items.Add(hireMenuItem);

        OpenSquadFolderMenuItem = new MenuItem
        {
            Header = "📂 _Squad Folder",
            Name = "OpenSquadFolderMenuItem",
            IsEnabled = squadFolderExists,
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        OpenSquadFolderMenuItem.Click += OpenSquadFolderMenuItem_Click;
        WorkspaceMenuItem.Items.Add(OpenSquadFolderMenuItem);

        AddWorkspaceFolderMenuItem(Path.Combine("decisions", "inbox"));

        AddWorkspaceMenuSeparator();

        var squadCliMenuItem = new MenuItem
        {
            Header = "Squad _CLI",
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        squadCliMenuItem.Click += SquadCliMenuItem_Click;
        WorkspaceMenuItem.Items.Add(squadCliMenuItem);

        _remoteAccessMenuItem = new MenuItem
        {
            Header = _settingsSnapshot.RemoteAccessActiveOnExit ? "Stop _Remote Access" : "Start _Remote Access",
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        _remoteAccessMenuItem.Click += RemoteAccessMenuItem_Click;
        RemoteMenuItem.Items.Add(_remoteAccessMenuItem);

        EnsureTintMenuItems();

        AddWorkspaceMenuSeparator();

        var powershellMenuItem = new MenuItem
        {
            Header = "PowerShell",
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        powershellMenuItem.Click += PowerShellMenuItem_Click;
        WorkspaceMenuItem.Items.Add(powershellMenuItem);

        ConfigureInboxWatcher(Path.Combine(squadRoot, "decisions", "inbox"));
        UpdateInteractiveControlState();
        SyncTasksPanel();
    }

    private void ClearWorkspaceMenuFileItems()
    {
        // WorkspaceMenuItem has no static XAML children — every item is dynamic,
        // so a full clear is both correct and simpler than tag-filtering.
        WorkspaceMenuItem.Items.Clear();
        RemoteMenuItem.Items.Clear();
    }

    private void AddWorkspaceFileMenuItem(string relativePath)
    {
        if (_currentWorkspace is null)
            return;

        var path = Path.Combine(_currentWorkspace.SquadFolderPath, relativePath);
        if (!File.Exists(path))
            return;

        var entry = new SidebarEntry(
            "📄" + Path.GetFileName(relativePath),
            string.Empty,
            path,
            true,
            SidebarEntryKind.File);
        AddWorkspaceEntryMenuItem(entry);
    }

    private void AddWorkspaceFolderMenuItem(string relativePath)
    {
        if (_currentWorkspace is null)
            return;

        var path = Path.Combine(_currentWorkspace.SquadFolderPath, relativePath);
        if (!Directory.Exists(path))
            return;

        var fileCount = CountFiles(path);
        var entry = new SidebarEntry(
            $"📂{Path.GetFileName(relativePath)} folder ({fileCount})",
            string.Empty,
            path,
            true,
            SidebarEntryKind.Folder);
        AddWorkspaceEntryMenuItem(entry);
    }

    private void AddWorkspaceEntryMenuItem(SidebarEntry entry)
    {
        //WorkspaceFileSeparator.Visibility = Visibility.Visible;
        var item = new MenuItem
        {
            Header = entry.Title,
            Tag = entry,
            Style = (Style)FindResource("ThemedMenuItemStyle")
        };
        item.Click += (_, _) => OpenSidebarEntry(entry);
        WorkspaceMenuItem.Items.Add(item);
    }

    private void OpenOrCreateLoopMd(string loopMdPath)
    {
        try
        {
            if (!File.Exists(loopMdPath))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(loopMdPath)!);
                File.WriteAllText(loopMdPath,
                    "---\n" +
                    "configured: true\n" +
                    "interval: 10\n" +
                    "timeout: 60\n" +
                    "description: \"My loop\"\n" +
                    "commands: [stop_loop]\n" +
                    "---\n" +
                    "\n" +
                    "# Loop Instructions\n" +
                    "\n" +
                    "You are running in autonomous loop mode. On each iteration:\n" +
                    "\n" +
                    "1. Check for outstanding tasks in `.squad/tasks.md`\n" +
                    "2. Pick the highest-priority unchecked item\n" +
                    "3. Work on it and mark it `[x]` when done\n" +
                    "4. Report what you accomplished\n" +
                    "\n" +
                    "When all tasks are complete (or all remaining tasks are owned by User), stop the loop:\n" +
                    "\n" +
                    "```\n" +
                    "HOST_COMMAND_JSON:\n" +
                    "[\n" +
                    "  { \"command\": \"stop_loop\" }\n" +
                    "]\n" +
                    "```\n");
                PopulateLoopFilePicker();
                RefreshLoopOptionsPanel();
                UpdateLoopPanelButtonStates();
            }

            var config          = LoopMdParser.Parse(loopMdPath);
            var initialDesc     = config?.Description ?? "";
            var loopEditCtx     = new LoopEditContext(initialDesc, newDesc => {
                LoopMdParser.UpdateDescription(loopMdPath, newDesc);
                PopulateLoopFilePicker();
            });
            MarkdownDocumentWindow.Show(
                CanShowOwnedWindow() ? this : null,
                "Loop Instructions",
                loopMdPath,
                showSource: true,
                loopEditContext: loopEditCtx);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenOrCreateLoopMd), ex);
        }
    }

    private void OpenOrCreateMaintenanceMd(string maintenanceMdPath)
    {
        try
        {
            if (!File.Exists(maintenanceMdPath))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(maintenanceMdPath)!);
                File.WriteAllText(maintenanceMdPath,
                    "---\n" +
                    "idle_threshold_minutes: 15\n" +
                    "max_tasks_per_cycle: 5\n" +
                    "safety: branch\n" +
                    "---\n" +
                    "\n" +
                    "# Maintenance Tasks\n" +
                    "\n" +
                    "Add maintenance tasks below. Each task runs during idle time.\n");
            }
            OpenMarkdownFile(maintenanceMdPath, "Maintenance", showSource: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenOrCreateMaintenanceMd), ex);
        }
    }

    private void OpenOrCreateTasksMd(string tasksMdPath)
    {
        try
        {
            if (!File.Exists(tasksMdPath))
            {
                Directory.CreateDirectory(Path.GetDirectoryName(tasksMdPath)!);
                File.WriteAllText(tasksMdPath,
                    "## 🔴 High Priority\n\n## 🟡 Mid Priority\n\n## 🟢 Low Priority\n");
                _pec.TasksFilePath = tasksMdPath;
                SyncTasksPanel();
            }
            OpenMarkdownFile(tasksMdPath, "Tasks", showSource: true);
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(OpenOrCreateTasksMd), ex);
        }
    }

    private void UpdateLoopPanelButtonStates()
    {
        if (_currentWorkspace is null) return;
        var loopMdPath = GetEffectiveLoopMdPath();
        var loopExists = File.Exists(loopMdPath);

        if (StartLoopButton is not null)
            StartLoopButton.IsEnabled = loopExists && !IsLoopRunning;
    }

    private void ConfigureInboxWatcher(string inboxPath)
    {
        DisposeInboxWatcher();
        if (!Directory.Exists(inboxPath))
            return;

        _inboxWatcher = new FileSystemWatcher(inboxPath)
        {
            IncludeSubdirectories = true,
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
        };
        _inboxWatcher.Created += InboxWatcher_Changed;
        _inboxWatcher.Deleted += InboxWatcher_Changed;
        _inboxWatcher.Renamed += InboxWatcher_Renamed;
        _inboxWatcher.Changed += InboxWatcher_Changed;
        _inboxWatcher.EnableRaisingEvents = true;
    }

    private void ConfigureTeamFileWatcher()
    {
        DisposeTeamFileWatcher();
        if (_currentWorkspace is null)
            return;

        var squadFolderPath = _currentWorkspace.SquadFolderPath;

        // If .squad doesn't exist yet, watch from the workspace root so we still
        // detect .squad/tasks.md creation (e.g. when a loop run first creates it).
        var watchPath = Directory.Exists(squadFolderPath)
            ? squadFolderPath
            : _currentWorkspace.FolderPath;

        if (!Directory.Exists(watchPath))
            return;

        _teamFileWatcher = new FileSystemWatcher(watchPath, "*.md")
        {
            IncludeSubdirectories = true,
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime
        };
        _teamFileWatcher.Changed += TeamFileWatcher_Changed;
        _teamFileWatcher.Created += TeamFileWatcher_Changed;
        _teamFileWatcher.Deleted += TeamFileWatcher_Changed;
        _teamFileWatcher.Renamed += TeamFileWatcher_Renamed;
        _teamFileWatcher.EnableRaisingEvents = true;
    }

    private void InboxWatcher_Changed(object sender, FileSystemEventArgs e)
    {
        try
        {
            TryPostToUi(RefreshSidebar, "InboxWatcher.Changed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(InboxWatcher_Changed), ex, showDialog: false);
        }
    }

    private void InboxWatcher_Renamed(object sender, RenamedEventArgs e)
    {
        try
        {
            TryPostToUi(RefreshSidebar, "InboxWatcher.Renamed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(InboxWatcher_Renamed), ex, showDialog: false);
        }
    }

    private void TeamFileWatcher_Changed(object sender, FileSystemEventArgs e)
    {
        try
        {
            TryPostToUi(() => HandleSquadMarkdownWatcherChange(e.FullPath), "TeamFileWatcher.Changed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(TeamFileWatcher_Changed), ex, showDialog: false);
        }
    }

    private void TeamFileWatcher_Renamed(object sender, RenamedEventArgs e)
    {
        try
        {
            TryPostToUi(() => HandleSquadMarkdownWatcherRename(e.OldFullPath, e.FullPath), "TeamFileWatcher.Renamed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(TeamFileWatcher_Renamed), ex, showDialog: false);
        }
    }

    private void HandleSquadMarkdownWatcherChange(string? fullPath)
    {
        if (_currentWorkspace is null) return;

        // Always refresh the loop picker and button states when any loop*.md file changes.
        if (fullPath is not null &&
            System.Text.RegularExpressions.Regex.IsMatch(
                Path.GetFileName(fullPath),
                @"^loop.*\.md$",
                System.Text.RegularExpressions.RegexOptions.IgnoreCase))
        {
            PopulateLoopFilePicker();
            RefreshLoopOptionsPanel();
            UpdateLoopPanelButtonStates();
        }

        // Reload the tasks panel whenever tasks.md changes.
        if (fullPath is not null &&
            fullPath.EndsWith("tasks.md", StringComparison.OrdinalIgnoreCase) &&
            _tasksPanelVisible)
        {
            LoadTasksPanel();
        }

        if (!RoutingIssueWatchPathPolicy.IsRelevantPath(_currentWorkspace.SquadFolderPath, fullPath))
            return;

        ScheduleAgentRefreshFromTeamWatcher();
    }

    private void HandleSquadMarkdownWatcherRename(string? oldFullPath, string? newFullPath)
    {
        if (_currentWorkspace is null)
            return;

        if (!RoutingIssueWatchPathPolicy.IsRelevantPath(_currentWorkspace.SquadFolderPath, oldFullPath) &&
            !RoutingIssueWatchPathPolicy.IsRelevantPath(_currentWorkspace.SquadFolderPath, newFullPath))
        {
            return;
        }

        ScheduleAgentRefreshFromTeamWatcher();
    }

    private void ScheduleAgentRefreshFromTeamWatcher()
    {
        _teamRefreshDebounceTimer.Stop();
        _teamRefreshDebounceTimer.Start();
    }

    private void TeamRefreshDebounceTimer_Tick(object? sender, EventArgs e)
    {
        try
        {
            _teamRefreshDebounceTimer.Stop();
            RefreshInstallationState();
            RefreshAgentCards();
            RefreshSidebar();
            MaybePublishRoutingIssueSystemEntry("team-files-changed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(TeamRefreshDebounceTimer_Tick), ex);
        }
    }

    private void DisposeInboxWatcher()
    {
        if (_inboxWatcher is null)
            return;

        _inboxWatcher.EnableRaisingEvents = false;
        _inboxWatcher.Created -= InboxWatcher_Changed;
        _inboxWatcher.Deleted -= InboxWatcher_Changed;
        _inboxWatcher.Renamed -= InboxWatcher_Renamed;
        _inboxWatcher.Changed -= InboxWatcher_Changed;
        _inboxWatcher.Dispose();
        _inboxWatcher = null;
    }

    private void DisposeTeamFileWatcher()
    {
        _teamRefreshDebounceTimer.Stop();

        if (_teamFileWatcher is null)
            return;

        _teamFileWatcher.EnableRaisingEvents = false;
        _teamFileWatcher.Changed -= TeamFileWatcher_Changed;
        _teamFileWatcher.Created -= TeamFileWatcher_Changed;
        _teamFileWatcher.Deleted -= TeamFileWatcher_Changed;
        _teamFileWatcher.Renamed -= TeamFileWatcher_Renamed;
        _teamFileWatcher.Dispose();
        _teamFileWatcher = null;
    }

    private void ConfigureRestartRequestWatcher()
    {
        DisposeRestartRequestWatcher();

        try
        {
            var requestPath = _restartCoordinatorStateStore.GetRequestPathForWatcher(_workspacePaths.ApplicationRoot);
            var directory = Path.GetDirectoryName(requestPath);
            var fileName = Path.GetFileName(requestPath);
            if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName))
                return;

            Directory.CreateDirectory(directory);
            _lastHandledRestartRequestId = _restartCoordinatorStateStore.LoadRequest(_workspacePaths.ApplicationRoot)?.RequestId;

            _restartRequestWatcher = new FileSystemWatcher(directory, fileName)
            {
                NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime
            };
            _restartRequestWatcher.Changed += RestartRequestWatcher_Changed;
            _restartRequestWatcher.Created += RestartRequestWatcher_Changed;
            _restartRequestWatcher.Renamed += RestartRequestWatcher_Renamed;
            _restartRequestWatcher.EnableRaisingEvents = true;
        }
        catch
        {
        }
    }

    private void DisposeRestartRequestWatcher()
    {
        if (_restartRequestWatcher is null)
            return;

        _restartRequestWatcher.EnableRaisingEvents = false;
        _restartRequestWatcher.Changed -= RestartRequestWatcher_Changed;
        _restartRequestWatcher.Created -= RestartRequestWatcher_Changed;
        _restartRequestWatcher.Renamed -= RestartRequestWatcher_Renamed;
        _restartRequestWatcher.Dispose();
        _restartRequestWatcher = null;
    }

    private void RestartRequestWatcher_Changed(object sender, FileSystemEventArgs e)
    {
        try
        {
            TryPostToUi(HandleRestartRequestChanged, "RestartRequestWatcher.Changed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RestartRequestWatcher_Changed), ex, showDialog: false);
        }
    }

    private void RestartRequestWatcher_Renamed(object sender, RenamedEventArgs e)
    {
        try
        {
            TryPostToUi(HandleRestartRequestChanged, "RestartRequestWatcher.Renamed");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RestartRequestWatcher_Renamed), ex, showDialog: false);
        }
    }

    private void ConfigureDocsWatcher()
    {
        DisposeDocsWatcher();

        try
        {
            var docsPath = DocTopicsLoader.FindDocsFolderPath();
            if (string.IsNullOrEmpty(docsPath) || !Directory.Exists(docsPath))
                return;

            _docsWatcher = new FileSystemWatcher(docsPath, "*.md")
            {
                IncludeSubdirectories = true,
                NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime
            };
            _docsWatcher.Created += DocsWatcher_Changed;
            _docsWatcher.Deleted += DocsWatcher_Changed;
            _docsWatcher.Renamed += DocsWatcher_Renamed;
            _docsWatcher.Changed += DocsWatcher_Changed;
            _docsWatcher.EnableRaisingEvents = true;
        }
        catch
        {
        }
    }

    private void DisposeDocsWatcher()
    {
        if (_docsWatcher is null)
            return;

        _docsWatcher.EnableRaisingEvents = false;
        _docsWatcher.Created -= DocsWatcher_Changed;
        _docsWatcher.Deleted -= DocsWatcher_Changed;
        _docsWatcher.Renamed -= DocsWatcher_Renamed;
        _docsWatcher.Changed -= DocsWatcher_Changed;
        _docsWatcher.Dispose();
        _docsWatcher = null;
        _docsRefreshCts?.Cancel();
        _docsRefreshCts?.Dispose();
        _docsRefreshCts = null;
    }

    private void InitMaintenanceMdWatcher(string squadFolder) {
        DisposeMaintenanceMdWatcher();
        if (!Directory.Exists(squadFolder)) return;
        _maintenanceMdWatcher = new FileSystemWatcher(squadFolder, "maintenance.md") {
            NotifyFilter          = NotifyFilters.LastWrite | NotifyFilters.Size,
            EnableRaisingEvents   = true,
            IncludeSubdirectories = false,
        };
        _maintenanceMdWatcher.Changed += OnMaintenanceMdChanged;
        _maintenanceMdWatcher.Created += OnMaintenanceMdChanged;
    }

    private void OnMaintenanceMdChanged(object sender, FileSystemEventArgs e) {
        var timer = new System.Timers.Timer(300) { AutoReset = false };
        timer.Elapsed += (_, _) => {
            timer.Dispose();
            Dispatcher.InvokeAsync(SyncMaintenancePanel);
        };
        timer.Start();
    }

    private void DisposeMaintenanceMdWatcher() {
        if (_maintenanceMdWatcher is null) return;
        _maintenanceMdWatcher.EnableRaisingEvents = false;
        _maintenanceMdWatcher.Changed -= OnMaintenanceMdChanged;
        _maintenanceMdWatcher.Created -= OnMaintenanceMdChanged;
        _maintenanceMdWatcher.Dispose();
        _maintenanceMdWatcher = null;
    }

    private ByokProviderSettings? BuildByokSettingsFromStore()
    {
        return BuildByokSettings(_settingsStore.Load());
    }

    private static ByokProviderSettings? BuildByokSettings(ApplicationSettingsSnapshot snapshot)
    {
        if (string.IsNullOrEmpty(snapshot.ByokProviderUrl))
            return null;

        return new ByokProviderSettings(
            snapshot.ByokProviderUrl,
            snapshot.ByokModel,
            snapshot.ByokProviderType,
            snapshot.ByokApiKey);
    }

    private void RestartBridgeForSettingsWhenIdle(string reason)
    {
        if (_isPromptRunning || IsLoopRunning || _backgroundTaskPresenter.HasRestartBlockingBackgroundWork())
        {
            _bridgeRestartForSettingsPending = true;
            SquadDashTrace.Write(
                "Bridge",
                $"Deferring bridge restart for settings reason={reason} promptRunning={_isPromptRunning} loopRunning={IsLoopRunning} background={_backgroundTaskPresenter.HasRestartBlockingBackgroundWork()}");
            return;
        }

        SquadDashTrace.Write("Bridge", $"Restarting bridge for settings reason={reason}");
        _bridge.RestartBridgeForNewSettings();
    }

    private void ApplyPendingBridgeSettingsRestartIfIdle(string reason)
    {
        if (!_bridgeRestartForSettingsPending)
            return;

        if (_isPromptRunning || IsLoopRunning || _backgroundTaskPresenter.HasRestartBlockingBackgroundWork())
        {
            SquadDashTrace.Write(
                "Bridge",
                $"Bridge settings restart remains pending reason={reason} promptRunning={_isPromptRunning} loopRunning={IsLoopRunning} background={_backgroundTaskPresenter.HasRestartBlockingBackgroundWork()}");
            return;
        }

        _bridgeRestartForSettingsPending = false;
        SquadDashTrace.Write("Bridge", $"Applying deferred bridge restart for settings reason={reason}");
        _bridge.RestartBridgeForNewSettings();
    }

    private ITtsProvider? BuildTtsProvider(ApplicationSettingsSnapshot s)
    {
        if (s.Tts_Provider == TtsProvider.Azure)
        {
            var key = Environment.GetEnvironmentVariable("SQUAD_SPEECH_KEY", EnvironmentVariableTarget.User);
            if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(s.SpeechRegion))
                return new AzureTtsProvider(key, s.SpeechRegion, s.Tts_Azure_Voice);
        }
        else if (s.Tts_Provider == TtsProvider.OpenAI)
        {
            if (!string.IsNullOrWhiteSpace(s.OpenAiSpeechApiKey))
            {
                var model = s.Tts_OpenAi_Model == OpenAiTtsModel.HD ? "tts-1-hd" : "tts-1";
                return new OpenAiTtsProvider(s.OpenAiSpeechApiKey, s.Tts_OpenAi_Voice, model);
            }
        }
        return null;
    }

    private void DocsWatcher_Changed(object sender, FileSystemEventArgs e)
    {
        try
        {
            // Skip refreshes caused by our own debounced save. External changes (e.g. from an
            // AI tool writing the file) land outside the suppression window and should reload.
            if (!string.IsNullOrEmpty(_currentDocPath)
                && string.Equals(e.FullPath, _currentDocPath, StringComparison.OrdinalIgnoreCase))
            {
                if (DateTime.UtcNow < _docSaveSuppressionUntil)
                    return;

                // External change to the currently-open doc — reload source editor and preview.
                TryPostToUi(ReloadCurrentDocFromDisk, "DocsWatcher.ExternalChange");
                ScheduleDebouncedDocsRefresh();
                return;
            }

            // If the changed file was recently written by our own save (e.g. the user approved a
            // topic then navigated away — SaveDocSourceToDisk wrote the previously-selected file
            // while _currentDocPath had already advanced to the new topic), don't revoke its
            // approval status.  Without this guard the watcher would see an approved file change
            // and call SetNeedsReview, silently losing the just-granted approval.
            bool isOwnSave = DateTime.UtcNow < _docSaveSuppressionUntil
                && !string.IsNullOrEmpty(_lastSavedDocPath)
                && string.Equals(e.FullPath, _lastSavedDocPath, StringComparison.OrdinalIgnoreCase);

            if (!isOwnSave && _docStatusStore != null)
            {
                var fullPath = e.FullPath;
                if (_docStatusStore.GetStatus(fullPath) == DocApprovalStatus.Approved)
                {
                    _docStatusStore.SetNeedsReview(fullPath);
                    // Tree will refresh via ScheduleDebouncedDocsRefresh below
                }
            }

            ScheduleDebouncedDocsRefresh();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocsWatcher_Changed), ex, showDialog: false);
        }
    }

    private void DocsWatcher_Renamed(object sender, RenamedEventArgs e)
    {
        try
        {
            ScheduleDebouncedDocsRefresh();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocsWatcher_Renamed), ex, showDialog: false);
        }
    }

    private void ScheduleDebouncedDocsRefresh()
    {
        // Cancel any pending refresh
        _docsRefreshCts?.Cancel();
        _docsRefreshCts?.Dispose();

        var cts = new CancellationTokenSource();
        _docsRefreshCts = cts;

        // Schedule refresh after 150ms debounce
        Task.Delay(150, cts.Token).ContinueWith(async _ =>
        {
            if (!cts.Token.IsCancellationRequested)
            {
                await Dispatcher.InvokeAsync(() =>
                {
                    try
                    {
                        // Snapshot the live expansion state before rebuilding the tree so
                        // PopulateDocumentationTopics restores exactly what the user sees now
                        // rather than whatever was persisted at the end of the last session.
                        if (DocTopicsTreeView is not null && _docsPanelState is not null)
                        {
                            var liveNodes = CollectExpandedDocNodes(DocTopicsTreeView.Items);
                            _docsPanelState = _docsPanelState with { ExpandedNodes = liveNodes };
                        }
                        PopulateDocumentationTopics();
                    }
                    catch (Exception ex)
                    {
                        HandleUiCallbackException(nameof(ScheduleDebouncedDocsRefresh), ex, showDialog: false);
                    }
                }, System.Windows.Threading.DispatcherPriority.Normal, cts.Token);
            }
        }, TaskScheduler.Default);
    }

    private void HandleRestartRequestChanged()
    {
        if (_isClosing || _mainWindowClosingInProgress) return;

        var request = _restartCoordinatorStateStore.LoadRequest(_workspacePaths.ApplicationRoot);
        if (request is null || string.Equals(request.RequestId, _lastHandledRestartRequestId, StringComparison.Ordinal))
            return;

        _lastHandledRestartRequestId = request.RequestId;
        _restartPending = true;

        TryCompletePendingRestart("restart-request");
    }

    private void TryAdoptPendingRestartRequestForClose()
    {
        if (_restartPending)
            return;

        try
        {
            var request = _restartCoordinatorStateStore.LoadRequest(_workspacePaths.ApplicationRoot);
            if (request is null || string.Equals(request.RequestId, _lastHandledRestartRequestId, StringComparison.Ordinal))
                return;

            _lastHandledRestartRequestId = request.RequestId;
            _restartPending = true;
            SquadDashTrace.Write(
                "Shutdown",
                $"Adopted pending rebuild restart during Closing requestId={request.RequestId}.");
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Shutdown", $"Could not inspect restart request during Closing: {ex.Message}");
        }
    }

    private void ShowRestartingOverlay()
    {
        RestartingOverlay.Visibility = Visibility.Visible;
        var fade = new System.Windows.Media.Animation.DoubleAnimation(0, 0.9,
            new Duration(TimeSpan.FromMilliseconds(400)));
        RestartingOverlay.BeginAnimation(OpacityProperty, fade);
        // Force the dispatcher to flush a render frame so the overlay paints before
        // the synchronous shutdown work blocks the UI thread.
        Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Render);
    }

    private void HideRestartingOverlay()
    {
        RestartingOverlay.BeginAnimation(OpacityProperty, null);
        RestartingOverlay.Opacity = 0;
        RestartingOverlay.Visibility = Visibility.Collapsed;
    }

    private RestartDeferralReason GetRestartDeferralReason() =>
        RestartDeferralPolicy.GetDeferralReason(
            isPromptRunning:                 _isPromptRunning,
            isLoopRunning:                   IsLoopRunning,
            hasBackgroundWork:               _backgroundTaskPresenter.HasRestartBlockingBackgroundWork(),
            hasPendingDirectQuickReplyHandoff: HasPendingDirectQuickReplyAgentFollowUp(),
            isVoiceInputActiveOrDraining:    _pttState == PttState.Active || _pttDraining,
            hasDocRevisionInFlight:          MarkdownDocumentWindow.AnyRevisionInFlight,
            isClipboardEditorOpen:           _clipboardEditorOpen,
            promptAppearsStalled:            _pec.PromptAppearsDeadShown);

    private bool DeferPendingRestartIfBlocked(string reason)
    {
        var blocker = GetRestartDeferralReason();
        if (blocker == RestartDeferralReason.None)
            return false;

        HideRestartingOverlay();
        var message = RestartDeferralPolicy.BuildStatusMessage(blocker);
        SetInstallStatus(message);
        UpdateSessionState("Restart pending");
        SquadDashTrace.Write(
            "Shutdown",
            $"Restart deferred reason={reason} blocker={blocker} promptRunning={_isPromptRunning} loop={IsLoopRunning} background={_backgroundTaskPresenter.HasRestartBlockingBackgroundWork()} directQuickReply={HasPendingDirectQuickReplyAgentFollowUp()} ptt={_pttState}/{_pttDraining} docRevision={MarkdownDocumentWindow.AnyRevisionInFlight} clipboard={_clipboardEditorOpen}");
        return true;
    }

    private bool TryCompletePendingRestart(string reason, bool emergencySaveBeforeClose = false)
    {
        if (!_restartPending || _isClosing || _mainWindowClosingInProgress)
            return false;

        if (DeferPendingRestartIfBlocked(reason))
            return false;

        SquadDashTrace.Write("Shutdown", $"Completing pending restart reason={reason} emergencySave={emergencySaveBeforeClose}.");
        ShowRestartingOverlay();
        if (emergencySaveBeforeClose)
            EmergencySaveAfterDrainingBridgeEvents(reason);
        Close();
        return true;
    }

    /// <summary>
    /// Called when an AI doc-revision lock is released. If a restart was deferred waiting
    /// for all in-flight revisions to complete, and none remain, trigger the restart now.
    /// </summary>
    private void OnDocRevisionCompleted()
    {
        if (_isClosing || _mainWindowClosingInProgress) return;
        TryCompletePendingRestart("doc-revision-completed", emergencySaveBeforeClose: true);
    }

    /// <summary>
    /// Shows a <see cref="ClipboardImageEditorWindow"/> as a modeless window and tracks it
    /// so that <see cref="OnClipboardEditorClosed"/> is invoked once the last instance closes.
    /// </summary>
    private void OpenEditorModeless(ClipboardImageEditorWindow editor)
    {
        _clipboardEditorCount++;
        _clipboardEditorOpen = true;
        editor.Closed += (_, _) =>
        {
            _clipboardEditorCount--;
            if (_clipboardEditorCount == 0)
            {
                _clipboardEditorOpen = false;
                OnClipboardEditorClosed();
            }
        };
        editor.Show();
    }

    /// <summary>
    /// Called after a ClipboardImageEditorWindow closes. If a restart was deferred
    /// waiting for the editor to finish, trigger it now.
    /// </summary>
    private void OnClipboardEditorClosed()
    {
        if (_isClosing || _mainWindowClosingInProgress) return;

        // If the user tried to close the main window while the editor was open, honour
        // that deferred close now that the editor has been dismissed (Insert or Cancel).
        if (_pendingShutdown)
        {
            _pendingShutdown = false;
            if (_restartPending)
            {
                TryCompletePendingRestart(
                    "clipboard-editor-closed-pending-shutdown",
                    emergencySaveBeforeClose: true);
                return;
            }
            Close();
            return;
        }

        TryCompletePendingRestart("clipboard-editor-closed-restart", emergencySaveBeforeClose: true);
    }

    private void EmergencySaveAfterDrainingBridgeEvents(string reason)
    {
        if (!Dispatcher.CheckAccess())
        {
            Dispatcher.Invoke(() => EmergencySaveAfterDrainingBridgeEvents(reason));
            return;
        }

        DrainPendingBridgeEventsForShutdown(reason);
        _conversationManager.EmergencySave(reason);
    }

    private void DrainPendingBridgeEventsForShutdown(string reason)
    {
        var sw = Stopwatch.StartNew();
        var handled = 0;
        var deltaEvents = 0;
        var initialPending = PendingBridgeEventCount;

        while (handled < 10_000)
        {
            SquadSdkEvent? evt;
            lock (_bridgeEventQueueGate)
            {
                evt = _pendingBridgeEvents.Count > 0
                    ? _pendingBridgeEvents.Dequeue()
                    : null;
                if (evt is null)
                    _bridgeEventDispatchPending = false;
            }

            if (evt is null)
                break;

            if (IsHighFrequencyBridgeEvent(evt.Type))
                deltaEvents++;

            try
            {
                HandleEvent(evt);
            }
            catch (Exception ex)
            {
                HandleUiCallbackException($"DrainPendingBridgeEventsForShutdown:{evt.Type}", ex, showDialog: false);
            }

            handled++;
        }

        var remaining = PendingBridgeEventCount;
        if (initialPending > 0 || handled > 0 || remaining > 0)
        {
            SquadDashTrace.Write(
                TraceCategory.Shutdown,
                $"Bridge queue drained before emergency save reason={reason} initial={initialPending} handled={handled} deltaEvents={deltaEvents} remaining={remaining} elapsedMs={sw.ElapsedMilliseconds}");
        }
    }

    private int PendingBridgeEventCount
    {
        get
        {
            lock (_bridgeEventQueueGate)
                return _pendingBridgeEvents.Count;
        }
    }

    private void MainWindow_PreProcessInput(object sender, PreProcessInputEventArgs e)
    {
        var input = e.StagingItem.Input;
        if (input is KeyEventArgs keyEvent &&
            (keyEvent.RoutedEvent == Keyboard.PreviewKeyDownEvent ||
             keyEvent.RoutedEvent == Keyboard.KeyDownEvent))
        {
            _lastKeyboardInputAt = DateTimeOffset.Now;
            return;
        }

        if (input is TextCompositionEventArgs textEvent &&
            (textEvent.RoutedEvent == TextCompositionManager.PreviewTextInputEvent ||
             textEvent.RoutedEvent == TextCompositionManager.TextInputEvent) &&
            !string.IsNullOrEmpty(textEvent.Text))
        {
            _lastKeyboardInputAt = DateTimeOffset.Now;
        }
    }

    private void QueueBridgeEventForUi(SquadSdkEvent evt)
    {
        var shouldPost = false;
        lock (_bridgeEventQueueGate)
        {
            _pendingBridgeEvents.Enqueue(evt);
            if (!_bridgeEventDispatchPending)
            {
                _bridgeEventDispatchPending = true;
                shouldPost = true;
            }
        }

        if (shouldPost)
            PostToUiAsync(DrainBridgeEventQueueOnUi, "Bridge.EventReceived:batch");
    }

    private void DrainBridgeEventQueueOnUi()
    {
        var sw = Stopwatch.StartNew();
        var handled = 0;
        var deltaEvents = 0;

        while (handled < BridgeEventUiBatchLimit && sw.ElapsedMilliseconds < BridgeEventUiBatchMaxMs)
        {
            SquadSdkEvent? evt;
            lock (_bridgeEventQueueGate)
            {
                evt = _pendingBridgeEvents.Count > 0
                    ? _pendingBridgeEvents.Dequeue()
                    : null;
            }

            if (evt is null)
                break;

            if (IsHighFrequencyBridgeEvent(evt.Type))
                deltaEvents++;

            HandleEvent(evt);
            handled++;
        }

        var shouldPostMore = false;
        var remaining = 0;
        lock (_bridgeEventQueueGate)
        {
            remaining = _pendingBridgeEvents.Count;
            if (remaining > 0)
                shouldPostMore = true;
            else
                _bridgeEventDispatchPending = false;
        }

        if (handled >= BridgeEventUiBatchLimit || sw.ElapsedMilliseconds >= BridgeEventUiBatchMaxMs)
        {
            SquadDashTrace.Write(
                TraceCategory.UI,
                $"Bridge.EventReceived batch handled={handled} deltaEvents={deltaEvents} elapsedMs={sw.ElapsedMilliseconds} remaining={remaining}");
        }

        if (shouldPostMore)
            PostToUiAsync(DrainBridgeEventQueueOnUi, "Bridge.EventReceived:batch");
    }

    private static bool IsHighFrequencyBridgeEvent(string? eventType) =>
        eventType is "thinking_delta" or "response_delta" or "subagent_message_delta" or "subagent_thinking_delta";

    private void TryPostToUi(Action action, string source)
    {
        if (_isClosing || _mainWindowClosingInProgress || Dispatcher.HasShutdownStarted || Dispatcher.HasShutdownFinished)
            return;

        try
        {
            if (Dispatcher.CheckAccess())
            {
                try
                {
                    action();
                }
                catch (Exception ex)
                {
                    HandleUiCallbackException(source, ex);
                }
                return;
            }

            PostToUiAsync(action, source);
        }
        catch (ObjectDisposedException ex)
        {
            SquadDashTrace.Write("Shutdown", $"{source} ignored after disposal: {ex.Message}");
        }
        catch (InvalidOperationException ex)
        {
            SquadDashTrace.Write("Shutdown", $"{source} ignored during dispatcher shutdown: {ex.Message}");
        }
    }

    private void PostToUiAsync(Action action, string source)
    {
        if (_isClosing || _mainWindowClosingInProgress || Dispatcher.HasShutdownStarted || Dispatcher.HasShutdownFinished)
            return;

        try
        {
            var sequence = _postedUiActionTracker.RegisterPostedAction();
            var queuedAt = Stopwatch.GetTimestamp();
            Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
            {
                var queueMs = ElapsedMillisecondsSince(queuedAt);
                var sw = Stopwatch.StartNew();
                try
                {
                    try
                    {
                        action();
                    }
                    catch (Exception ex)
                    {
                        HandleUiCallbackException(source, ex);
                    }
                }
                finally
                {
                    sw.Stop();
                    _postedUiActionTracker.MarkCompleted(sequence);
                    if (queueMs >= UiDispatchQueueLagTraceMs || sw.ElapsedMilliseconds >= UiDispatchWorkTraceMs)
                    {
                        SquadDashTrace.Write(
                            TraceCategory.UI,
                            $"UI_DISPATCH source={source} queueMs={queueMs} workMs={sw.ElapsedMilliseconds} pending={_postedUiActionTracker.PendingCount}");
                    }
                }
            }));
        }
        catch (ObjectDisposedException ex)
        {
            SquadDashTrace.Write("Shutdown", $"{source} ignored after disposal: {ex.Message}");
        }
        catch (InvalidOperationException ex)
        {
            SquadDashTrace.Write("Shutdown", $"{source} ignored during dispatcher shutdown: {ex.Message}");
        }
    }

    private static long ElapsedMillisecondsSince(long stopwatchTimestamp) =>
        (long)((Stopwatch.GetTimestamp() - stopwatchTimestamp) * 1000.0 / Stopwatch.Frequency);

    private static int CountFiles(string folderPath)
    {
        try
        {
            return Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories).Count();
        }
        catch
        {
            return 0;
        }
    }

    private void RefreshAgentCards()
    {
        if (_leadAgent is null)
        {
            _leadAgent = new AgentStatusCard(
                AgentThreadRegistry.HumanizeAgentName("Squad"),
                "S",
                "Coordinator",
                "Ready",
                string.Empty,
                string.Empty,
                LeadAgentDefaultAccentHex,
                accentStorageKey: "Squad",
                isLeadAgent: true);
            _agents.Add(_leadAgent);
        }

        ApplyAgentAccent(_leadAgent, ResolveAgentAccentHex(_leadAgent, isLeadAgent: true), persist: false);
        ApplyAgentImage(_leadAgent, ResolveAgentImagePath(_leadAgent), persist: false);

        while (_agents.Count > 1)
            _agents.RemoveAt(_agents.Count - 1);

        if (_currentWorkspace is null)
        {
            SquadDashTrace.Write("AgentCards", "RefreshAgentCards: no workspace, showing lead only.");
            _leadAgent.DetailText = string.Empty;
            UpdateAgentCardVisibility();
            ScheduleAgentPanelLayoutRefresh();
            return;
        }

        var members = _teamRosterLoader.Load(_currentWorkspace.FolderPath);
        SquadDashTrace.Write("AgentCards", $"RefreshAgentCards: workspace={_currentWorkspace.FolderPath} members={members.Count}");

        foreach (var member in members)
        {
            var card = new AgentStatusCard(
                AgentThreadRegistry.HumanizeAgentName(member.Name),
                GetAgentInitial(member.Name),
                member.Role,
                member.Status,
                string.Empty,
                string.Empty,
                ObservedAgentDefaultAccentHex,
                accentStorageKey: member.AccentKey,
                charterPath: member.CharterPath,
                historyPath: member.HistoryPath,
                folderPath: member.FolderPath,
                isCompact: member.IsUtilityAgent && !AgentRosterVisibilityPolicy.IsScribeAgent(member.Name, member.FolderPath),
                isUtilityAgent: member.IsUtilityAgent);

            ApplyAgentAccent(card, ResolveAgentAccentHex(card, isLeadAgent: false), persist: false);
            ApplyAgentImage(card, ResolveAgentImagePath(card), persist: false);
            _agents.Add(card);
        }

        UpdateAvatarSizes();
        SquadDashTrace.Write("AgentCards", $"RefreshAgentCards: total cards={_agents.Count}");
        UpdateAgentCardVisibility();
        SyncAgentCardsWithThreads();
        UpdateAgentCardImageVisibility(ActualHeight);

        // Walk up the visual tree logging heights at each level
        SquadDashTrace.Write("AgentCards",
            $"Status panels: ActiveCount={_activeAgentCards.Count} InactiveCount={_inactiveAgentCards.Count} " +
            $"ActiveH={ActiveAgentItemsControl.ActualHeight:F0} ActiveViewport={ActiveAgentsScrollViewer.ActualHeight:F0} " +
            $"InactiveH={InactiveAgentItemsControl.ActualHeight:F0} InactiveViewport={InactiveAgentsScrollViewer.ActualHeight:F0} RootH={StatusAgentPanelsGrid.ActualHeight:F0}");
        System.Windows.FrameworkElement? node = StatusAgentPanelsGrid;
        for (var depth = 0; depth < 6 && node is not null; depth++)
        {
            var parent = System.Windows.Media.VisualTreeHelper.GetParent(node) as System.Windows.FrameworkElement;
            if (parent is null) break;
            SquadDashTrace.Write("AgentCards",
                $"  Ancestor[{depth}] {parent.GetType().Name} '{parent.Name}': " +
                $"W={parent.ActualWidth:F0} H={parent.ActualHeight:F0} DesiredH={parent.DesiredSize.Height:F0} Vis={parent.Visibility}");
            node = parent;
        }
    }

    private void UpdateLeadAgent(string status, string bubble, string detail)
    {
        if (_leadAgent is null)
            return;

        _leadAgent.StatusText = status;
        _leadAgent.BubbleText = bubble;
        _leadAgent.DetailText = detail;

        var kind = status switch
        {
            "Streaming" => SpinnerActivityKind.Writing,
            "Tooling"   => SpinnerActivityKind.Reading,
            _           => SpinnerActivityKind.Thinking,
        };
        _leadAgent.FireActivityPulse(kind);
    }

    private void UpdateAgentCardImageVisibility(double windowHeight)
    {
        AgentStatusCard.ImagesVisible = windowHeight >= 650;
    }

    private void UpdateAgentCardVisibility()
    {
        foreach (var agent in _agents)
        {
            agent.CardVisibility = AgentRosterVisibilityPolicy.ShouldShow(agent)
                ? Visibility.Visible
                : Visibility.Collapsed;
        }

        SyncAgentCardBuckets();
    }

    private void SyncAgentCardBuckets()
    {
        var visibleCards = _agents
            .Where(agent => agent.CardVisibility == Visibility.Visible)
            .ToArray();
        foreach (var card in visibleCards)
        {
            card.IsInActivePanel = card.IsLeadAgent || card.Threads.Any(_backgroundTaskPresenter.IsThreadCurrentRunForDisplay);
        }

        // Active panel: stable insertion order — never reorder cards already present.
        // Only remove cards that left the active set and append cards that just entered.
        var shouldBeActive = visibleCards.Where(static c => c.IsInActivePanel).ToHashSet();
        for (var i = _activeAgentCards.Count - 1; i >= 0; i--)
        {
            if (!shouldBeActive.Contains(_activeAgentCards[i]))
                _activeAgentCards.RemoveAt(i);
        }
        var alreadyActive = _activeAgentCards.ToHashSet();
        foreach (var card in visibleCards.Where(c => c.IsInActivePanel && !alreadyActive.Contains(c))
                                         .OrderBy(GetAgentCardBucketSortKey))
            _activeAgentCards.Add(card);

        // Inactive panel: full rebuild (sorted by last activity).
        _inactiveAgentCards.Clear();
        foreach (var card in visibleCards.Where(static card => !card.IsInActivePanel).OrderBy(GetAgentCardBucketSortKey))
            _inactiveAgentCards.Add(card);

        var activeNames = string.Join(", ", _activeAgentCards.Select(static card => card.Name));
        SquadDashTrace.Write("AgentCards",
            $"SyncAgentCardBuckets: visible={visibleCards.Length} active={_activeAgentCards.Count} " +
            $"inactive={_inactiveAgentCards.Count} threads={visibleCards.Sum(static card => card.Threads.Count)} " +
            $"activeCards={activeNames}");

        var currentActive = _activeAgentCards.ToHashSet();
        foreach (var added in currentActive.Except(_prevActiveAgentCards).Where(c => !c.IsLeadAgent))
            _selectionController.OnAgentEnteredActivePanel(added);
        foreach (var removed in _prevActiveAgentCards.Except(currentActive).Where(c => !c.IsLeadAgent))
            _selectionController.OnAgentLeftActivePanel(removed);
        _prevActiveAgentCards = currentActive;
    }

    private static (int Group, long SortTicks, string Name) GetAgentCardBucketSortKey(AgentStatusCard card) =>
        AgentCardSorting.ComputeSortKey(
            card.IsLeadAgent,
            card.IsDynamicAgent,
            card.Threads
                .Where(static t => !t.IsPlaceholderThread)
                .Select(static t => AgentThreadRegistry.GetThreadLastActivityAt(t).UtcTicks)
                .ToArray(),
            card.Name,
            isScribe: string.Equals(card.AccentStorageKey, "scribe", StringComparison.OrdinalIgnoreCase),
            isRetired: string.Equals(card.RegistryStatus, "retired", StringComparison.OrdinalIgnoreCase));

    private void UpdateAgentPanelWidths()
    {
        var availableWidth = StatusAgentPanelsGrid.ActualWidth;
        if (availableWidth <= 0)
            return;

        var maxActiveWidth = Math.Max(360, Math.Floor(availableWidth * 0.8));
        ActiveAgentsPanelBorder.MaxWidth = maxActiveWidth;
        ActiveAgentsColumnDefinition.Width = GridLength.Auto;
    }

    private void UpdateRosterHeightCap()
    {
        StatusAgentPanelsGrid.MaxHeight = ActualHeight < 900
            ? Math.Floor(ActualHeight / 3)
            : double.PositiveInfinity;
    }

    private void ScheduleAgentPanelLayoutRefresh()
    {
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, new Action(() =>
        {
            UpdateAgentPanelWidths();
            ActiveAgentsPanelBorder.InvalidateMeasure();
            ActiveAgentsPanelBorder.InvalidateArrange();
            ActiveAgentsScrollViewer.InvalidateMeasure();
            ActiveAgentsScrollViewer.InvalidateArrange();
            ActiveAgentItemsControl.InvalidateMeasure();
            ActiveAgentItemsControl.InvalidateArrange();
            InactiveAgentsPanelBorder.InvalidateMeasure();
            InactiveAgentsPanelBorder.InvalidateArrange();
            InactiveAgentsScrollViewer.InvalidateMeasure();
            InactiveAgentsScrollViewer.InvalidateArrange();
            InactiveAgentItemsControl.InvalidateMeasure();
            InactiveAgentItemsControl.InvalidateArrange();
            StatusAgentPanelsGrid.InvalidateMeasure();
            StatusAgentPanelsGrid.InvalidateArrange();
            StatusAgentPanelsGrid.UpdateLayout();
            ActiveAgentsScrollViewer.ScrollToLeftEnd();
            ActiveAgentsScrollViewer.ScrollToTop();
            InactiveAgentsScrollViewer.ScrollToLeftEnd();
            InactiveAgentsScrollViewer.ScrollToTop();
            TryNudgeAgentLaneLayout();
        }));
    }

    private void TryNudgeAgentLaneLayout()
    {
        TryNudgeAgentLaneLayout(
            _activeAgentCards,
            ActiveAgentItemsControl,
            "active");
        TryNudgeAgentLaneLayout(
            _inactiveAgentCards,
            InactiveAgentItemsControl,
            "inactive");
    }

    private void TryNudgeAgentLaneLayout(
        ObservableCollection<AgentStatusCard> targetCollection,
        ItemsControl itemsControl,
        string laneName)
    {
        if (IsAgentLaneNudgeScheduled(laneName) || targetCollection.Count == 0 || itemsControl.ActualHeight > 0)
            return;

        SetAgentLaneNudgeScheduled(laneName, true);
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Render, new Action(() =>
        {
            try
            {
                if (itemsControl.ActualHeight > 0)
                {
                    SetAgentLaneNudgeScheduled(laneName, false);
                    return;
                }

                SquadDashTrace.Write("AgentCards", $"Nudging {laneName} lane because ActualHeight is still zero.");
                var placeholder = CreateAgentLanePlaceholderCard(laneName);
                targetCollection.Add(placeholder);
                itemsControl.InvalidateMeasure();
                itemsControl.InvalidateArrange();
                StatusAgentPanelsGrid.InvalidateMeasure();
                StatusAgentPanelsGrid.InvalidateArrange();
                StatusAgentPanelsGrid.UpdateLayout();

                Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Render, new Action(() =>
                {
                    try
                    {
                        targetCollection.Remove(placeholder);
                        itemsControl.InvalidateMeasure();
                        itemsControl.InvalidateArrange();
                        StatusAgentPanelsGrid.InvalidateMeasure();
                        StatusAgentPanelsGrid.InvalidateArrange();
                        StatusAgentPanelsGrid.UpdateLayout();
                    }
                    finally
                    {
                        SetAgentLaneNudgeScheduled(laneName, false);
                    }
                }));
            }
            catch
            {
                SetAgentLaneNudgeScheduled(laneName, false);
            }
        }));
    }

    private bool IsAgentLaneNudgeScheduled(string laneName) =>
        string.Equals(laneName, "active", StringComparison.OrdinalIgnoreCase)
            ? _activeAgentLaneNudgeScheduled
            : _inactiveAgentLaneNudgeScheduled;

    private void SetAgentLaneNudgeScheduled(string laneName, bool value)
    {
        if (string.Equals(laneName, "active", StringComparison.OrdinalIgnoreCase))
            _activeAgentLaneNudgeScheduled = value;
        else
            _inactiveAgentLaneNudgeScheduled = value;
    }

    private static AgentStatusCard CreateAgentLanePlaceholderCard(string laneName)
    {
        var placeholder = new AgentStatusCard(
            "Placeholder",
            "P",
            string.Empty,
            string.Empty,
            string.Empty,
            string.Empty,
            DynamicAgentDefaultAccentHex,
            accentStorageKey: "placeholder:" + laneName,
            isDynamicAgent: true);
        placeholder.CardVisibility = Visibility.Hidden;
        return placeholder;
    }

    private void UpdateSessionState(string state)
    {
        _currentSessionState = state;
        var activeToolName = _pec is null ? null : _pec.ActiveToolName;
        SessionStateTextBlock.Text = string.IsNullOrWhiteSpace(activeToolName)
            ? state
            : $"{state} | Tool: {activeToolName}";
    }

    private Brush ResolveBrushResource(string key, Brush fallback)
    {
        return TryFindResource(key) as Brush ?? fallback;
    }

    private static Brush ThemeBrush(string key) =>
        (Brush?)Application.Current.Resources[key] ?? Brushes.Gray;

    private void ApplyTheme(string themeName)
    {
        var themeUri = string.Equals(themeName, "Light", StringComparison.OrdinalIgnoreCase)
            ? new Uri("Themes/Light.xaml", UriKind.Relative)
            : new Uri("Themes/Dark.xaml", UriKind.Relative);

        var mergedDicts = Application.Current.Resources.MergedDictionaries;
        var existing = mergedDicts.FirstOrDefault(d =>
            d.Source?.OriginalString?.Contains("/Themes/") == true);
        if (existing is not null)
            mergedDicts.Remove(existing);

        mergedDicts.Add(new ResourceDictionary { Source = themeUri });
        _themeDict = mergedDicts.Last();
        CaptureTintBaseline();
        ApplyTintStop(_activeTintStop, notify: false);
        // Refresh tint menu swatches — baseline just changed due to theme swap.
        for (int i = 0; i < TintMenuItem.Items.Count; i++)
        {
            if (TintMenuItem.Items[i] is MenuItem mi)
                ApplyTintSwatchToItem(mi, i);
        }
        var previousThemeName = _activeThemeName;
        _activeThemeName = themeName;
        if (!string.Equals(previousThemeName, themeName, StringComparison.OrdinalIgnoreCase))
            InvalidateTranscriptSnapshots("theme-changed");

        // Re-render adorners so they pick up the new theme's brush tokens.
        _searchAdorner?.InvalidateHighlights();
        _scrollbarAdorner?.InvalidateVisual();

        var isDark = string.Equals(themeName, "Dark", StringComparison.OrdinalIgnoreCase);
        AgentStatusCard.SetTheme(isDark);

        // Re-render doc-source find highlights so they use the new theme's colours.
        DocSourceFind_RenderHighlights();
        foreach (var agent in _agents)
            agent.NotifyThemeChanged();

        foreach (var thread in _agentThreadRegistry.ThreadOrder)
            SyncThreadChip(thread);

        MarkdownDocumentWindow.RefreshAllOpenWindows();

        RefreshDocumentationViewer();

        // Rebuild queue tabs after the current dispatcher frame so WPF's deferred
        // InvalidateProperty notifications (triggered by the MergedDictionaries swap)
        // have fully propagated before new elements establish their SetResourceReference
        // bindings — otherwise the active tab can render with stale theme brushes.
        Dispatcher.InvokeAsync(SyncQueuePanel, System.Windows.Threading.DispatcherPriority.Render);

        // Re-paint session-gap stripes so baked solid colors match the new theme.
        RefreshSessionGapStripes();

        UpdateThemeMenuState();

    }

    private void CaptureTintBaseline()
    {
        _tintBaseline = new Dictionary<string, Color>(StringComparer.Ordinal);
        var themeDict = _themeDict;
        if (themeDict is null) return;
        foreach (var key in TintKeys.All)
        {
            if (themeDict[key] is SolidColorBrush brush)
                _tintBaseline[key] = brush.Color;
        }
        foreach (var key in TintKeys.ActiveAccent)
        {
            if (themeDict[key] is SolidColorBrush brush)
                _tintBaseline[key] = brush.Color;
        }
    }

    private void ApplyTintStop(int stop, bool notify = true)
    {
        if (_tintBaseline is null) return;
        var resources = Application.Current.Resources;
        // Subtract 35° to compensate for the ~34° baseline hue of the theme surfaces,
        // so stop 1 (Amber) lands near orange rather than yellow-green.
        // Stop 0 is always unmodified (natural theme colours).
        const double baselineHueOffset = 35.0;
        const double naturalTintShift = -30.0; // shift Natural stop toward red
        var hueDelta = stop * (360.0 / 8) - baselineHueOffset; // adjusted steps
        foreach (var (key, baseColor) in _tintBaseline)
        {
            bool isAccent = TintKeys.ActiveAccent.Contains(key);
            double delta;
            bool shouldRotate;
            if (stop == 0)
            {
                // Natural: accent keys unchanged; non-accent keys get a small baseline shift toward red
                delta = naturalTintShift;
                shouldRotate = !isAccent; // only rotate non-accent keys
            }
            else
            {
                delta = isAccent ? hueDelta + _activeAccentHueOffset : hueDelta;
                shouldRotate = true;
            }
            var tinted = shouldRotate ? RotateHue(baseColor, delta) : baseColor;
            var brush = new SolidColorBrush(tinted);
            brush.Freeze();
            resources[key] = brush;
        }
        if (notify)
            NotifyTintChanged();
    }

    private static Color RotateHue(Color color, double hueDeltaDegrees)
    {
        ColorUtilities.RgbToHsl(color.R, color.G, color.B, out double h, out double s, out double l);
        h = ((h + hueDeltaDegrees / 360.0) % 1.0 + 1.0) % 1.0;
        ColorUtilities.HslToRgb(h, s, l, out byte r, out byte g, out byte b);
        return Color.FromArgb(color.A, r, g, b);
    }

    private void NotifyTintChanged()
    {
        _searchAdorner?.InvalidateHighlights();
        _scrollbarAdorner?.InvalidateVisual();
        DocSourceFind_RenderHighlights();
        RefreshSessionGapStripes();
        foreach (var thread in _agentThreadRegistry.ThreadOrder)
            SyncThreadChip(thread);
        Dispatcher.InvokeAsync(SyncQueuePanel, DispatcherPriority.Render);

        // Regenerate HTML in all markdown preview surfaces — the CSS embeds
        // color values read from Application.Current.Resources, so a re-render
        // is required whenever those resources change due to tinting.
        MarkdownDocumentWindow.RefreshAllOpenWindows();
        RefreshDocumentationViewer();
        RefreshDocMarkdownViewerFromSource();
    }

    private void ApplyWorkspaceTint(string? folderPath)
    {
        if (string.IsNullOrEmpty(folderPath)) return;
        var key = Path.GetFullPath(folderPath)
            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
        _activeTintStop = _settingsSnapshot.TintStopByWorkspace.TryGetValue(key, out var stop) ? stop : 0;
        _activeAccentHueOffset = _settingsSnapshot.AccentHueOffsetByWorkspace.TryGetValue(key, out var accentOffset) ? accentOffset : 0;
        ApplyTintStop(_activeTintStop);
        UpdateTintMenuState();
    }

    private void SetWorkspaceTintStop(int stop)
    {
        if (_currentWorkspace is null) return;
        _activeTintStop = stop;
        _settingsSnapshot = _settingsStore.SaveWorkspaceTintStop(_currentWorkspace.FolderPath, stop);
        var defaultAccentOffset = DefaultAccentOffsetByTintStop[stop];
        _activeAccentHueOffset = defaultAccentOffset;
        _settingsSnapshot = _settingsStore.SaveWorkspaceAccentHueOffset(_currentWorkspace.FolderPath, defaultAccentOffset);
        ApplyTintStop(stop);
        UpdateTintMenuState();
    }

    private void SetWorkspaceAccentHueOffset(int offsetDegrees)
    {
        if (_currentWorkspace is null) return;
        _activeAccentHueOffset = offsetDegrees;
        _settingsSnapshot = _settingsStore.SaveWorkspaceAccentHueOffset(_currentWorkspace.FolderPath, offsetDegrees);
        ApplyTintStop(_activeTintStop);
    }

    private void UpdateTintMenuState()
    {
        for (int i = 0; i < TintMenuItem.Items.Count; i++)
        {
            if (TintMenuItem.Items[i] is MenuItem item)
                item.IsChecked = i == _activeTintStop;
        }
    }

    private Color ComputeTintSwatch(int stop)
    {
        var fallback = Color.FromRgb(128, 128, 128);
        if (_tintBaseline is null || !_tintBaseline.TryGetValue("PanelSurface", out var baseColor))
            return fallback;
        // Subtract the baseline hue (~35°) so stop 0 renders as neutral and the
        // 8 stops spread evenly around the hue wheel from a true-red anchor.
        const double baselineHueOffset = 35.0;
        var hueDelta = stop * 45.0 - baselineHueOffset;
        return RotateHue(baseColor, hueDelta);
    }

    private void ApplyTintSwatchToItem(MenuItem item, int stop)
    {
        var swatch = ComputeTintSwatch(stop);
        ColorUtilities.RgbToHsl(swatch.R, swatch.G, swatch.B, out double h, out _, out double l);
        // PanelSurface is near-neutral (~3% saturation), so every stop lands at the same
        // luminance and would yield identical black/white text. Instead, boost saturation
        // and flip luminance so each stop gets richly-colored, hue-matched text.
        var textL = l > 0.5 ? 0.25 : 0.82;
        ColorUtilities.HslToRgb(h, 0.60, textL, out byte r, out byte g, out byte b);
        var foregroundBrush = new SolidColorBrush(Color.FromRgb(r, g, b));
        item.Background  = new SolidColorBrush(swatch);
        item.Foreground  = foregroundBrush;
        item.BorderBrush = foregroundBrush;
    }

    // Accent hue offsets offered in the right-click menu, in degrees.
    // Span 300° total (±150°) in 30° steps, centered on the natural complement.
    private static readonly int[] AccentHueOffsets = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];

    // Default accent hue offset pre-selected when the user picks each tint stop.
    // Order matches the tint label list: Natural=0, Amber=1, Mint=2, Sage=3, Aqua=4, Sky=5, Plum=6, Blush=7.
    private static readonly int[] DefaultAccentOffsetByTintStop = [30, -60, -90, -60, -120, 150, 120, 90];

    private Color ComputeAccentSwatch(int offsetDegrees)
    {
        var fallback = Color.FromRgb(80, 120, 200);
        if (_tintBaseline is null || !_tintBaseline.TryGetValue("ActivePanelSurface", out var baseColor))
            return fallback;
        const double baselineHueOffset = 35.0;
        var hueDelta = _activeTintStop * (360.0 / 8) - baselineHueOffset;
        var totalDelta = hueDelta + offsetDegrees;
        return (_activeTintStop == 0 && offsetDegrees == 0) ? baseColor : RotateHue(baseColor, totalDelta);
    }

    private void ApplyAccentSwatchToItem(MenuItem item, int offsetDegrees)
    {
        var swatch = ComputeAccentSwatch(offsetDegrees);
        ColorUtilities.RgbToHsl(swatch.R, swatch.G, swatch.B, out double h, out _, out double l);
        var textL = l > 0.5 ? 0.25 : 0.82;
        ColorUtilities.HslToRgb(h, 0.60, textL, out byte r, out byte g, out byte b);
        var foregroundBrush = new SolidColorBrush(Color.FromRgb(r, g, b));
        item.Background  = new SolidColorBrush(swatch);
        item.Foreground  = foregroundBrush;
        item.BorderBrush = foregroundBrush;
    }

    private ContextMenu CreateAccentHueContextMenu()
    {
        var menu = MakeMenu();
        foreach (var offset in AccentHueOffsets)
        {
            var label = offset == 0 ? "0°" : (offset > 0 ? $"+{offset}°" : $"{offset}°");
            var item = new MenuItem
            {
                Header           = label,
                IsCheckable      = true,
                IsChecked        = _activeAccentHueOffset == offset,
                Style            = (Style)FindResource("TintMenuItemStyle"),
                StaysOpenOnClick = false,
            };
            ApplyAccentSwatchToItem(item, offset);
            var captured = offset;
            item.Click += (_, _) =>
            {
                try { SetWorkspaceAccentHueOffset(captured); }
                catch (Exception ex) { HandleUiCallbackException("AccentHue_Click", ex); }
            };
            menu.Items.Add(item);
        }
        return menu;
    }

    private void ActiveAgentsPanelBorder_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
    {
        try
        {
            var menu = CreateAccentHueContextMenu();
            menu.PlacementTarget = ActiveAgentsPanelBorder;
            menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ActiveAgentsPanelBorder_MouseRightButtonUp), ex); }
    }

    private void EnsureTintMenuItems()
    {
        if (TintMenuItem.Items.Count > 0)
        {
            UpdateTintMenuState();
            for (int i = 0; i < TintMenuItem.Items.Count; i++)
            {
                if (TintMenuItem.Items[i] is MenuItem existing)
                    ApplyTintSwatchToItem(existing, i);
            }
            return;
        }
        string[] labels = ["Natural", "Amber", "Mint", "Sage", "Aqua", "Sky", "Plum", "Blush"];
        for (int i = 0; i < labels.Length; i++)
        {
            var stop = i;
            var item = new MenuItem
            {
                Header    = labels[i],
                IsCheckable = true,
                IsChecked   = _activeTintStop == stop,
                Style       = (Style)FindResource("TintMenuItemStyle")
            };
            ApplyTintSwatchToItem(item, stop);
            item.Click += (_, _) =>
            {
                try { SetWorkspaceTintStop(stop); }
                catch (Exception ex) { HandleUiCallbackException("TintStop_Click", ex); }
            };
            TintMenuItem.Items.Add(item);
        }
    }

    private MenuItem BuildTintSubmenu()
    {
        // Kept for compatibility but no longer used — tint lives in TintMenuItem (View menu).
        var sub = new MenuItem
        {
            Header = "_Tint",
            Style  = (Style)FindResource("ThemedMenuItemStyle")
        };
        return sub;
    }

    private void RefreshDocumentationViewer()
    {
        // Re-render the current doc topic (if any) so HTML is regenerated with new theme CSS
        if (DocMarkdownViewer is null || DocsPanel is null || DocsPanel.Visibility != Visibility.Visible)
            return;

        var currentItem = DocTopicsTreeView?.SelectedItem as TreeViewItem;
        if (currentItem is null)
            return;

        var filePath = currentItem.Tag as string;
        if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
            return;

        try
        {
            var markdown = File.ReadAllText(filePath);
            var title = currentItem.Header?.ToString() ?? "Documentation";
            var html = MarkdownHtmlBuilder.Build(markdown, title,
                filePath: filePath, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);
        }
        catch
        {
            // Silently ignore errors during theme refresh
        }
    }

    // ── Screenshot-paste feature ──────────────────────────────────────────────────

    /// <summary>
    /// Sanitizes the filename component of a doc-relative image path so it contains
    /// only characters that are legal on Windows. Invalid chars (parens, colons, etc.)
    /// are replaced with hyphens. The directory portion (e.g. "images/") is preserved.
    /// </summary>
    private static string SanitizeDocImagePath(string imagePath)
    {
        var dir  = imagePath.Replace('\\', '/').Contains('/')
            ? imagePath.Replace('\\', '/')[..(imagePath.Replace('\\', '/').LastIndexOf('/') + 1)]
            : "";
        var file = Path.GetFileNameWithoutExtension(imagePath);
        var ext  = Path.GetExtension(imagePath);
        var invalid = Path.GetInvalidFileNameChars().ToHashSet();
        var sanitized = string.Concat(file.Select(c => invalid.Contains(c) ? '-' : c));
        // Collapse any run of hyphens down to a single hyphen
        while (sanitized.Contains("--"))
            sanitized = sanitized.Replace("--", "-");
        sanitized = sanitized.Trim('-');
        return dir + sanitized + ext;
    }

    /// <summary>
    /// If <paramref name="sanitizedPath"/> differs from <paramref name="originalPath"/>,
    /// rewrites all occurrences of the original path in the markdown source file with
    /// the sanitized one so image links remain valid.
    /// </summary>
    private static void UpdateDocImagePathInMarkdown(string docPath, string originalPath, string sanitizedPath)
    {
        if (originalPath == sanitizedPath) return;
        var text = File.ReadAllText(docPath);
        var updated = text.Replace(originalPath.Replace('\\', '/'), sanitizedPath.Replace('\\', '/'));
        if (updated != text)
            File.WriteAllText(docPath, updated);
    }

    /// <summary>
    /// Called by <see cref="DocViewerScriptingBridge"/> when the user right-clicks a
    /// 📸 placeholder blockquote in the docs viewer.  Shows a context menu with the
    /// "Use screenshot on clipboard" action and a "Capture new screenshot" action.
    /// </summary>
    public void ShowDocScreenshotContextMenu(string imagePath)
    {
        var menu = MakeMenu();

        var pasteItem = MakeItem("Use screenshot on clipboard");
        pasteItem.IsEnabled = Clipboard.ContainsImage();
        pasteItem.Click += (s, e) => PasteScreenshotToDoc(imagePath);
        menu.Items.Add(pasteItem);

        var captureItem = MakeItem("Capture new screenshot for this placeholder");
        captureItem.Click += (s, e) => CaptureScreenshotForDocPlaceholder(imagePath);
        menu.Items.Add(captureItem);

        menu.PlacementTarget = DocMarkdownViewer;
        menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
        menu.IsOpen = true;
    }

    private void PasteScreenshotToDoc(string imagePath)
    {
        if (!Clipboard.ContainsImage())
        {
            MessageBox.Show("No image found on clipboard.", "Screenshot",
                MessageBoxButton.OK, MessageBoxImage.Information);
            return;
        }

        if (string.IsNullOrEmpty(_currentDocPath)) return;

        if (string.IsNullOrWhiteSpace(imagePath))
        {
            MessageBox.Show(
                "Could not determine the image file path for this placeholder.\n\n" +
                "Make sure the markdown has an ![alt](path/to/image.png) line immediately before the 📸 blockquote.",
                "Screenshot", MessageBoxButton.OK, MessageBoxImage.Warning);
            return;
        }

        // Ensure the target has a file extension — protect against accidentally writing to a directory.
        if (string.IsNullOrEmpty(Path.GetExtension(imagePath)))
        {
            MessageBox.Show(
                $"The image path \"{imagePath}\" has no file extension. Expected a path like images/screenshot.png.",
                "Screenshot", MessageBoxButton.OK, MessageBoxImage.Warning);
            return;
        }

        // Sanitize the filename component — strip characters that are illegal on Windows
        // (parens, colons, etc.) and update the markdown if the path changed.
        var sanitizedPath = SanitizeDocImagePath(imagePath);
        UpdateDocImagePathInMarkdown(_currentDocPath, imagePath, sanitizedPath);
        imagePath = sanitizedPath;

        var docDir = Path.GetDirectoryName(_currentDocPath)!;
        var fullImagePath = Path.Combine(docDir, imagePath.Replace('/', '\\'));
        Directory.CreateDirectory(Path.GetDirectoryName(fullImagePath)!);

        var clipImg = Clipboard.GetImage()!;
        var capturedDocPath = _currentDocPath;
        var capturedFullImagePath = fullImagePath;
        var capturedImagePath = imagePath;
        var editor = new ClipboardImageEditorWindow(this, clipImg);
        editor.ImageAccepted += image =>
        {
            var encoder = new PngBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(image));
            using (var stream = File.OpenWrite(capturedFullImagePath))
                encoder.Save(stream);

            // Remove the 📸 placeholder line that corresponds to this image.
            // Match using forward-slash paths (as written in markdown).
            var lines = File.ReadAllLines(capturedDocPath).ToList();
            var fwdSlashPath = capturedImagePath.Replace('\\', '/');
            for (int i = lines.Count - 1; i >= 0; i--)
            {
                if ((lines[i].Contains("📸") || lines[i].Contains("Screenshot needed")) &&
                    i > 0 && lines[i - 1].Replace('\\', '/').Contains(fwdSlashPath))
                {
                    lines.RemoveAt(i);
                    // Remove the blank line immediately after the placeholder, if any
                    if (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
                        lines.RemoveAt(i);
                    break;
                }
            }
            File.WriteAllLines(capturedDocPath, lines);

            // Reload the current doc in the viewer
            var markdown = File.ReadAllText(capturedDocPath);
            var title = (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Header?.ToString() ?? "Documentation";
            var html = MarkdownHtmlBuilder.Build(markdown, title, filePath: capturedDocPath, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);
        };
        OpenEditorModeless(editor);
    }

    /// <summary>
    /// Called by <see cref="DocViewerScriptingBridge.ShowImageMenu"/> when the user right-clicks
    /// an existing image in the docs viewer. Shows a "Replace with image on clipboard" option.
    /// </summary>
    public void ShowImageContextMenu(string imagePath)
    {
        var menu = MakeMenu();

        var replaceItem = MakeItem("Replace with image on clipboard");
        replaceItem.IsEnabled = Clipboard.ContainsImage();
        replaceItem.Click += (s, e) => ReplaceScreenshotInDoc(imagePath);
        menu.Items.Add(replaceItem);

        var captureItem = MakeItem("Replace with captured image");
        captureItem.Click += (_, _) => CaptureScreenshotForDocPlaceholder(imagePath);
        menu.Items.Add(captureItem);

        // "Refresh screenshot" — only enabled when a definition with DocImagePath for this image exists.
        var resolvedPath = ResolveDocImagePath(imagePath);
        var refreshItem = MakeItem("Refresh screenshot");
        refreshItem.IsEnabled = false;
        if (!string.IsNullOrEmpty(resolvedPath) && !string.IsNullOrEmpty(_currentDocPath))
        {
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            var def = _cachedDefinitionRegistry?.TryGetByDocImagePath(resolvedPath, screenshotsDir);
            if (def is not null)
            {
                refreshItem.IsEnabled = true;
                refreshItem.Click += (_, _) => _ = RefreshDocImageAsync(def.Name);
            }
        }
        menu.Items.Add(refreshItem);

        var showInFolderItem = MakeItem("Show image in folder");
        showInFolderItem.IsEnabled = !string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath);
        showInFolderItem.Click += (_, _) => ShowFileInExplorer(resolvedPath!);
        menu.Items.Add(showInFolderItem);

        menu.PlacementTarget = DocMarkdownViewer;
        menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
        menu.IsOpen = true;
    }

    private string? ResolveDocImagePath(string imagePath)
    {
        if (string.IsNullOrEmpty(_currentDocPath) || string.IsNullOrWhiteSpace(imagePath))
            return null;
        var docDir = Path.GetDirectoryName(_currentDocPath);
        if (string.IsNullOrEmpty(docDir)) return null;
        return Path.Combine(docDir, imagePath.Replace('/', '\\'));
    }

    private static void ShowFileInExplorer(string fullPath)
    {
        try
        {
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
            {
                FileName = "explorer.exe",
                Arguments = $"/select,\"{fullPath}\"",
                UseShellExecute = true
            });
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("UI", $"ShowFileInExplorer failed: {ex.Message}");
        }
    }

    private void ReplaceScreenshotInDoc(string imagePath)
    {
        if (!Clipboard.ContainsImage())
        {
            MessageBox.Show("No image found on clipboard.", "Replace Screenshot",
                MessageBoxButton.OK, MessageBoxImage.Information);
            return;
        }

        if (string.IsNullOrEmpty(_currentDocPath)) return;
        if (string.IsNullOrWhiteSpace(imagePath) || string.IsNullOrEmpty(Path.GetExtension(imagePath)))
        {
            MessageBox.Show($"Cannot determine image file path from \"{imagePath}\".",
                "Replace Screenshot", MessageBoxButton.OK, MessageBoxImage.Warning);
            return;
        }

        // Sanitize filename — strip invalid chars and update markdown if path changed.
        var sanitizedPath = SanitizeDocImagePath(imagePath);
        UpdateDocImagePathInMarkdown(_currentDocPath, imagePath, sanitizedPath);
        imagePath = sanitizedPath;

        var docDir = Path.GetDirectoryName(_currentDocPath)!;
        var fullImagePath = Path.Combine(docDir, imagePath.Replace('/', '\\'));
        Directory.CreateDirectory(Path.GetDirectoryName(fullImagePath)!);

        var clipImg = Clipboard.GetImage()!;
        var capturedDocPath = _currentDocPath;
        var capturedFullImagePath = fullImagePath;
        var capturedImagePath = imagePath;
        var capturedThemeName = _activeThemeName;
        var editor = new ClipboardImageEditorWindow(this, clipImg);
        editor.ImageAccepted += image =>
        {
            var encoder = new PngBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(image));
            // Overwrite existing file (OpenWrite truncates)
            using (var stream = File.OpenWrite(capturedFullImagePath))
            {
                stream.SetLength(0);
                encoder.Save(stream);
            }

            // Remove the 📸 placeholder line that immediately follows the image line.
            var lines = File.ReadAllLines(capturedDocPath).ToList();
            var fwdSlashPath = capturedImagePath.Replace('\\', '/');
            for (int i = 0; i < lines.Count - 1; i++)
            {
                if (lines[i].Replace('\\', '/').Contains(fwdSlashPath))
                {
                    int nextI = i + 1;
                    if (nextI < lines.Count &&
                        (lines[nextI].Contains("📸") || lines[nextI].Contains("Screenshot needed")))
                    {
                        lines.RemoveAt(nextI);
                        if (nextI < lines.Count && string.IsNullOrWhiteSpace(lines[nextI]))
                            lines.RemoveAt(nextI);
                        break;
                    }
                }
            }
            File.WriteAllLines(capturedDocPath, lines);

            // Reload the doc so the viewer shows the updated image
            var markdown = File.ReadAllText(capturedDocPath);
            var title = (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Header?.ToString() ?? "Documentation";
            var html = MarkdownHtmlBuilder.Build(markdown, title, filePath: capturedDocPath, isDark: AgentStatusCard.IsDarkTheme);
            DocMarkdownViewer.NavigateToString(html);

            // Sync the definition's Theme to the current active theme so "Refresh screenshot"
            // will recapture in the same theme as the image the user just pasted in.
            _ = SyncDefinitionThemeForDocImageAsync(capturedFullImagePath, capturedThemeName);
        };
        OpenEditorModeless(editor);
    }

    /// <summary>
    /// When the user replaces a doc screenshot via clipboard paste, updates the matching
    /// <see cref="Screenshots.ScreenshotDefinition"/> to use <paramref name="themeName"/>
    /// so that a subsequent "Refresh screenshot" captures in the same theme.
    /// </summary>
    private async Task SyncDefinitionThemeForDocImageAsync(string fullDocImagePath, string themeName)
    {
        try
        {
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            var registry = await Screenshots.ScreenshotDefinitionRegistry.LoadAsync(screenshotsDir)
                                                                          .ConfigureAwait(true);
            var def = registry.TryGetByDocImagePath(fullDocImagePath, screenshotsDir);
            if (def is null) return;

            registry.AddOrUpdate(def with { Theme = themeName });
            await registry.SaveAsync().ConfigureAwait(true);
            _cachedDefinitionRegistry = registry;
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Screenshot", $"SyncDefinitionTheme failed: {ex.Message}");
        }
    }

    /// <summary>
    /// Called by <see cref="DocViewerScriptingBridge.Navigate"/> when JS link-click
    /// handling routes a navigation request through the COM bridge instead of the
    /// browser's default navigation (which fires <see cref="DocMarkdownViewer_Navigating"/>).
    /// </summary>
    internal void InvokeDocNavigation(string href)
    {
        if (string.IsNullOrEmpty(href)) return;

        if (href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            href.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
            href.StartsWith("chrome://", StringComparison.OrdinalIgnoreCase) ||
            href.StartsWith("edge://", StringComparison.OrdinalIgnoreCase))
        {
            try { _squadCliAdapter.OpenExternalLink(href); }
            catch { }
            return;
        }

        try
        {
            string? resolvedPath = null;
            if (Uri.TryCreate(href, UriKind.Absolute, out var uri) && uri.IsFile)
            {
                resolvedPath = uri.LocalPath;
            }
            else if (!string.IsNullOrEmpty(_currentDocPath))
            {
                var currentDir = Path.GetDirectoryName(_currentDocPath);
                if (!string.IsNullOrEmpty(currentDir))
                {
                    var relativePart = Uri.UnescapeDataString(href);
                    resolvedPath = Path.GetFullPath(Path.Combine(currentDir, relativePart));
                }
            }

            if (resolvedPath != null &&
                resolvedPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) &&
                File.Exists(resolvedPath))
            {
                NavigateToDocByPath(resolvedPath);
            }
        }
        catch { }
    }


    private void UpdateThemeMenuState()
    {
        if (ThemeAutoMenuItem is not null)
            ThemeAutoMenuItem.IsChecked = string.Equals(_themePreference, "Auto", StringComparison.OrdinalIgnoreCase);
        if (ThemeLightMenuItem is not null)
            ThemeLightMenuItem.IsChecked = string.Equals(_themePreference, "Light", StringComparison.OrdinalIgnoreCase);
        if (ThemeDarkMenuItem is not null)
            ThemeDarkMenuItem.IsChecked = string.Equals(_themePreference, "Dark", StringComparison.OrdinalIgnoreCase);
    }

    private void SetThemePreference(string preference)
    {
        _themePreference = preference;
        _settingsSnapshot = _settingsStore.SaveTheme(preference);
        var resolved = string.Equals(preference, "Auto", StringComparison.OrdinalIgnoreCase)
            ? GetWindowsTheme()
            : preference;
        ApplyTheme(resolved);
    }

    private void ThemeAutoMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { SetThemePreference("Auto"); }
        catch (Exception ex) { HandleUiCallbackException(nameof(ThemeAutoMenuItem_Click), ex); }
    }

    private void ThemeLightMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { SetThemePreference("Light"); }
        catch (Exception ex) { HandleUiCallbackException(nameof(ThemeLightMenuItem_Click), ex); }
    }

    private void ThemeDarkMenuItem_Click(object sender, RoutedEventArgs e)
    {
        try { SetThemePreference("Dark"); }
        catch (Exception ex) { HandleUiCallbackException(nameof(ThemeDarkMenuItem_Click), ex); }
    }

    private static string GetWindowsTheme()
    {
        try
        {
            using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(
                @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
            var value = key?.GetValue("AppsUseLightTheme");
            return value is int i && i == 0 ? "Dark" : "Light";
        }
        catch
        {
            return "Light"; // safe fallback
        }
    }

    private void OnUserPreferenceChanged(object sender, Microsoft.Win32.UserPreferenceChangedEventArgs e)
    {
        if (e.Category != Microsoft.Win32.UserPreferenceCategory.General) return;
        if (!string.Equals(_themePreference, "Auto", StringComparison.OrdinalIgnoreCase)) return;
        var resolved = GetWindowsTheme();
        Dispatcher.InvokeAsync(() => ApplyTheme(resolved));
    }

    private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e)
    {
        if (e.Mode != PowerModes.Resume)
            return;

        Dispatcher.InvokeAsync(RecoverInputAfterSystemResume, DispatcherPriority.Input);
    }

    private void RecoverInputAfterSystemResume()
    {
        try
        {
            var previousPttState = _pttState;
            StopPttCtrlPollTimer();

            if (_pttState == PttState.Active || _pttDraining)
                _ = StopPushToTalkAsync(send: false);
            else
                ResetPttState();

            Keyboard.ClearFocus();
            if (PromptTextBox is { IsEnabled: true, IsVisible: true })
                PromptTextBox.Focus();

            SquadDashTrace.Write(
                TraceCategory.UI,
                $"SYSTEM_RESUME_INPUT_RECOVERY previousPttState={previousPttState} physicalModifiers={NativeMethods.GetPhysicalModifierKeys()} promptFocused={PromptTextBox?.IsKeyboardFocusWithin == true}");
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(RecoverInputAfterSystemResume), ex, showDialog: false);
        }
    }

    private static string CollapseWhitespace(string? text) =>
        TranscriptTextUtilities.CollapseWhitespace(text);

    private static string FormatJson(JsonElement element) =>
        TranscriptTextUtilities.FormatJson(element);

    private void OpenSidebarEntry(SidebarEntry entry)
    {
        if (_currentWorkspace is null)
        {
            ShowTextWindow(entry.Title, "No workspace is open.");
            return;
        }

        if (entry.Kind == SidebarEntryKind.Message)
        {
            ShowTextWindow(entry.Title, entry.Subtitle);
            return;
        }

        if (!entry.Exists)
        {
            ShowTextWindow(entry.Title, $"Expected path not found:{Environment.NewLine}{entry.Path}");
            return;
        }

        if (entry.Kind == SidebarEntryKind.Folder)
        {
            _squadCliAdapter.OpenFolderInExplorer(entry.Path, $"Open {entry.Title}");
            return;
        }

        if (Path.GetExtension(entry.Path).Equals(".md", StringComparison.OrdinalIgnoreCase))
        {
            OpenMarkdownFile(entry.Path, entry.Title);
            return;
        }

        ShowTextWindow(entry.Title, BuildSidebarEntryContent(entry));
    }

    private string BuildSidebarEntryContent(SidebarEntry entry)
    {
        if (_currentWorkspace is null)
            return "No workspace is open.";

        if (entry.Kind == SidebarEntryKind.Message)
            return entry.Subtitle;

        if (!entry.Exists)
            return $"Expected path not found:\n{entry.Path}";

        try
        {
            return entry.Kind switch
            {
                SidebarEntryKind.File => File.ReadAllText(entry.Path),
                SidebarEntryKind.Folder => BuildFolderContent(entry.Path),
                _ => entry.Subtitle
            };
        }
        catch (Exception ex)
        {
            return $"Unable to read {entry.Path}\n\n{ex.Message}";
        }
    }

    private static string BuildFolderContent(string folderPath)
    {
        if (!Directory.Exists(folderPath))
            return $"Folder not found:\n{folderPath}";

        var builder = new StringBuilder();
        builder.AppendLine(folderPath);

        var files = Directory
            .EnumerateFiles(folderPath, "*", SearchOption.AllDirectories)
            .OrderBy(path => path)
            .ToArray();

        if (files.Length == 0)
        {
            builder.AppendLine();
            builder.AppendLine("(Folder is empty)");
            return builder.ToString().TrimEnd();
        }

        foreach (var file in files)
        {
            builder.AppendLine();
            builder.AppendLine(new string('=', 72));
            builder.AppendLine(file);
            builder.AppendLine(new string('=', 72));
            builder.AppendLine(File.ReadAllText(file));
        }

        return builder.ToString().TrimEnd();
    }

    private static string BuildInstallDiagnostics(SquadCommandResult result, string activeDirectory)
    {
        var builder = new StringBuilder();
        builder.AppendLine($"Active directory: {activeDirectory}");
        builder.AppendLine();
        builder.AppendLine(result.ToDisplayText());
        return builder.ToString().TrimEnd();
    }

    private bool CanShowOwnedWindow()
    {
        return !_isClosing &&
               !Dispatcher.HasShutdownStarted &&
               !Dispatcher.HasShutdownFinished &&
               IsLoaded &&
               PresentationSource.FromVisual(this) is not null;
    }

    private void OpenMarkdownFile(string? filePath, string title, bool showSource = false)
    {
        if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
            return;

        OpenMarkdownFiles([new MarkdownDocumentSpec(Path.GetFileNameWithoutExtension(filePath), filePath)], title, showSource);
    }

    private void OpenMarkdownFiles(IReadOnlyList<MarkdownDocumentSpec> files, string title, bool showSource = false)
    {
        if (files.Count == 0)
            return;

        try
        {
            MarkdownDocumentWindow.Show(
                CanShowOwnedWindow() ? this : null,
                title,
                files,
                showSource,
                BuildMarkdownCaptureContext());
        }
        catch (Exception ex)
        {
            MessageBox.Show(
                $"Unable to open the markdown file.{Environment.NewLine}{Environment.NewLine}{ex.Message}",
                title,
                MessageBoxButton.OK,
                MessageBoxImage.Error);
        }
    }

    private MarkdownDocumentCaptureContext BuildMarkdownCaptureContext() =>
        new MarkdownDocumentCaptureContext(
            FixtureRegistry: _fixtureLoaderRegistry,
            ActionRegistry: _uiActionReplayRegistry,
            ScreenshotsDirectory: _workspacePaths.ScreenshotsDirectory,
            ThemeName: _activeThemeName,
            SpeechRegion: _settingsSnapshot.SpeechRegion ?? string.Empty) {
            AddToChatCallback = text => Dispatcher.Invoke(() => {
                if (!string.IsNullOrWhiteSpace(text))
                    AttachContextFollowUp(NotesStore.DeriveTitle(text), text.TrimEnd());
            }),
            AddToNotesCallback = text => Dispatcher.Invoke(() => AddNoteFromText(text)),
            ReviseWithAiCallback = (instructions, sel, doc, cwd, ct) =>
                _bridge.RunDocRevisionAsync(instructions, sel, doc, cwd, ct),
            StartPttCallback = tb => {
                _pttTargetTextBox = tb;
                _sessionCaretIndex = tb.SelectionStart;
                _sessionSelectionLength = tb.SelectionLength;
                _voiceStartedWithSendEnabled = false;
                _pttState = PttState.Active;
                _ = StartPushToTalkAsync();
            },
            StopPttCallback = () => _ = StopPushToTalkAsync(send: false),
            CleanupPrompt = _settingsSnapshot.CleanupPrompt,
        };

    private void ShowTextWindow(string title, string content)
    {
        var window = new Window
        {
            Title = title,
            Width = 900,
            Height = 700,
            MinWidth = 640,
            MinHeight = 480
        };
        window.SetResourceReference(BackgroundProperty, "AppSurface");

        if (CanShowOwnedWindow())
            window.Owner = this;

        var textBox = new TextBox
        {
            Text = content,
            IsReadOnly = true,
            AcceptsReturn = true,
            AcceptsTab = true,
            TextWrapping = TextWrapping.Wrap,
            VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
            HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
            FontFamily = new FontFamily("Consolas"),
            Margin = new Thickness(12),
            BorderThickness = new Thickness(0)
        };
        textBox.SetResourceReference(TextBox.BackgroundProperty, "AppSurface");
        textBox.SetResourceReference(TextBox.ForegroundProperty, "LabelText");
        window.Content = textBox;

        WindowPlacementHelper.CenterOnOwnerAndEnsureOnScreen(window, this);
        window.Show();
    }

    private HireAgentWindow.HireAgentSubmission? ShowHireAgentWindow()
    {
        if (_currentWorkspace is null || !CanShowOwnedWindow())
            return null;

        var existingNames = _agents
            .Where(card => !card.IsLeadAgent && !card.IsDynamicAgent)
            .Select(card => card.Name)
            .Where(name => !string.IsNullOrWhiteSpace(name))
            .ToArray();
        var universes = HireAgentWindow.LoadCatalog(
            _currentWorkspace.FolderPath,
            _settingsSnapshot,
            _workspacePaths.AgentImageAssetsDirectory,
            existingNames);
        var activeUniverse = HireAgentWindow.ResolveActiveUniverseName(_currentWorkspace.FolderPath);
        var submission = HireAgentWindow.Show(
            this,
            universes,
            activeUniverse,
            _workspacePaths.RoleIconAssetsDirectory,
            (agentKey, imagePath) =>
            {
                _settingsSnapshot = _settingsStore.SaveAgentImagePath(
                    _currentWorkspace.FolderPath,
                    agentKey,
                    imagePath);
            });
        if (submission is null)
            return null;

        if (!string.IsNullOrWhiteSpace(submission.ImagePath))
        {
            foreach (var candidate in HireAgentWindow.BuildImageKeyCandidates(submission.AgentName))
            {
                _settingsSnapshot = _settingsStore.SaveAgentImagePath(
                    _currentWorkspace.FolderPath,
                    candidate,
                    submission.ImagePath);
            }
        }

        return submission;
    }

    private void RefreshStatusPresentation()
    {
        var now = DateTimeOffset.Now;
        _backgroundTaskPresenter.RecoverStaleBackgroundThreads(now, "status-refresh");

        foreach (var card in _agents.Where(candidate => !candidate.IsLeadAgent))
            SyncCardThreads(card, now);

        RefreshTasksStatusWindow(now);
        RefreshBridgeStallWarning(now);
    }

    private void RefreshBridgeStallWarning(DateTimeOffset now)
    {
        var stallActive = _isPromptRunning
            && (_pec.PromptNoActivityWarningShown || _pec.PromptStallWarningShown)
            && _pec.LastPromptActivityAt is not null;

        if (stallActive)
        {
            var idleFor = now - _pec.LastPromptActivityAt!.Value;
            var msg = $"⚠ Bridge not responding ({idleFor.TotalSeconds:0}s)";

            _bridgeStallShowing = true;

            if (QueueTabBorder.Visibility == Visibility.Visible)
            {
                QueueStatusLabel.Text = msg;
                QueueStatusLabel.Background = Brushes.DarkRed;
                QueueStatusLabel.Foreground = Brushes.White;

                // After 5 min silence show the "Cancel stalled agent" recovery button.
                ForceAbortStalledAgentButton.Visibility = _pec.PromptAppearsDeadShown
                    ? Visibility.Visible
                    : Visibility.Collapsed;
            }

            // When the prompt is assumed dead and a build-restart is waiting, let it through.
            // RestartDeferralPolicy already ignores PromptRunning at this point, so calling
            // TryCompletePendingRestart will actually close and relaunch the app.
            if (_pec.PromptAppearsDeadShown && _restartPending)
            {
                SquadDashTrace.Write("Shutdown", "Prompt assumed dead — auto-completing deferred restart.");
                TryCompletePendingRestart("prompt-assumed-dead", emergencySaveBeforeClose: true);
            }
        }
        else if (_bridgeStallShowing)
        {
            _bridgeStallShowing = false;

            if (QueueTabBorder.Visibility == Visibility.Visible)
            {
                ForceAbortStalledAgentButton.Visibility = Visibility.Collapsed;
                SetQueuePaused(_queueManuallyPaused);
            }
        }
    }

    private void ForceAbortStalledAgent_Click(object sender, RoutedEventArgs e)
    {
        SquadDashTrace.Write("UI", "ForceAbortStalledAgent: user requested force-cancel of stalled prompt after bridge silence.");
        ForceCoordinatorAbortCleanup();
        // If a build-restart was waiting behind the now-cleared PromptRunning gate, complete it.
        TryCompletePendingRestart("stalled-agent-cancelled", emergencySaveBeforeClose: true);
    }

    private void QueueStatusCopyDiagnostics_Click(object sender, RoutedEventArgs e)
    {
        var now = DateTimeOffset.Now;
        var idleSeconds = _pec.LastPromptActivityAt is not null
            ? (now - _pec.LastPromptActivityAt.Value).TotalSeconds
            : 0.0;

        var sb = new System.Text.StringBuilder();

        // === Header ===
        sb.AppendLine("=== SquadDash Diagnostics ===");
        sb.AppendLine($"Timestamp:       {now:yyyy-MM-dd HH:mm:ss}");
        sb.AppendLine($"Bridge Status:   Not responding for {idleSeconds:0} seconds");
        sb.AppendLine($"Last activity:   {_pec.LastPromptActivityName ?? "(unknown)"}");
        sb.AppendLine($"Active tool:     {_pec.ActiveToolName ?? "(none)"}");
        sb.AppendLine($"Workspace:       {_currentWorkspace?.FolderPath ?? "(none)"}");
        sb.AppendLine($"Model:           {_settingsSnapshot.LastUsedModel ?? "(unknown)"}");
        sb.AppendLine($"App version:     {AppVersion.Full}");

        // === Active Agents ===
        sb.AppendLine();
        sb.AppendLine("=== Active Agents ===");
        var activeAgents = _activeAgentCards.ToList();
        if (activeAgents.Count == 0)
        {
            sb.AppendLine("No active agents");
        }
        else
        {
            foreach (var agent in activeAgents)
                sb.AppendLine($"  {agent.Name} | {agent.RoleText} | {agent.StatusText}");
        }

        // === Recent Transcript ===
        sb.AppendLine();
        var transcriptThread = _selectedTranscriptThread ?? CoordinatorThread;
        var transcriptThreadLabel = ReferenceEquals(transcriptThread, CoordinatorThread)
            ? "Coordinator"
            : (transcriptThread.AgentDisplayName ?? transcriptThread.AgentName ?? transcriptThread.ThreadId ?? "Agent");
        sb.AppendLine($"=== Recent Transcript (last 20 entries — {transcriptThreadLabel}) ===");
        try
        {
            var turns = transcriptThread.SavedTurns
                .Where(t => !t.IsSessionBoundary)
                .TakeLast(20)
                .ToList();
            if (turns.Count == 0)
            {
                sb.AppendLine("No transcript entries");
            }
            else
            {
                foreach (var turn in turns)
                {
                    var prompt = turn.Prompt?.Trim() ?? string.Empty;
                    if (prompt.Length > 100) prompt = prompt[..100] + "…";
                    if (!string.IsNullOrEmpty(prompt))
                        sb.AppendLine($"  User:      {prompt}");

                    var response = turn.ResponseText?.Trim() ?? string.Empty;
                    if (response.Length > 100) response = response[..100] + "…";
                    if (!string.IsNullOrEmpty(response))
                        sb.AppendLine($"  Assistant: {response}");
                }
            }
        }
        catch (Exception ex)
        {
            sb.AppendLine($"  (error reading transcript: {ex.Message})");
        }

        // === Trace Log ===
        sb.AppendLine();
        sb.AppendLine("=== Trace Log (last 50 lines) ===");
        try
        {
            var logPath = SquadDashTrace.CurrentLogPath;
            if (File.Exists(logPath))
            {
                var lines = File.ReadLines(logPath).TakeLast(50).ToList();
                foreach (var line in lines)
                    sb.AppendLine(line);
            }
            else
            {
                sb.AppendLine("  (trace log not found)");
            }
        }
        catch (Exception ex)
        {
            sb.AppendLine($"  (error reading trace log: {ex.Message})");
        }

        Clipboard.SetText(sb.ToString());

        var original = QueueStatusLabel.Text;
        QueueStatusLabel.Text = original + " (copied)";
        var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
        timer.Tick += (_, _) =>
        {
            timer.Stop();
            if (QueueStatusLabel.Text == original + " (copied)")
                QueueStatusLabel.Text = original;
        };
        timer.Start();
    }

    private void RestoreUtilityWindowVisibility()
    {
        if (_settingsSnapshot.TasksWindowOpen)
        {
            SquadDashTrace.Write("Startup", "Restoring tasks window from previous session.");
            ShowTasksStatusWindow();
        }

        if (_settingsSnapshot.TraceWindowOpen)
        {
            SquadDashTrace.Write("Startup", "Restoring live trace window from previous session.");
            ShowTraceWindow();
        }

        RestoreDocsPanelState();
    }

    private void RestoreDocsPanelState()
    {
        _docsPanelState = _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);

        // Restore per-workspace fullscreen transcript state.
        if (_docsPanelState.FullScreenTranscript == true)
        {
            // Initialize pre-fullscreen fallback from the current window placement so that
            // exiting fullscreen this session has a valid size/position to return to.
            _preFullScreenWindowState = WindowState;
            _preFullScreenBounds = WindowState == WindowState.Maximized
                ? RestoreBounds
                : new Rect(Left, Top, Width, Height);
            // If bounds are degenerate (e.g. not yet laid out), fall back to maximized on exit.
            if (_preFullScreenBounds.Width <= 0 || _preFullScreenBounds.Height <= 0)
                _preFullScreenWindowState = WindowState.Maximized;

            _transcriptFullScreenEnabled = true;
            ApplyViewMode();
        }

        // Restore tasks panel visibility.
        if (_docsPanelState.TasksPanelVisible == true)
        {
            _tasksPanelVisible = true;
            SyncTasksPanel();
            if (ViewTasksMenuItem is not null)
                ViewTasksMenuItem.IsChecked = true;
        }

        // Restore approval panel visibility.
        if (_docsPanelState.ApprovalPanelVisible == true)
        {
            _approvalPanelVisible = true;
            SyncApprovalPanel();
            if (ViewCommitApprovalsMenuItem is not null)
                ViewCommitApprovalsMenuItem.IsChecked = true;
        }

        // Restore notes panel visibility.
        if (_docsPanelState.NotesPanelVisible == true)
        {
            _notesPanelVisible = true;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = true;
        }

        // Restore maintenance panel visibility.
        if (_docsPanelState.MaintenancePanelVisible == true)
        {
            _maintenancePanelVisible = true;
            SyncMaintenancePanel();
            if (ViewMaintenanceMenuItem is not null)
                ViewMaintenanceMenuItem.IsChecked = true;
        }

        // Start idle detection so maintenance can fire automatically.
        if (_currentWorkspace?.FolderPath is string wPath)
            InitIdleDetection(wPath);

        // Restore loop panel visibility. Default is visible (true); only hide if
        // the user explicitly closed it (LoopPanelVisible == false).
        if (_docsPanelState.LoopPanelVisible == false)
        {
            _loopPanelVisible = false;
            SyncLoopPanel();
            if (ViewLoopPanelMenuItem is not null)
                ViewLoopPanelMenuItem.IsChecked = false;
        }

        // Restore prompt panel position (above/below transcript).
        if (_docsPanelState.PromptPanelOnTop == true)
            SetPromptPanelOnTop(true);

        // Restore queue paused state.
        if (_docsPanelState.QueuePaused == true)
            SetQueuePaused(true);

        // Restore selected loop file and populate the file picker.
        _selectedLoopMdPath = _docsPanelState.SelectedLoopFile;
        PopulateLoopFilePicker();
        RefreshLoopOptionsPanel();

        // Restore draft follow-up attachments if any were persisted.
        var restoredList = new List<FollowUpAttachment>();
        if (_docsPanelState.DraftFollowUpsJson is { Length: > 0 } followUpsJson)
        {
            try
            {
                var items = System.Text.Json.JsonSerializer.Deserialize<List<FollowUpAttachmentDto>>(followUpsJson);
                if (items != null)
                    restoredList.AddRange(items.Select(d => new FollowUpAttachment(
                        d.CommitSha ?? "",
                        d.Description ?? "",
                        d.OriginalPrompt,
                        d.TranscriptQuote,
                        d.ContentBlock,
                        d.ImagePath,
                        d.ImageSubmittedAt is not null && DateTime.TryParse(d.ImageSubmittedAt, out var dt1) ? dt1 : null)));
            }
            catch { /* corrupt data — ignore */ }
        }
        else if (_docsPanelState.DraftFollowUpCommitSha is { Length: > 0 } sha &&
                 _docsPanelState.DraftFollowUpDescription is { Length: > 0 } desc)
        {
            // Legacy single-item fallback
            restoredList.Add(new FollowUpAttachment(sha, desc, _docsPanelState.DraftFollowUpOriginalPrompt));
        }
        if (restoredList.Count > 0)
        {
            _followUpAttachments[""] = restoredList;
            UpdateFollowUpStrip();
        }

        // Restore panel filter text boxes (always, regardless of whether the docs panel is open).
        if (_docsPanelState.ApprovalsPanelFilter is { Length: > 0 } approvalsFilter)
            Dispatcher.BeginInvoke(() => {
                if (ApprovalFilterBox is not null) ApprovalFilterBox.Text = approvalsFilter;
            }, System.Windows.Threading.DispatcherPriority.Loaded);

        if (_docsPanelState.TasksPanelFilter is { Length: > 0 } tasksFilter)
            Dispatcher.BeginInvoke(() => {
                if (TasksFilterBox is not null) TasksFilterBox.Text = tasksFilter;
            }, System.Windows.Threading.DispatcherPriority.Loaded);

        if (_docsPanelState.NotesPanelFilter is { Length: > 0 } notesFilter)
            Dispatcher.BeginInvoke(() => {
                if (NotesFilterBox is not null) NotesFilterBox.Text = notesFilter;
            }, System.Windows.Threading.DispatcherPriority.Loaded);

        // Open: true = explicitly opened by user. null (absent) or false = closed (default for new installs).
        if (_docsPanelState.Open != true)
            return;

        SquadDashTrace.Write("Startup", "Restoring documentation panel from previous session.");
        // Open the panel without persisting — this is a startup restore, not a user action.
        SetDocumentationMode(true, persistChange: false);

        // Defer proportional width restore until after WM_SIZE is processed (Input=5) and the
        // subsequent layout pass (Render=7) so that MainGrid.ActualWidth reflects the restored
        // window size.  Background (4) is lower priority than both, guaranteeing correct values.
        Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, () =>
        {
            ApplyDocsPanelProportionalWidths();
        });
    }

    private void ApplyDocsPanelProportionalWidths()
    {
        var docState = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        // Restore docs panel width (proportional takes priority over absolute)
        if (DocsPanelColumn is not null && MainGrid is not null && MainGrid.ActualWidth > 0)
        {
            double width;
            if (docState.PanelWidthFraction is { } fraction && fraction > 0 && fraction < 1)
                width = MainGrid.ActualWidth * fraction;
            else
                width = docState.PanelWidth ?? 600;
            DocsPanelColumn.Width = new GridLength(Math.Max(200, width));
        }

        // Restore View Source panel state
        if (docState.SourceOpen == true)
        {
            var sourceSize = docState.SourceWidth ?? 300;
            _docSourceLayoutTopBottom = docState.SourceLayoutTopBottom == true;
            UpdateDocSourceLayoutButtons();
            ShowDocSourcePanel();
            // Override the default calculated size with the saved size
            if (_docSourceLayoutTopBottom)
            {
                if (DocsSourceRow is not null)
                    DocsSourceRow.Height = new GridLength(Math.Max(100, sourceSize), GridUnitType.Pixel);
            }
            else
            {
                if (DocsSourceColumn is not null)
                    ApplyDocSourceSideBySide(Math.Max(100, sourceSize));
            }
        }
        else
        {
            // Restore layout button state even when source is closed
            _docSourceLayoutTopBottom = docState.SourceLayoutTopBottom == true;
            UpdateDocSourceLayoutButtons();
        }
    }

    // ── Docs TreeView drag-and-drop ───────────────────────────────────────────────

    private void DocTopicsTreeView_MouseRightButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        try
        {
            var item = FindAncestorTreeViewItem(e.OriginalSource as DependencyObject);
            if (item is null) return;

            var filePath = item.Tag as string;
            if (string.IsNullOrEmpty(filePath)) return;

            item.IsSelected = true;

            var renameItem = MakeItem("Rename\u2026");
            renameItem.Click += (_, _) => EnterInPlaceRename(item, filePath);

            var copyLinkItem = MakeItem("Copy markdown link to this topic");
            copyLinkItem.Click += (_, _) => DocTopicsTreeView_CopyMarkdownLink(item);

            var menu = MakeMenu();

            // Add to chat first
            var followUpItem = MakeItem("Add to chat");
            followUpItem.Click += (_, _) => AttachTopicFollowUp(item, filePath);
            menu.Items.Add(followUpItem);

            // Add to Notes second
            var addToNotesItem = MakeItem("Add to Notes");
            addToNotesItem.Click += (_, _) => {
                var topicTitle = GetTopicItemTitle(item) ?? System.IO.Path.GetFileNameWithoutExtension(filePath);
                string content = "";
                try { content = System.IO.File.ReadAllText(filePath); } catch { }
                AddNoteFromTextWithTitle($"Topic - {topicTitle}", content);
            };
            menu.Items.Add(addToNotesItem);

            menu.Items.Add(MakeSep());
            menu.Items.Add(renameItem);
            menu.Items.Add(MakeSep());
            menu.Items.Add(copyLinkItem);

            var showInFolderItem = MakeItem("Show in Folder");
            showInFolderItem.Click += (_, _) =>
            {
                if (File.Exists(filePath))
                    System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\"");
                else
                {
                    var dir = Path.GetDirectoryName(filePath);
                    if (dir is not null && Directory.Exists(dir))
                        System.Diagnostics.Process.Start("explorer.exe", dir);
                }
            };
            menu.Items.Add(MakeSep());
            menu.Items.Add(showInFolderItem);

            if (_docStatusStore?.GetStatus(filePath) == DocApprovalStatus.Approved)
            {
                menu.Items.Add(MakeSep());
                var resetApprovalItem = MakeItem("Reset approval");
                resetApprovalItem.Click += (_, _) =>
                {
                    _docStatusStore.SetNeedsReview(filePath);
                    PopulateDocumentationTopics();
                    UpdateApproveDocButton(filePath);
                };
                menu.Items.Add(resetApprovalItem);
            }
            menu.PlacementTarget = DocTopicsTreeView;
            menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
            menu.IsOpen = true;
            e.Handled = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocTopicsTreeView_MouseRightButtonUp), ex);
        }
    }


    private void UpdateApproveDocButton(string? filePath)
    {
        if (ApproveDocButton is null) return;

        if (string.IsNullOrEmpty(filePath) || _docStatusStore is null)
        {
            ApproveDocButton.Visibility = Visibility.Collapsed;
            UpdateDocPublishedToggle(null);
            return;
        }

        ApproveDocButton.Visibility = Visibility.Visible;
        var status = _docStatusStore.GetStatus(filePath);
        if (status == DocApprovalStatus.Approved)
        {
            ApproveDocButton.Content = "✓ Approved";
            ApproveDocButton.Opacity = 0.55;
        }
        else
        {
            ApproveDocButton.Content = "✓ Approve";
            ApproveDocButton.Opacity = 1.0;
        }

        UpdateDocPublishedToggle(filePath);
    }

    private void UpdateDocPublishedToggle(string? filePath)
    {
        if (DocPublishedToggle is null) return;

        if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
        {
            DocPublishedToggle.Visibility = Visibility.Collapsed;
            DocPublishedToggle.IsEnabled  = false;
            return;
        }

        DocPublishedToggle.Visibility = Visibility.Visible;
        DocPublishedToggle.IsEnabled  = true;
        DocPublishedToggle.IsChecked  = !DocTopicsLoader.IsNavExcluded(filePath);
    }

    private void DocPublishedToggle_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var filePath = _currentDocPath;
            if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) return;

            var isPublished = DocPublishedToggle?.IsChecked == true;
            var newFrontMatter = DocumentUtilities.SetNavExclude(_currentDocFrontMatter, excluded: !isPublished);

            string bodyText;
            if (DocSourcePanel?.Visibility == Visibility.Visible && DocSourceTextBox is not null)
            {
                _docSourceSaveTimer?.Stop();
                _docPreviewRefreshTimer?.Stop();
                bodyText = DocSourceTextBox.GetPlainText();
            }
            else
            {
                var raw = File.ReadAllText(filePath);
                bodyText = DocumentUtilities.StripDocFrontMatter(raw, out _);
            }

            _currentDocFrontMatter = newFrontMatter;
            _docSaveSuppressionUntil = DateTime.UtcNow.AddSeconds(2);
            File.WriteAllText(filePath, _currentDocFrontMatter + bodyText);

            RefreshCurrentDocTopicHeader();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(DocPublishedToggle_Click), ex);
        }
    }

    private void RefreshCurrentDocTopicHeader()
    {
        if (DocTopicsTreeView is null || _currentDocPath is null) return;

        var item = FindDocNodeByTag(DocTopicsTreeView.Items, _currentDocPath);
        if (item is null) return;

        var title = DocTopicsLoader.ExtractMarkdownTitle(_currentDocPath)
                    ?? Path.GetFileNameWithoutExtension(_currentDocPath);
        item.Header = DocTopicsLoader.BuildItemHeaderPublic(title, _currentDocPath, _docStatusStore);
    }

    private void ApproveDocButton_Click(object sender, RoutedEventArgs e)
    {
        var item = DocTopicsTreeView?.SelectedItem as TreeViewItem;
        var filePath = item?.Tag as string;
        if (string.IsNullOrEmpty(filePath) || _docStatusStore is null) return;

        _docStatusStore.SetApproved(filePath);
        PopulateDocumentationTopics();
        UpdateApproveDocButton(filePath);
    }

    private void RenameDocTopic(TreeViewItem item, string filePath)
        => EnterInPlaceRename(item, filePath);

    private void EnterInPlaceRename(TreeViewItem item, string filePath)
    {
        CancelInPlaceRename();

        if (item.Header is not System.Windows.Controls.StackPanel sp || sp.Children.Count == 0)
            return;
        if (sp.Children[0] is not System.Windows.Controls.TextBlock tb)
            return;

        _docsRenameItem = item;
        _docsRenameOriginalTextBlock = tb;

        var textBox = new System.Windows.Controls.TextBox
        {
            Text = tb.Text,
            MinWidth = 80,
            MaxWidth = 220,
            FontSize = tb.FontSize,
            FontWeight = tb.FontWeight,
            Margin = new System.Windows.Thickness(0),
            Padding = new System.Windows.Thickness(2, 0, 2, 0),
            VerticalAlignment = System.Windows.VerticalAlignment.Center,
            BorderThickness = new System.Windows.Thickness(1),
        };

        sp.Children.RemoveAt(0);
        sp.Children.Insert(0, textBox);

        textBox.Focus();
        textBox.SelectAll();

        textBox.KeyDown += (_, ke) =>
        {
            if (ke.Key == System.Windows.Input.Key.Enter)
            {
                ke.Handled = true;
                CommitInPlaceRename(item, filePath, textBox.Text.Trim());
            }
            else if (ke.Key == System.Windows.Input.Key.Escape)
            {
                ke.Handled = true;
                CancelInPlaceRename();
            }
        };

        textBox.LostFocus += (_, _) =>
        {
            if (_docsRenameItem == item)
                CommitInPlaceRename(item, filePath, textBox.Text.Trim());
        };
    }

    private void CancelInPlaceRename()
    {
        if (_docsRenameItem is null) return;
        var item = _docsRenameItem;
        var originalTb = _docsRenameOriginalTextBlock;
        _docsRenameItem = null;
        _docsRenameIsFromAdd = false;
        _docsRenameOriginalTextBlock = null;

        if (item.Header is System.Windows.Controls.StackPanel sp
            && sp.Children.Count > 0
            && sp.Children[0] is System.Windows.Controls.TextBox
            && originalTb is not null)
        {
            sp.Children.RemoveAt(0);
            sp.Children.Insert(0, originalTb);
        }
    }

    private void CommitInPlaceRename(TreeViewItem item, string filePath, string newName)
    {
        if (_docsRenameItem != item) return;

        bool isFromAdd = _docsRenameIsFromAdd;
        var oldTb = _docsRenameOriginalTextBlock;
        var oldName = oldTb?.Text ?? Path.GetFileNameWithoutExtension(filePath);

        // Clear state before any async/UI work to prevent re-entry from LostFocus
        _docsRenameItem = null;
        _docsRenameIsFromAdd = false;
        _docsRenameOriginalTextBlock = null;

        // Restore original TextBlock regardless of outcome
        if (item.Header is System.Windows.Controls.StackPanel sp
            && sp.Children.Count > 0
            && sp.Children[0] is System.Windows.Controls.TextBox
            && oldTb is not null)
        {
            sp.Children.RemoveAt(0);
            sp.Children.Insert(0, oldTb);
        }

        if (string.IsNullOrWhiteSpace(newName)) return;

        if (isFromAdd)
            newName = SlugifyDocName(newName);

        if (string.Equals(newName, oldName, StringComparison.Ordinal)) return;

        CommitDocRename(item, filePath, oldName, newName);
    }

    private static string SlugifyDocName(string name) =>
        DocumentUtilities.SlugifyDocName(name);

    private void CommitDocRename(TreeViewItem item, string filePath, string oldName, string newName)
    {
        try
        {
            var dir = Path.GetDirectoryName(filePath)!;
            var ext = Path.GetExtension(filePath);
            var newFileName = newName + ext;
            var newFilePath = Path.Combine(dir, newFileName);

            if (File.Exists(newFilePath) && !string.Equals(filePath, newFilePath, StringComparison.OrdinalIgnoreCase))
            {
                System.Windows.MessageBox.Show($"A file named '{newFileName}' already exists.", "Rename", MessageBoxButton.OK, MessageBoxImage.Warning);
                return;
            }

            if (_docsWatcher != null) _docsWatcher.EnableRaisingEvents = false;

            try
            {
                try
                {
                    var fileLines = File.ReadAllLines(filePath).ToList();
                    for (int i = 0; i < fileLines.Count; i++)
                    {
                        if (fileLines[i].StartsWith("# "))
                        {
                            var heading = fileLines[i].Substring(2).Trim();
                            if (string.Equals(heading, oldName, StringComparison.OrdinalIgnoreCase))
                                fileLines[i] = "# " + newName;
                            break;
                        }
                    }
                    File.WriteAllLines(filePath, fileLines);
                }
                catch { }

                File.Move(filePath, newFilePath);

                if (_docStatusStore != null)
                {
                    var oldStatus = _docStatusStore.GetStatus(filePath);
                    if (_docStatusStore.HasBeenTracked(filePath))
                    {
                        if (oldStatus == DocApprovalStatus.Approved)
                            _docStatusStore.SetApproved(newFilePath);
                        else
                            _docStatusStore.SetNeedsReview(newFilePath);
                    }
                }

                var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);
                if (!string.IsNullOrEmpty(docsRoot))
                {
                    var summaryPath = Path.Combine(docsRoot, "SUMMARY.md");
                    if (File.Exists(summaryPath))
                    {
                        try
                        {
                            var summaryContent = File.ReadAllText(summaryPath);
                            var oldRelPath = Path.GetRelativePath(docsRoot, filePath).Replace('\\', '/');
                            var newRelPath = Path.GetRelativePath(docsRoot, newFilePath).Replace('\\', '/');
                            var oldPattern = $"[{oldName}]({oldRelPath})";
                            var newPattern = $"[{newName}]({newRelPath})";
                            if (summaryContent.Contains(oldPattern))
                            {
                                summaryContent = summaryContent.Replace(oldPattern, newPattern);
                                File.WriteAllText(summaryPath, summaryContent);
                            }
                        }
                        catch { }
                    }
                }
            }
            finally
            {
                if (_docsWatcher != null) _docsWatcher.EnableRaisingEvents = true;
            }

            if (string.Equals(_currentDocPath, filePath, StringComparison.OrdinalIgnoreCase))
                _currentDocPath = newFilePath;

            PopulateDocumentationTopics();
            var renamedItem = FindDocNodeByTag(DocTopicsTreeView.Items, newFilePath);
            if (renamedItem != null)
                renamedItem.IsSelected = true;
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(CommitDocRename), ex);
        }
    }

    private string? ShowStringInputDialog(string title, string prompt, string initialValue)
    {
        var dialog = new System.Windows.Window
        {
            Title = title,
            Width = 380,
            Height = 160,
            WindowStartupLocation = WindowStartupLocation.CenterOwner,
            Owner = this,
            ResizeMode = ResizeMode.NoResize,
            ShowInTaskbar = false,
            Background = (System.Windows.Media.Brush)TryFindResource("AppSurface")
                         ?? System.Windows.Media.Brushes.White,
        };

        var grid = new System.Windows.Controls.Grid { Margin = new Thickness(16) };
        grid.RowDefinitions.Add(new System.Windows.Controls.RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new System.Windows.Controls.RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new System.Windows.Controls.RowDefinition { Height = GridLength.Auto });

        var promptLabel = new System.Windows.Controls.TextBlock
        {
            Text = prompt,
            Margin = new Thickness(0, 0, 0, 6),
            Foreground = (System.Windows.Media.Brush)TryFindResource("LabelText")
                         ?? System.Windows.Media.Brushes.Black,
        };
        System.Windows.Controls.Grid.SetRow(promptLabel, 0);

        var textBox = new System.Windows.Controls.TextBox
        {
            Text = initialValue,
            Margin = new Thickness(0, 0, 0, 12),
            Padding = new Thickness(6, 4, 6, 4),
            Background = (System.Windows.Media.Brush)TryFindResource("InputSurface")
                         ?? System.Windows.Media.Brushes.White,
            Foreground = (System.Windows.Media.Brush)TryFindResource("LabelText")
                         ?? System.Windows.Media.Brushes.Black,
        };
        System.Windows.Controls.Grid.SetRow(textBox, 1);

        var buttonPanel = new System.Windows.Controls.StackPanel
        {
            Orientation = System.Windows.Controls.Orientation.Horizontal,
            HorizontalAlignment = HorizontalAlignment.Right,
        };
        System.Windows.Controls.Grid.SetRow(buttonPanel, 2);

        string? result = null;

        var okButton = new System.Windows.Controls.Button
        {
            Content = "OK",
            Width = 72,
            Height = 28,
            Margin = new Thickness(0, 0, 8, 0),
            IsDefault = true,
        };
        if (TryFindResource("ThemedButtonStyle") is System.Windows.Style btnStyle)
            okButton.Style = btnStyle;
        okButton.Click += (_, _) => { result = textBox.Text; dialog.DialogResult = true; };

        var cancelButton = new System.Windows.Controls.Button
        {
            Content = "Cancel",
            Width = 72,
            Height = 28,
            IsCancel = true,
        };
        if (TryFindResource("ThemedButtonStyle") is System.Windows.Style cancelStyle)
            cancelButton.Style = cancelStyle;
        cancelButton.Click += (_, _) => { dialog.DialogResult = false; };

        buttonPanel.Children.Add(okButton);
        buttonPanel.Children.Add(cancelButton);

        grid.Children.Add(promptLabel);
        grid.Children.Add(textBox);
        grid.Children.Add(buttonPanel);
        dialog.Content = grid;

        textBox.Loaded += (_, _) => { textBox.SelectAll(); textBox.Focus(); };

        dialog.ShowDialog();
        return result;
    }
    /// <summary>
    /// Extracts the display title string from a docs TreeViewItem, handling both
    /// plain-string headers and StackPanel headers (built by DocTopicsLoader.BuildItemHeader).
    /// </summary>
    private static string? GetTopicItemTitle(TreeViewItem item)
    {
        return item.Header switch
        {
            string s                    => s,
            StackPanel sp when sp.Children.Count > 0 && sp.Children[0] is TextBlock tb => tb.Text,
            _                           => null,
        };
    }

    private void DocTopicsTreeView_CopyMarkdownLink(TreeViewItem item)
    {
        var filePath = item.Tag as string;
        if (string.IsNullOrEmpty(filePath)) return;

        var title = GetTopicItemTitle(item) ?? Path.GetFileNameWithoutExtension(filePath);
        var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);

        string relativePath;
        if (!string.IsNullOrEmpty(docsRoot) &&
            filePath.StartsWith(docsRoot, StringComparison.OrdinalIgnoreCase))
        {
            relativePath = filePath.Substring(docsRoot.Length)
                .TrimStart('\\', '/')
                .Replace('\\', '/');
        }
        else
        {
            relativePath = Path.GetFileName(filePath);
        }

        Clipboard.SetText($"[{title}]({relativePath})");
    }

    private void DocTopicsTreeView_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        _docsDragStartPoint = e.GetPosition(null);
        var clickedItem = FindAncestorTreeViewItem(e.OriginalSource as DependencyObject);
        _docsDragItem = clickedItem;
        _docsDragInProgress = false;

        if (clickedItem is not null && clickedItem.IsSelected && clickedItem == _docsRenameLastClickedItem)
        {
            var elapsed = (DateTime.Now - _docsRenameClickTime).TotalMilliseconds;
            if (elapsed >= 400 && elapsed <= 1200)
            {
                var filePath = clickedItem.Tag as string;
                if (!string.IsNullOrEmpty(filePath))
                {
                    e.Handled = true;
                    _docsDragItem = null;
                    EnterInPlaceRename(clickedItem, filePath);
                    return;
                }
            }
        }

        _docsRenameClickTime = DateTime.Now;
        _docsRenameLastClickedItem = clickedItem;
    }

    private void DocTopicsTreeView_PreviewMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
    {
        if (_docsRenameItem is not null) return;
        if (e.LeftButton != System.Windows.Input.MouseButtonState.Pressed || _docsDragItem is null || _docsDragInProgress)
            return;

        var pos = e.GetPosition(null);
        var diff = pos - _docsDragStartPoint;
        if (Math.Abs(diff.X) < SystemParameters.MinimumHorizontalDragDistance &&
            Math.Abs(diff.Y) < SystemParameters.MinimumVerticalDragDistance)
            return;

        _docsDragInProgress = true;
        var data = new DataObject("DocTopicTreeViewItem", _docsDragItem);
        DragDrop.DoDragDrop(_docsDragItem, data, DragDropEffects.Move);
        _docsDragInProgress = false;
        _docsDragItem = null;
        HideDocTopicsDropIndicator();
    }

    private void DocTopicsTreeView_DragOver(object sender, DragEventArgs e)
    {
        if (!e.Data.GetDataPresent("DocTopicTreeViewItem"))
        {
            e.Effects = DragDropEffects.None;
            e.Handled = true;
            return;
        }

        e.Effects = DragDropEffects.Move;
        var draggedItem = e.Data.GetData("DocTopicTreeViewItem") as TreeViewItem;
        UpdateDocTopicsDropIndicator(e.GetPosition(DocTopicsTreeView), draggedItem);
        e.Handled = true;
    }

    private void DocTopicsTreeView_DragLeave(object sender, DragEventArgs e)
    {
        HideDocTopicsDropIndicator();
    }

    private void DocTopicsTreeView_Drop(object sender, DragEventArgs e)
    {
        HideDocTopicsDropIndicator();
        if (!e.Data.GetDataPresent("DocTopicTreeViewItem"))
            return;

        var draggedItem = e.Data.GetData("DocTopicTreeViewItem") as TreeViewItem;
        if (draggedItem is null)
            return;

        var dropTarget = FindDropTarget(e.GetPosition(DocTopicsTreeView), draggedItem, out DropZone zone);
        if (dropTarget is null || ReferenceEquals(dropTarget, draggedItem))
            return;

        ReorderDocTopics(draggedItem, dropTarget, zone);
    }

    private static TreeViewItem? FindAncestorTreeViewItem(DependencyObject? source)
    {
        while (source is not null)
        {
            if (source is TreeViewItem item)
                return item;
            source = VisualTreeHelper.GetParent(source);
        }
        return null;
    }

    private static ListBoxItem? FindAncestorListBoxItem(DependencyObject? source)
    {
        while (source is not null)
        {
            if (source is ListBoxItem item)
                return item;
            source = VisualTreeHelper.GetParent(source);
        }
        return null;
    }

    private void UpdateDocTopicsDropIndicator(Point posInTreeView, TreeViewItem? draggedItem = null)
    {
        if (DocTopicsDropIndicatorCanvas is null || DocTopicsDropIndicator is null)
            return;

        var hitItem = FindItemAtPoint(DocTopicsTreeView, posInTreeView);
        if (hitItem is null)
        {
            HideDocTopicsDropIndicator();
            return;
        }

        var itemBounds = hitItem.TransformToAncestor(DocTopicsTreeView).TransformBounds(
            new Rect(0, 0, hitItem.ActualWidth, hitItem.ActualHeight));

        // A node is a valid InsideAsChild target when it is at the root level of the tree
        // (parent == TreeView). Child-level nodes only support Before/After to avoid creating
        // three-level nesting that the tree loader cannot represent. Critically, this includes
        // childless root nodes — previously Items.Count > 0 excluded them, causing Bug 2.
        bool isGroupTarget = hitItem.Parent is TreeView;
        bool draggedIsGroup = draggedItem?.Items.Count > 0;
        double relY = posInTreeView.Y - itemBounds.Y;
        double height = itemBounds.Height;

        DropZone zone;
        // Groups (items with children) cannot be nested inside other groups — use 50/50 zones.
        if (isGroupTarget && !draggedIsGroup)
        {
            if (relY < height * 0.25)
                zone = DropZone.Before;
            else if (relY > height * 0.75)
                zone = DropZone.After;
            else
                zone = DropZone.InsideAsChild;
        }
        else
        {
            zone = relY < height / 2 ? DropZone.Before : DropZone.After;
        }

        // Clear previous inside-highlight if the hover moved to a different item
        if (_dropInsideTarget is not null && !ReferenceEquals(_dropInsideTarget, hitItem))
        {
            _dropInsideTarget.Background = null;
            _dropInsideTarget = null;
        }

        if (zone == DropZone.InsideAsChild)
        {
            DocTopicsDropIndicator.Visibility = Visibility.Collapsed;
            hitItem.SetResourceReference(BackgroundProperty, "HoverSurface");
            _dropInsideTarget = hitItem;
        }
        else
        {
            if (_dropInsideTarget is not null)
            {
                _dropInsideTarget.Background = null;
                _dropInsideTarget = null;
            }

            double lineY = zone == DropZone.Before ? itemBounds.Y : itemBounds.Bottom;
            DocTopicsDropIndicator.Width = DocTopicsDropIndicatorCanvas.ActualWidth > 0
                ? DocTopicsDropIndicatorCanvas.ActualWidth
                : DocTopicsTreeView.ActualWidth;
            Canvas.SetTop(DocTopicsDropIndicator, Math.Max(0, lineY - 1));
            Canvas.SetLeft(DocTopicsDropIndicator, 0);
            DocTopicsDropIndicator.Visibility = Visibility.Visible;
        }
    }

    private void HideDocTopicsDropIndicator()
    {
        if (DocTopicsDropIndicator is not null)
            DocTopicsDropIndicator.Visibility = Visibility.Collapsed;

        if (_dropInsideTarget is not null)
        {
            _dropInsideTarget.Background = null;
            _dropInsideTarget = null;
        }
    }

    private static TreeViewItem? FindItemAtPoint(TreeView treeView, Point point)
    {
        var result = VisualTreeHelper.HitTest(treeView, point);
        if (result?.VisualHit is null) return null;
        return FindAncestorTreeViewItem(result.VisualHit);
    }

    private enum DropZone { Before, After, InsideAsChild }

    private TreeViewItem? FindDropTarget(Point posInTreeView, TreeViewItem? draggedItem, out DropZone zone)
    {
        zone = DropZone.After;
        var hitItem = FindItemAtPoint(DocTopicsTreeView, posInTreeView);
        if (hitItem is null) return null;

        var itemBounds = hitItem.TransformToAncestor(DocTopicsTreeView).TransformBounds(
            new Rect(0, 0, hitItem.ActualWidth, hitItem.ActualHeight));

        bool isGroupTarget = hitItem.Parent is TreeView;
        bool draggedIsGroup = draggedItem?.Items.Count > 0;
        double relY = posInTreeView.Y - itemBounds.Y;
        double height = itemBounds.Height;

        // Groups (items with children) cannot be nested inside other groups — use 50/50 zones.
        // Root-level nodes (isGroupTarget) allow InsideAsChild regardless of their current
        // child count, fixing the bug where childless root nodes couldn't accept dropped items.
        if (isGroupTarget && !draggedIsGroup)
        {
            if (relY < height * 0.25)
                zone = DropZone.Before;
            else if (relY > height * 0.75)
                zone = DropZone.After;
            else
                zone = DropZone.InsideAsChild;
        }
        else
        {
            zone = relY < height / 2 ? DropZone.Before : DropZone.After;
        }

        return hitItem;
    }

    private void ReorderDocTopics(TreeViewItem draggedItem, TreeViewItem targetItem, DropZone zone)
    {
        var docsRoot = DocTopicsLoader.FindDocsFolderPath(_currentWorkspace?.FolderPath);
        if (string.IsNullOrEmpty(docsRoot)) return;

        var summaryPath = Path.Combine(docsRoot, "SUMMARY.md");
        if (!File.Exists(summaryPath)) return;

        var lines = File.ReadAllLines(summaryPath).ToList();

        int draggedLineIndex = FindSummaryLineIndex(lines, draggedItem, docsRoot);
        int targetLineIndex = FindSummaryLineIndex(lines, targetItem, docsRoot);

        if (draggedLineIndex < 0 || targetLineIndex < 0 || draggedLineIndex == targetLineIndex)
            return;

        // Collect the dragged block: header line + all immediately following child lines
        int parentIndent = lines[draggedLineIndex].TakeWhile(char.IsWhiteSpace).Count();
        var draggedBlock = new List<string> { lines[draggedLineIndex] };
        int next = draggedLineIndex + 1;
        while (next < lines.Count)
        {
            var nextLine = lines[next];
            if (string.IsNullOrWhiteSpace(nextLine) || nextLine.TrimStart().StartsWith("##"))
                break;
            if (nextLine.TakeWhile(char.IsWhiteSpace).Count() <= parentIndent)
                break;
            draggedBlock.Add(nextLine);
            next++;
        }

        // Guard: target must not be inside the dragged block
        if (targetLineIndex >= draggedLineIndex && targetLineIndex < draggedLineIndex + draggedBlock.Count)
            return;

        lines.RemoveRange(draggedLineIndex, draggedBlock.Count);

        // Adjust targetLineIndex after removal
        if (targetLineIndex > draggedLineIndex)
            targetLineIndex -= draggedBlock.Count;

        if (zone == DropZone.InsideAsChild)
        {
            // Insert as first child of target, with two extra spaces of indentation
            int targetIndent = lines[targetLineIndex].TakeWhile(char.IsWhiteSpace).Count();
            for (int i = 0; i < draggedBlock.Count; i++)
            {
                int lineIndent = draggedBlock[i].TakeWhile(char.IsWhiteSpace).Count();
                int delta = lineIndent - parentIndent;
                draggedBlock[i] = new string(' ', targetIndent + 2 + delta) + draggedBlock[i].TrimStart();
            }
            lines.InsertRange(targetLineIndex + 1, draggedBlock);
        }
        else
        {
            // Before or After: preserve the dragged block's original indentation.
            // Re-indenting would silently reparent items; reorder does not reparent.
            int insertIndex;
            if (zone == DropZone.After)
            {
                insertIndex = GetBlockEndIndex(lines, targetLineIndex) + 1;
            }
            else
            {
                insertIndex = targetLineIndex;
            }

            insertIndex = Math.Clamp(insertIndex, 0, lines.Count);
            lines.InsertRange(insertIndex, draggedBlock);
        }

        if (_docsWatcher is not null)
            _docsWatcher.EnableRaisingEvents = false;

        try
        {
            File.WriteAllLines(summaryPath, lines);
        }
        finally
        {
            if (_docsWatcher is not null)
                _docsWatcher.EnableRaisingEvents = true;
        }

        PopulateDocumentationTopics();
    }

    private static int GetBlockEndIndex(List<string> lines, int startIndex)
    {
        int parentIndent = lines[startIndex].TakeWhile(char.IsWhiteSpace).Count();
        int end = startIndex;
        int i = startIndex + 1;
        while (i < lines.Count)
        {
            var line = lines[i];
            if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("##"))
                break;
            if (line.TakeWhile(char.IsWhiteSpace).Count() <= parentIndent)
                break;
            end = i;
            i++;
        }
        return end;
    }

    private static int FindSummaryLineIndex(List<string> lines, TreeViewItem item, string docsRoot)
    {
        var filePath = item.Tag as string;
        var header = item.Header?.ToString();

        for (int i = 0; i < lines.Count; i++)
        {
            var line = lines[i];
            if (string.IsNullOrWhiteSpace(line)) continue;

            if (filePath is not null)
            {
                var start = line.IndexOf('(');
                var end = line.IndexOf(')', start + 1);
                if (start >= 0 && end > start)
                {
                    var href = line.Substring(start + 1, end - start - 1);
                    var relPath = Path.GetRelativePath(docsRoot, filePath).Replace('\\', '/');
                    if (string.Equals(href, relPath, StringComparison.OrdinalIgnoreCase))
                        return i;
                }
            }

            if (header is not null && filePath is null)
            {
                var titleStart = line.IndexOf('[');
                var titleEnd = line.IndexOf(']', titleStart + 1);
                if (titleStart >= 0 && titleEnd > titleStart)
                {
                    var title = line.Substring(titleStart + 1, titleEnd - titleStart - 1);
                    if (string.Equals(title, header, StringComparison.OrdinalIgnoreCase))
                        return i;
                }
            }
        }
        return -1;
    }

    private void ShowTasksStatusWindow()
    {
        if (_tasksStatusWindow is null)
        {
            SquadDashTrace.Write("UI", "Showing live tasks popup.");
            _tasksStatusWindow = new TasksStatusWindow();
            if (CanShowOwnedWindow())
                _tasksStatusWindow.Owner = this;

            _tasksStatusWindow.Closed += (_, _) => { _tasksStatusWindow = null; _tasksWindowOffset = null; };
            _tasksStatusWindow.LocationChanged += (_, _) => OnTasksWindowMoved();
            _tasksStatusWindow.Show();
        }

        RefreshTasksStatusWindow(DateTimeOffset.Now);
        PositionTasksStatusWindow();
    }

    private void HideTasksStatusWindow()
    {
        if (_tasksStatusWindow is not null)
            SquadDashTrace.Write("UI", "Hiding live tasks popup.");
        _tasksStatusWindow?.Close();
    }

    private void ShowScreenshotHealthWindow()
    {
        if (_screenshotHealthWindow is null)
        {
            SquadDashTrace.Write("UI", "Showing screenshot health popup.");
            _screenshotHealthWindow = new ScreenshotHealthWindow(ScreenshotHealthChecker);
            if (CanShowOwnedWindow())
                _screenshotHealthWindow.Owner = this;

            _screenshotHealthWindow.Closed += (_, _) =>
            {
                _screenshotHealthWindow      = null;
                _screenshotHealthWindowOffset = null;
            };
            _screenshotHealthWindow.LocationChanged += (_, _) => OnScreenshotHealthWindowMoved();
            _screenshotHealthWindow.Show();
        }
        else
        {
            _screenshotHealthWindow.Activate();
        }

        PositionScreenshotHealthWindow();
    }

    private void ShowApprovalPanel()
    {
        _approvalPanelVisible = !_approvalPanelVisible;
        SyncApprovalPanel();
        if (ViewCommitApprovalsMenuItem is not null)
            ViewCommitApprovalsMenuItem.IsChecked = _approvalPanelVisible;
        PersistApprovalPanelVisible();
    }

    private void SyncApprovalPanel()
    {
        if (ApprovalPanelBorder is null) return;
        ApprovalPanelBorder.Visibility = _approvalPanelVisible ? Visibility.Visible : Visibility.Collapsed;
        if (_approvalPanelVisible && _approvalPanel is null)
        {
            _approvalPanel = new CommitApprovalPanel(
                ApprovalNeedsPanel!,
                ApprovalApprovedPanel!,
                ApprovalRejectedPanel!,
                ApprovalRejectedSection!,
                ApprovalApprovedSection!,
                ApprovalApprovedScrollViewer!,
                ApprovalPanelBorder!,
                ApprovalNeedsScrollViewer!,
                navigateUrl: url => _ = OpenExternalLinkWithCommitCheckAsync(url),
                scrollToTurn: item => ScrollToApprovalTurn(item),
                onItemChanged: item => OnApprovalItemChanged(item),
                onItemsRemoved: items => OnApprovalItemsRemoved(items),
                onFollowUp: item => AttachFollowUpToActiveTab(item),
                addToNotes: item => AddNoteFromTextWithTitle(
                    $"Approval - {item.Description}",
                    BuildApprovalContentBlock(item)),
                initialShowApproved: _settingsStore.Load().ApprovalShowApproved,
                onShowApprovedChanged: show => _settingsStore.SaveApprovalShowApproved(show),
                initialShowRejected: _settingsStore.Load().ApprovalShowRejected,
                onShowRejectedChanged: show => _settingsStore.SaveApprovalShowRejected(show));
            _approvalPanel.ReplaceAllItems(_approvalItems);
        }
    }

    // ── Transcript follow-up attachment ──────────────────────────────────────

    private void AttachTranscriptFollowUp(RichTextBox rtb)
    {
        var quote = TranscriptCopyService.BuildSelectionText(rtb);
        if (string.IsNullOrWhiteSpace(quote)) return;

        var list = GetOrCreateFollowUpList(_activeTabId ?? "");
        // Deduplicate: don't add if the same transcript quote is already in the list.
        if (list.Count >= 15 || list.Any(a => a.TranscriptQuote == quote)) return;

        // Title: first non-whitespace line of the selection, capped at 80 chars.
        var firstLine = quote.Split('\n')
            .Select(l => l.Trim())
            .FirstOrDefault(l => l.Length > 0) ?? quote.TrimStart();
        var title = firstLine.Length > 80 ? firstLine[..80].TrimEnd() + "…" : firstLine;

        // Scan backwards through the thread's prompt paragraphs to find the prompt
        // that precedes the selection, giving the AI useful context.
        var thread = FindThreadForDocument(rtb.Document);
        var selectionStart = rtb.Selection.Start;
        PromptEntry? precedingEntry = null;
        // Guard: only compare TextPointers when both belong to the same FlowDocument.
        // FindThreadForDocument falls back to CoordinatorThread when the RTB's document
        // isn't registered yet; in that case the PromptParagraphs belong to a different
        // TextTree and CompareTo would throw ArgumentException.
        if (ReferenceEquals(thread.Document, rtb.Document))
        {
            foreach (var entry in thread.PromptParagraphs)
            {
                if (entry.Paragraph.ContentStart.CompareTo(selectionStart) < 0)
                    precedingEntry = entry;
                else
                    break;
            }
        }
        string? originalPrompt = precedingEntry is not null
            ? new TextRange(precedingEntry.Paragraph.ContentStart, precedingEntry.Paragraph.ContentEnd).Text.Trim()
            : null;

        list.Add(new FollowUpAttachment(
            CommitSha:      string.Empty,
            Description:    title,
            OriginalPrompt: originalPrompt,
            TranscriptQuote: quote));
        UpdateFollowUpStrip();
        SyncQueuePanel();
        if (_activeTabId is null) PersistDraftFollowUp();
    }

    private TranscriptThreadState FindThreadForDocument(FlowDocument document)
    {
        if (ReferenceEquals(CoordinatorThread.Document, document))
            return CoordinatorThread;
        return _agentThreadRegistry.ThreadOrder.FirstOrDefault(t => ReferenceEquals(t.Document, document))
            ?? CoordinatorThread;
    }

    // ── Follow-up attachment ──────────────────────────────────────────────────

    private void AttachNoteFollowUp(NoteItem note)
    {
        var path = _notesStore?.GetNotePath(note.Id) ?? "";
        string content = "";
        try { if (!string.IsNullOrEmpty(path)) content = File.ReadAllText(path); } catch { }

        var body = new System.Text.StringBuilder();
        if (!string.IsNullOrEmpty(path))
            body.AppendLine($"File: {path}");
        if (!string.IsNullOrWhiteSpace(content))
        {
            if (body.Length > 0) body.AppendLine();
            body.Append(content);
        }

        AttachContextFollowUp($"Note: {note.Title}", BuildTypedAttachmentBlock("note", note.Title, body.ToString().TrimEnd()));
    }

    private void AttachTopicFollowUp(TreeViewItem treeItem, string filePath)
    {
        // Extract display title from TreeViewItem header or fall back to filename.
        string title = filePath;
        if (treeItem.Header is FrameworkElement fe)
        {
            var tb = FindVisualChild<TextBlock>(fe);
            if (tb is not null && !string.IsNullOrWhiteSpace(tb.Text))
                title = tb.Text.Trim();
        }
        if (string.IsNullOrWhiteSpace(title) || title == filePath)
            title = Path.GetFileNameWithoutExtension(filePath);

        string content = "";
        try { content = File.ReadAllText(filePath); } catch { }

        var body = new System.Text.StringBuilder();
        body.AppendLine($"File: {filePath}");
        if (!string.IsNullOrWhiteSpace(content))
        {
            body.AppendLine();
            body.Append(content);
        }

        AttachContextFollowUp($"Topic: {title}", BuildTypedAttachmentBlock("doc", title, body.ToString().TrimEnd()));
    }

    private static string BuildTaskContentBlock(TaskItem task)
    {
        var sb = new System.Text.StringBuilder();
        sb.AppendLine("## Task context");
        sb.AppendLine($"Title: {task.Text}");
        var priority = task.Emoji switch {
            "🔴" => "High",
            "🟡" => "Mid",
            "🟢" => "Low",
            "✅" => "Done",
            _    => task.Emoji
        };
        sb.AppendLine($"Priority: {priority}");
        sb.AppendLine($"Status: {(task.IsChecked ? "Done" : "Open")}");
        if (!string.IsNullOrWhiteSpace(task.Owner))
            sb.AppendLine($"Owner: {task.Owner}");
        if (!string.IsNullOrWhiteSpace(task.Description))
        {
            sb.AppendLine();
            sb.AppendLine("Description:");
            sb.AppendLine(task.Description.Trim());
        }
        return sb.ToString().TrimEnd();
    }

    /// <summary>
    /// Body content for a task typed attachment block (no markdown header — title goes in the XML attribute).
    /// </summary>
    private static string BuildTaskContentBody(TaskItem task)
    {
        var sb = new System.Text.StringBuilder();
        var priority = task.Emoji switch {
            "🔴" => "High",
            "🟡" => "Mid",
            "🟢" => "Low",
            "✅" => "Done",
            _    => task.Emoji
        };
        sb.AppendLine($"Priority: {priority}");
        sb.AppendLine($"Status: {(task.IsChecked ? "Done" : "Open")}");
        if (!string.IsNullOrWhiteSpace(task.Owner))
            sb.AppendLine($"Owner: {task.Owner}");
        if (!string.IsNullOrWhiteSpace(task.Description))
        {
            sb.AppendLine();
            sb.AppendLine("Description:");
            sb.AppendLine(task.Description.Trim());
        }
        return sb.ToString().TrimEnd();
    }

    private static string BuildApprovalContentBlock(CommitApprovalItem item)
    {
        var sb = new System.Text.StringBuilder();
        sb.AppendLine("## Approval context");
        sb.AppendLine($"Description: {item.Description}");
        if (!string.IsNullOrWhiteSpace(item.CommitSha))
            sb.AppendLine($"Commit: {item.CommitSha}");
        if (!string.IsNullOrWhiteSpace(item.OriginalPrompt))
        {
            sb.AppendLine();
            sb.AppendLine("Original prompt:");
            sb.AppendLine(item.OriginalPrompt!.Trim());
        }
        return sb.ToString().TrimEnd();
    }

    private static string BuildClipboardAttachDescription(string text)
    {
        const int PreviewLength = 60;
        var normalized = System.Text.RegularExpressions.Regex.Replace(text.Trim(), @"\s+", " ");
        var preview = normalized.Length > PreviewLength
            ? normalized[..PreviewLength] + "…"
            : normalized;
        return $"Clipboard: {preview} ({text.Length:N0} chars)";
    }

    /// <summary>
    /// Builds a typed XML-style attachment block for the AI.
    /// Format: <c>&lt;attachment type="…" title="…"&gt;\ncontent\n&lt;/attachment&gt;</c>.
    /// The <paramref name="title"/> attribute is omitted when null.
    /// Any <c>&lt;/attachment&gt;</c> in <paramref name="content"/> is escaped so the
    /// block close tag cannot collide with content.
    /// </summary>
    private static string BuildTypedAttachmentBlock(string type, string? title, string content) =>
        AttachmentBlockFormatter.BuildTypedAttachmentBlock(type, title, content);

    [Obsolete("Use BuildTypedAttachmentBlock for new attachments. This overload is retained only to generate " +
              "SQUADCLIP fences for addToNotes callers that still expect the old markdown format.")]
    private static string BuildClipboardContentBlock(string text)
    {
        var nonce = Guid.NewGuid().ToString("N")[..8];
        return $"<<<SQUADCLIP:{nonce}>>>\n{text}\n<<<ENDCLIP:{nonce}>>>";
    }

    /// <summary>
    /// Given a <paramref name="prompt"/> that starts with a nonce-fenced clipboard block
    /// (<c>&lt;&lt;&lt;SQUADCLIP:nonce&gt;&gt;&gt;</c>), returns the index of the first character
    /// AFTER the close fence and its trailing <c>\n\n</c> separator (i.e. the start of the user's
    /// actual prompt body).  Returns -1 if the fence is malformed or the separator is missing.
    /// </summary>
    private static int StripClipboardFenceHeader(string prompt)
    {
        // Open fence line: <<<SQUADCLIP:{nonce}>>>
        var openEnd = prompt.IndexOf(">>>", StringComparison.Ordinal);
        if (openEnd < 0) return -1;
        var nonce = prompt["<<<SQUADCLIP:".Length..openEnd];
        var closeFence = $"\n<<<ENDCLIP:{nonce}>>>";
        var closeIdx = prompt.IndexOf(closeFence, openEnd, StringComparison.Ordinal);
        if (closeIdx < 0) return -1;
        var afterClose = closeIdx + closeFence.Length;
        // Expect \n\n separator immediately after the close fence line.
        if (afterClose + 2 <= prompt.Length && prompt[afterClose] == '\n' && prompt[afterClose + 1] == '\n')
            return afterClose + 2;
        return -1;
    }

    /// <summary>
    /// Given a prompt that uses the typed attachment format (preamble line + XML-style
    /// <c>&lt;attachment&gt;</c> blocks), returns the index of the first character of the user's
    /// actual message.  Returns -1 if the header section cannot be parsed.
    /// </summary>
    private static int StripTypedAttachmentHeaders(string prompt) =>
        AttachmentBlockFormatter.StripTypedAttachmentHeaders(prompt);

    private void AttachContextFollowUp(string description, string contentBlock)
    {
        var list = GetOrCreateFollowUpList(_activeTabId ?? "");
        // Deduplicate by description.
        if (list.Count >= 15 || list.Any(a => a.Description == description)) return;
        list.Add(new FollowUpAttachment(
            CommitSha:    string.Empty,
            Description:  description,
            OriginalPrompt: null,
            ContentBlock: contentBlock));
        UpdateFollowUpStrip();
        SyncQueuePanel();
        if (_activeTabId is null) PersistDraftFollowUp();
    }

    private void AttachFollowUpToActiveTab(CommitApprovalItem item)
    {
        var list = GetOrCreateFollowUpList(_activeTabId ?? "");
        // Deduplicate: don't add the same commit SHA twice.
        if (list.Count >= 15 || list.Any(a => string.Equals(a.CommitSha, item.CommitSha, StringComparison.OrdinalIgnoreCase))) return;

        list.Add(new FollowUpAttachment(item.CommitSha, item.Description, item.OriginalPrompt));
        UpdateFollowUpStrip();
        SyncQueuePanel();
        if (_activeTabId is null) PersistDraftFollowUp();
    }

    private List<FollowUpAttachment> GetOrCreateFollowUpList(string key)
    {
        if (!_followUpAttachments.TryGetValue(key, out var list))
            _followUpAttachments[key] = list = [];
        return list;
    }

    private void UpdateFollowUpStrip()
    {
        if (FollowUpStrip is null || FollowUpItemsPanel is null) return;
        if (_followUpAttachments.TryGetValue(_activeTabId ?? "", out var list) && list.Count > 0)
        {
            FollowUpItemsPanel.Children.Clear();
            for (int i = 0; i < list.Count; i++)
            {
                var att = list[i];
                var row = new DockPanel();
                if (i > 0)
                    row.Margin = new System.Windows.Thickness(0, 3, 0, 0);

                var dismissBtn = new Button
                {
                    Content           = "×",
                    Width             = 20,
                    Height            = 20,
                    Padding           = new System.Windows.Thickness(0),
                    VerticalAlignment = VerticalAlignment.Center,
                    Style             = (Style)FindResource("ThemedIconButtonStyle"),
                };
                DockPanel.SetDock(dismissBtn, Dock.Right);
                var capturedAtt = att;
                dismissBtn.Click += (_, e) =>
                {
                    e.Handled = true;
                    var l = GetOrCreateFollowUpList(_activeTabId ?? "");
                    l.Remove(capturedAtt);
                    UpdateFollowUpStrip();
                    SyncQueuePanel();
                    if (_activeTabId is null) PersistDraftFollowUp();
                };
                row.Children.Add(dismissBtn);

                var label = new TextBlock
                {
                    VerticalAlignment = VerticalAlignment.Center,
                    TextTrimming      = TextTrimming.CharacterEllipsis,
                };
                label.SetResourceReference(TextBlock.FontSizeProperty, "FontSizeSmall");
                label.SetResourceReference(TextBlock.ForegroundProperty, "SubtleText");

                if (att.ImagePath != null)
                {
                    int imgIndex = list.Take(i + 1).Count(a => a.ImagePath != null);
                    var icon    = new Run("📷 ");
                    var descRun = new Run($"(image {imgIndex})");
                    descRun.SetResourceReference(Run.ForegroundProperty, "LabelText");
                    label.Inlines.Add(icon);
                    label.Inlines.Add(descRun);
                    label.Cursor = System.Windows.Input.Cursors.Hand;
                    var capturedImg = capturedAtt;
                    label.MouseLeftButtonUp += (_, _) =>
                        PromptAttachmentViewerWindow.Show(new[] { capturedImg }, CanShowOwnedWindow() ? this : null);
                }
                else if (att.ContentBlock != null)
                {
                    if (att.Description.StartsWith("Note: ", StringComparison.Ordinal))
                    {
                        var icon = new Run("📝 ");
                        var descRun = new Run(att.Description["Note: ".Length..]);
                        descRun.SetResourceReference(Run.ForegroundProperty, "LabelText");
                        label.Inlines.Add(icon);
                        label.Inlines.Add(descRun);
                    }
                    else
                    {
                        var icon = new Run("📎 ");
                        var displayText = att.Description.StartsWith("Task: ", StringComparison.Ordinal)
                            ? att.Description["Task: ".Length..]
                            : att.Description.StartsWith("Topic: ", StringComparison.Ordinal)
                                ? att.Description["Topic: ".Length..]
                                : att.Description;
                        var descRun = new Run(displayText);
                        descRun.SetResourceReference(Run.ForegroundProperty, "LabelText");
                        label.Inlines.Add(icon);
                        label.Inlines.Add(descRun);
                    }
                    label.Cursor = System.Windows.Input.Cursors.Hand;
                    var capturedContent = capturedAtt;
                    label.MouseLeftButtonUp += (_, _) =>
                        PromptAttachmentViewerWindow.Show(new[] { capturedContent }, CanShowOwnedWindow() ? this : null);
                }
                else if (att.TranscriptQuote != null)
                {
                    var prefix = new Run("↩ ");
                    prefix.SetResourceReference(Run.ForegroundProperty, "SubtleText");
                    var quoteRun = new Run($"\"{att.Description}\"");
                    quoteRun.SetResourceReference(Run.ForegroundProperty, "LabelText");
                    label.Inlines.Add(prefix);
                    label.Inlines.Add(quoteRun);
                    label.Cursor = System.Windows.Input.Cursors.Hand;
                    var capturedQuote = capturedAtt;
                    label.MouseLeftButtonUp += (_, _) =>
                        PromptAttachmentViewerWindow.Show(new[] { capturedQuote }, CanShowOwnedWindow() ? this : null);
                }
                else
                {
                    AppendCommitFollowUpInlines(label, att);
                }

                row.Children.Add(label);
                FollowUpItemsPanel.Children.Add(row);
            }
            FollowUpStrip.Visibility = Visibility.Visible;
        }
        else
        {
            FollowUpItemsPanel.Children.Clear();
            FollowUpStrip.Visibility = Visibility.Collapsed;
        }
    }

    private void AppendCommitFollowUpInlines(TextBlock label, FollowUpAttachment att)
    {
        var shaDisplay = att.CommitSha.Length >= 7 ? att.CommitSha[..7] : att.CommitSha;

        var prefix = new Run("↩ ");
        prefix.SetResourceReference(Run.ForegroundProperty, "SubtleText");

        var shaRun = new Run(shaDisplay)
        {
            TextDecorations = TextDecorations.Underline,
            Cursor          = System.Windows.Input.Cursors.Hand,
        };
        shaRun.SetResourceReference(Run.ForegroundProperty, "DocumentLinkText");

        var suffix = new Run($" — \"{att.Description}\"");
        suffix.SetResourceReference(Run.ForegroundProperty, "SubtleText");

        if (label.Inlines.Count == 0)
            label.Inlines.Add(prefix);
        label.Inlines.Add(shaRun);
        label.Inlines.Add(suffix);

        // Clicking the underlined SHA opens the commit on GitHub.
        var capturedSha = att.CommitSha;
        shaRun.MouseLeftButtonUp += (_, e) =>
        {
            e.Handled = true;
            var commitUrl = _workspaceGitHubUrl is not null
                ? $"{_workspaceGitHubUrl}/commit/{capturedSha}"
                : null;
            if (commitUrl is not null)
                _ = OpenExternalLinkWithCommitCheckAsync(commitUrl);
        };
    }

    // Clicking anywhere on the follow-up strip (not the dismiss buttons) scrolls the
    // transcript to the turn where the first approval prompt was dispatched.
    private void FollowUpStrip_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e)
    {
        if (e.Handled) return;
        if (!_followUpAttachments.TryGetValue(_activeTabId ?? "", out var list)) return;

        // Find the first attachment that came from an approval item (has a commit SHA).
        var commitAtt = list.FirstOrDefault(a => !string.IsNullOrEmpty(a.CommitSha));
        if (commitAtt is null) return;

        var item = _approvalItems.FirstOrDefault(i =>
            string.Equals(i.CommitSha, commitAtt.CommitSha, StringComparison.OrdinalIgnoreCase) ||
            (commitAtt.CommitSha.Length >= 7 && i.CommitSha.StartsWith(commitAtt.CommitSha, StringComparison.OrdinalIgnoreCase)));
        if (item is not null)
            ScrollToApprovalTurn(item);
    }

    private void FollowUpDismissBtn_Click(object sender, RoutedEventArgs e)
    {
        _followUpAttachments.Remove(_activeTabId ?? "");
        UpdateFollowUpStrip();
        SyncQueuePanel();
        if (_activeTabId is null) PersistDraftFollowUp();
    }

    /// <summary>
    /// Saves (or clears) the active-draft follow-up attachments to workspace settings
    /// so they survive a restart.  Only the draft slot (key "") is persisted; queue items
    /// are not persisted because the queue itself does not survive a restart.
    /// </summary>
    private void PersistDraftFollowUp()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        if (_followUpAttachments.TryGetValue("", out var list) && list.Count > 0)
        {
            var dtos = list.Select(a => new FollowUpAttachmentDto
            {
                CommitSha        = a.CommitSha,
                Description      = a.Description,
                OriginalPrompt   = a.OriginalPrompt,
                TranscriptQuote  = a.TranscriptQuote,
                ContentBlock     = a.ContentBlock,
                ImagePath        = a.ImagePath,
                ImageSubmittedAt = a.ImageSubmittedAt?.ToString("O"),
            }).ToList();
            _docsPanelState = state with
            {
                DraftFollowUpsJson          = System.Text.Json.JsonSerializer.Serialize(dtos),
                DraftFollowUpCommitSha      = null,
                DraftFollowUpDescription    = null,
                DraftFollowUpOriginalPrompt = null,
            };
        }
        else
        {
            // "[]" is the sentinel for "explicitly cleared". We cannot use null here because
            // SaveDocsPanelState uses (new ?? existing) merge logic — null would cause the
            // stale JSON from a previous save to survive and re-show follow-ups on restart.
            _docsPanelState = state with
            {
                DraftFollowUpsJson          = "[]",
                DraftFollowUpCommitSha      = null,
                DraftFollowUpDescription    = null,
                DraftFollowUpOriginalPrompt = null,
            };
        }
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private string ApplyFollowUpHeader(string text, string tabId)
    {
        _pendingTranscriptAttachments = null;
        if (!_followUpAttachments.TryGetValue(tabId, out var list) || list.Count == 0)
            return text;

        // Stamp SubmittedAt for image attachments and rebuild the list with the timestamp set.
        var submittedAt = DateTime.UtcNow;
        var stamped = new List<FollowUpAttachment>(list.Count);
        foreach (var att in list)
        {
            if (att.ImagePath != null)
            {
                _pastedImageStore.SetSubmittedAt(att.ImagePath, submittedAt);
                stamped.Add(att with { ImageSubmittedAt = submittedAt });
            }
            else
            {
                stamped.Add(att);
            }
        }

        _pendingTranscriptAttachments = stamped;
        _followUpAttachments.Remove(tabId);
        if (tabId == "") PersistDraftFollowUp();
        UpdateFollowUpStrip();

        var headers = stamped.Select(att =>
        {
            if (att.ImagePath != null)
                return $"[Attached image: {att.ImagePath}]";
            if (att.ContentBlock != null)
                return att.ContentBlock;
            if (att.TranscriptQuote != null)
                return $"Regarding this section of the transcript: \"{att.TranscriptQuote}\"";
            var summaryHint = att.OriginalPrompt is { Length: > 0 } op
                ? (op.Length > 120 ? op[..120] + "…" : op)
                : att.Description;
            return $"[Follow-up on {att.CommitSha} — \"{att.Description}\": {summaryHint}]";
        }).ToList();

        // When the user has any typed content/image attachments, prepend a one-liner so the AI
        // understands the structure it is receiving.
        bool hasTypedBlocks = stamped.Any(a => a.ContentBlock != null || a.ImagePath != null);
        const string AttachmentPreamble = "The user has attached the following context items. Please refer to them as needed.";
        var headerSection = string.Join("\n", headers);
        return (hasTypedBlocks ? AttachmentPreamble + "\n" + headerSection : headerSection) + "\n\n" + text;
    }

    private void PersistApprovalPanelVisible()    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { ApprovalPanelVisible = _approvalPanelVisible };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    // ── Notes panel ───────────────────────────────────────────────────────────

    private void SyncNotesPanel()
    {
        if (NotesPanelBorder is null) return;
        NotesPanelBorder.Visibility = _notesPanelVisible ? Visibility.Visible : Visibility.Collapsed;
        if (_notesPanelVisible && _notesPanel is null)
        {
            _notesPanel = new NotesPanelController(
                listPanel:           NotesListPanel!,
                scrollContainer:     (FrameworkElement)NotesListPanel!.Parent,
                openNote:            note => OpenNote(note),
                editNote:            note => EditNote(note),
                renameNote:          (note, title) => RenameNote(note, title),
                deleteNote:          note => DeleteNote(note),
                newNote:             () => CreateNewNote(),
                attachFollowUp:      note => AttachNoteFollowUp(note),
                loadPreview:         note => _notesStore?.LoadContent(note.Id) ?? "",
                initialSortOrder:    _docsPanelState?.NotesSortOrder ?? NotesSortOrder.MostRecentOnTop,
                onSortOrderChanged:  order => {
                    var st = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
                    _docsPanelState = st with { NotesSortOrder = order };
                    _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
                });
            _notesPanel.Refresh(_noteItems);
        }
    }

    private void PersistNotesPanelVisible()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { NotesPanelVisible = _notesPanelVisible };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void SyncMaintenancePanel()
    {
        if (MaintenancePanelBorder is null) return;
        MaintenancePanelBorder.Visibility = _maintenancePanelVisible ? Visibility.Visible : Visibility.Collapsed;
        if (!_maintenancePanelVisible) return;

        var workspacePath = _currentWorkspace?.FolderPath;
        if (workspacePath is null) return;

        if (_maintenancePanel is null)
        {
            _maintenancePanel = new MaintenancePanelController(
                listPanel:              MaintenanceTaskListPanel,
                statusLabel:            MaintenanceStatusLabel,
                runNowButton:           MaintenanceRunNowButton,
                enabledOnIdleCheckBox:  MaintenanceEnabledOnIdleCheckBox,
                getWorkspacePath:       () => _currentWorkspace?.FolderPath,
                runNow:                 () => _ = StartMaintenanceCycleAsync(),
                toggleTaskEnabled:      (taskId, enabled) => OnMaintenanceTaskToggled(taskId, enabled),
                reloadPanel:            () => OnMaintenanceTaskToggled(string.Empty, false));
        }

        var config = MaintenanceMdParser.Parse(Path.Combine(workspacePath, ".squad", "maintenance.md"));
        _maintenancePanel.Refresh(config, _maintenanceStateStore);
    }

    private void PersistMaintenancePanelVisible()
    {
        var state = _docsPanelState ?? _settingsStore.GetDocsPanelState(_currentWorkspace?.FolderPath);
        _docsPanelState = state with { MaintenancePanelVisible = _maintenancePanelVisible };
        _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
    }

    private void DismissMaintenanceBadge()
    {
        if (MaintenancePanelBadge is not null)
            MaintenancePanelBadge.Visibility = Visibility.Collapsed;
    }

    private void DismissMaintenanceBanner()
    {
        _maintenanceBannerTimer?.Stop();
        _maintenanceBannerTimer = null;
        _pendingMaintenanceBannerReport = null;
        if (MaintenanceBannerBorder is not null)
            MaintenanceBannerBorder.Visibility = Visibility.Collapsed;
        DismissMaintenanceBadge();
    }

    private void ShowMaintenanceBannerIfPending()
    {
        if (_pendingMaintenanceBannerReport is null) return;
        var report = _pendingMaintenanceBannerReport;

        int ran     = report.RanTaskIds?.Count  ?? 0;
        int skipped = report.SkippedTaskIds?.Count ?? 0;
        string summary = ran == 1
            ? $"Maintenance complete — {ran} task ran, {skipped} skipped."
            : $"Maintenance complete — {ran} tasks ran, {skipped} skipped.";

        if (MaintenanceBannerTextBlock is not null)
            MaintenanceBannerTextBlock.Text = summary;
        if (MaintenanceBannerBorder is not null)
            MaintenanceBannerBorder.Visibility = Visibility.Visible;

        _maintenanceBannerTimer?.Stop();
        _maintenanceBannerTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(12) };
        _maintenanceBannerTimer.Tick += (_, _) => DismissMaintenanceBanner();
        _maintenanceBannerTimer.Start();
    }

    private void OnMaintenanceTaskToggled(string taskId, bool enabled)
    {
        var workspacePath = _currentWorkspace?.FolderPath;
        if (workspacePath is null || _maintenancePanel is null) return;
        var config = MaintenanceMdParser.Parse(Path.Combine(workspacePath, ".squad", "maintenance.md"));
        _maintenancePanel.Refresh(config, _maintenanceStateStore);
    }

    private async Task StartMaintenanceCycleAsync()
    {
        var workspacePath = _currentWorkspace?.FolderPath;
        if (workspacePath is null) return;
        if (ShouldSuppressMaintenanceCycle())
            return;

        var config = MaintenanceMdParser.Parse(Path.Combine(workspacePath, ".squad", "maintenance.md"));
        if (config is null || !config.EnabledOnIdle) return;
        if (config.Tasks is not { Count: > 0 } tasks || !tasks.Any(task => task.Enabled))
            return;

        _maintenanceStateStore ??= new MaintenanceStateStore(Path.Combine(workspacePath, ".squad"));
        _maintenanceReportWriter = new MaintenanceReportWriter(workspacePath);

        if (_maintenanceRunner?.IsRunning == true) return;

        _maintenanceRunner = new MaintenanceRunner(
            executePromptAsync: (prompt, ct) =>
            {
                var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
                Dispatcher.InvokeAsync(async () =>
                {
                    try
                    {
                        await _pec.ExecutePromptAsync(prompt, addToHistory: true, clearPromptBox: false, sessionIdOverride: "argus-weld");
                        tcs.TrySetResult();
                    }
                    catch (OperationCanceledException) { tcs.TrySetCanceled(ct); }
                    catch (Exception ex) { tcs.TrySetException(ex); }
                });
                return tcs.Task;
            },
            stateStore:      _maintenanceStateStore,
            onTaskStarted:   title => Dispatcher.InvokeAsync(() => {
                EnsureArgusWeldRegistered(workspacePath);
                _maintenancePanel?.OnRunnerStarted(title);
            }),
            onTaskCompleted: id    => Dispatcher.InvokeAsync(() => _maintenancePanel?.OnRunnerCompleted()),
            onCompleted:     report =>
            {
                Dispatcher.InvokeAsync(async () =>
                {
                    var reportPath = _maintenanceReportWriter?.WriteReport(report) ?? "";
                    _pendingMaintenanceBannerReport = report;
                    if (MaintenancePanelBadge is not null)
                        MaintenancePanelBadge.Visibility = Visibility.Visible;
                    _idleDetectionService?.SetRunnerActive(false);
                    _maintenancePanel?.OnRunnerCompleted();
                    var updatedConfig = MaintenanceMdParser.Parse(Path.Combine(workspacePath, ".squad", "maintenance.md"));
                    _maintenancePanel?.Refresh(updatedConfig, _maintenanceStateStore);
                    // Banner surfaces on next user activity (see Window_PreviewKeyDown).
                    var notifBody = !string.IsNullOrEmpty(report.Summary)
                        ? report.Summary
                        : $"{report.TaskResults.Count} task(s) completed";
                    await _pushNotificationService.NotifyEventAsync(
                        "maintenance_completed",
                        "Argus Weld — Maintenance Complete",
                        notifBody);
                });
            });

        _idleDetectionService?.SetRunnerActive(true);
        _maintenancePanel?.OnRunnerStarted("starting…");

        await _maintenanceRunner.StartAsync(config, workspacePath, CancellationToken.None);
    }

    private bool ShouldSuppressMaintenanceCycle()
    {
        if (_isPromptRunning)
            return true;

        if (IsLoopRunning || _loopController.IsRunning || _loopQueued)
            return true;

        if (_promptQueue.Count > 0 && !_queueManuallyPaused)
            return true;

        if (_backgroundTaskPresenter.HasBackgroundTasks())
            return true;

        if (HasPendingDirectQuickReplyAgentFollowUp() || _loopFollowUpTcs is not null)
            return true;

        return false;
    }

    private void EnsureArgusWeldRegistered(string workspacePath) {
        const string threadKey = "agent:argus-weld";
        if (_agentThreadRegistry.ThreadsByKey.ContainsKey(threadKey))
            return;

        string? charter = null;
        var charterPath = Path.Combine(workspacePath, ".squad", "agents", "argus-weld", "charter.md");
        if (File.Exists(charterPath)) {
            try { charter = File.ReadAllText(charterPath); }
            catch { /* best-effort */ }
        }

        _agentThreadRegistry.GetOrCreateAgentThread(
            toolCallId:       null,
            agentId:          "argus-weld",
            agentName:        "argus-weld",
            agentDisplayName: "Argus Weld",
            agentDescription: "Maintenance Coordinator",
            status:           null,
            prompt:           charter,
            startedAt:        DateTimeOffset.UtcNow.ToString("O"));
    }

    private void InitIdleDetection(string workspacePath) {
        _maintenanceStateStore ??= new MaintenanceStateStore(Path.Combine(workspacePath, ".squad"));

        var config = MaintenanceMdParser.Parse(Path.Combine(workspacePath, ".squad", "maintenance.md"));
        double thresholdMinutes = config?.IdleTimeout ?? 15.0;

        _idleDetectionService = new IdleDetectionService();
        _idleDetectionService.IdleThresholdReached += () =>
            Dispatcher.InvokeAsync(() => _ = StartMaintenanceCycleAsync());
        _idleDetectionService.Start(thresholdMinutes);
    }

    private void StopIdleDetection()
    {
        _idleDetectionService?.Stop();
        _idleDetectionService = null;
    }

    private void OpenNote(NoteItem note)
    {
        if (_notesStore is null) return;
        var path = _notesStore.GetNotePath(note.Id);
        if (!File.Exists(path)) return;
        var liveNote = note;
        MarkdownDocumentWindow.Show(
            CanShowOwnedWindow() ? this : null,
            liveNote.Title,
            path,
            showSource: true,
            BuildMarkdownCaptureContext(),
            autoSave: true,
            noteContext: new NoteEditContext(
                InitialTitle: liveNote.Title,
                OnTitleCommit: newTitle => RenameNote(liveNote, newTitle)));
    }

    private void EditNote(NoteItem note)
    {
        if (_notesStore is null) return;
        var path = _notesStore.GetNotePath(note.Id);
        if (!File.Exists(path)) return;
        var liveNote = note;
        MarkdownDocumentWindow.Show(
            CanShowOwnedWindow() ? this : null,
            liveNote.Title,
            path,
            showSource: true,
            BuildMarkdownCaptureContext(),
            autoSave: true,
            noteContext: new NoteEditContext(
                InitialTitle: liveNote.Title,
                OnTitleCommit: newTitle => RenameNote(liveNote, newTitle)));
    }

    private void CreateNewNote()
    {
        if (_notesStore is null) return;
        var note = new NoteItem(Guid.NewGuid(), "New Note", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
        _notesStore.WriteContent(note.Id, string.Empty);
        _noteItems.Insert(0, note);
        _notesStore.SaveAll(_noteItems);

        if (!_notesPanelVisible)
        {
            _notesPanelVisible = true;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = true;
            PersistNotesPanelVisible();
        }
        else
        {
            _notesPanel?.AddNote(note);
        }

        // Open with source visible so the user can start typing immediately.
        var path = _notesStore.GetNotePath(note.Id);
        MarkdownDocumentWindow.Show(
            CanShowOwnedWindow() ? this : null,
            note.Title,
            path,
            showSource: true,
            BuildMarkdownCaptureContext(),
            autoSave: true,
            noteContext: new NoteEditContext(
                InitialTitle: note.Title,
                OnTitleCommit: newTitle => RenameNote(note, newTitle)));
    }

    private void RenameNote(NoteItem oldNote, string newTitle)
    {
        var idx = _noteItems.FindIndex(n => n.Id == oldNote.Id);
        if (idx < 0 || _notesStore is null) return;
        var updated = oldNote with { Title = newTitle };
        _noteItems[idx] = updated;
        _notesStore.SaveAll(_noteItems);
        _notesPanel?.Refresh(_noteItems);
    }

    private void DeleteNote(NoteItem note)
    {
        if (_notesStore is null) return;
        _noteItems.RemoveAll(n => n.Id == note.Id);
        _notesStore.DeleteContent(note.Id);
        _notesStore.SaveAll(_noteItems);
        _notesPanel?.Refresh(_noteItems);
    }

    private void AddNoteFromText(string text)
    {
        if (_notesStore is null) return;
        var title = NotesStore.DeriveTitle(text);
        var note  = new NoteItem(Guid.NewGuid(), title, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
        _notesStore.WriteContent(note.Id, text);
        _noteItems.Insert(0, note);
        _notesStore.SaveAll(_noteItems);

        // Show the panel if hidden
        if (!_notesPanelVisible)
        {
            _notesPanelVisible = true;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = true;
            PersistNotesPanelVisible();
        }
        else
        {
            _notesPanel?.AddNote(note);
        }
    }

    private void AddNoteFromTextWithTitle(string title, string text)
    {
        if (_notesStore is null) return;
        var note = new NoteItem(Guid.NewGuid(), title, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
        _notesStore.WriteContent(note.Id, text);
        _noteItems.Insert(0, note);
        _notesStore.SaveAll(_noteItems);

        // Show the panel if hidden
        if (!_notesPanelVisible)
        {
            _notesPanelVisible = true;
            SyncNotesPanel();
            if (ViewNotesMenuItem is not null)
                ViewNotesMenuItem.IsChecked = true;
            PersistNotesPanelVisible();
        }
        else
        {
            _notesPanel?.AddNote(note);
        }
    }

    private void ApprovalPanelCloseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _approvalPanelVisible = false;
            SyncApprovalPanel();
            if (ViewCommitApprovalsMenuItem is not null)
                ViewCommitApprovalsMenuItem.IsChecked = false;
            PersistApprovalPanelVisible();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ApprovalPanelCloseButton_Click), ex); }
    }

    private void ApprovalFilterBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        try
        {
            var text = ApprovalFilterBox?.Text ?? string.Empty;
            if (ApprovalFilterClearButton is not null)
                ApprovalFilterClearButton.Visibility = text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
            _approvalPanel?.SetFilter(text);
            _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with { ApprovalsPanelFilter = text.Length > 0 ? text : string.Empty };
            _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ApprovalFilterBox_TextChanged), ex); }
    }

    private void ApprovalFilterClearButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (ApprovalFilterBox is not null)
            {
                ApprovalFilterBox.Text = string.Empty;
                ApprovalFilterBox.Focus();
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ApprovalFilterClearButton_Click), ex); }
    }

    private void TasksFilterBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        try
        {
            var text = TasksFilterBox?.Text ?? string.Empty;
            if (TasksFilterClearButton is not null)
                TasksFilterClearButton.Visibility = text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
            // Update IntelliSense first — a character like space may dismiss it (returning null).
            // The suppression check below must see the post-update state so that typing e.g. "@me "
            // doesn't spuriously show all tasks due to stale open-popup state.
            if (TasksFilterBox is not null)
                TryUpdateTasksIntelliSense();
            // While @ IntelliSense is open, pass the live typed text so the Tasks panel can do
            // prefix-based owner filtering (e.g. "@v" shows all tasks owned by any agent whose
            // handle starts with "v"). Once the user accepts a suggestion the full resolved handle
            // is committed and the exact filter takes over.
            string filterText = text;
            _tasksPanelController?.SetFilter(filterText);
            ScheduleLoopPreviewRefreshFromFilter();
            _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with { TasksPanelFilter = text.Length > 0 ? text : string.Empty };
            _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(TasksFilterBox_TextChanged), ex); }
    }

    private void TasksFilterBox_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        try
        {
            if (_intelliSenseState is null || _intelliSenseOwnerBox != TasksFilterBox) return;
            switch (e.Key)
            {
                case Key.Up:
                    _intelliSenseState = IntelliSenseController.MoveSelection(_intelliSenseState, -1);
                    UpdateIntelliSensePopup();
                    e.Handled = true;
                    break;
                case Key.Down:
                    _intelliSenseState = IntelliSenseController.MoveSelection(_intelliSenseState, +1);
                    UpdateIntelliSensePopup();
                    e.Handled = true;
                    break;
                case Key.Return:
                case Key.Tab:
                    ApplyIntelliSenseAccept(andSubmit: false);
                    e.Handled = true;
                    break;
                case Key.Escape:
                    _intelliSenseState = null;
                    _intelliSenseOwnerBox = null;
                    UpdateIntelliSensePopup();
                    // IntelliSense was suppressing the filter; now apply it with current text.
                    _tasksPanelController?.SetFilter(TasksFilterBox.Text.Trim());
                    e.Handled = true;
                    break;
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(TasksFilterBox_PreviewKeyDown), ex); }
    }



    private void TasksFilterClearButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (TasksFilterBox is not null)
            {
                TasksFilterBox.Text = string.Empty;
                TasksFilterBox.Focus();
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(TasksFilterClearButton_Click), ex); }
    }

    private async void TasksPanelDoTheseButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var workspaceFolder = _currentWorkspace?.FolderPath;
            if (string.IsNullOrEmpty(workspaceFolder)) return;

            // Shift+click: show a "Do these with…" context menu listing all loop files.
            if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0)
            {
                ShowDoTheseWithMenu(sender as FrameworkElement);
                return;
            }

            // If the loop is already running, don't start another one
            if (IsLoopRunning)
            {
                MessageBox.Show(
                    "A loop is already running. Wait for the current loop to stop before starting a new one.",
                    "Loop Already Running",
                    MessageBoxButton.OK,
                    MessageBoxImage.Information);
                return;
            }

            var loopFilePath = Path.Combine(workspaceFolder, ".squad", "loop-filtered-tasks.md");
            if (!File.Exists(loopFilePath)) return;

            // When the loop panel is visible and the current loop is not the filtered-tasks loop,
            // show a picker so the user can choose which loop to run.
            if (_loopPanelVisible && !IsFilteredTasksLoopSelected())
            {
                var dialog = new LoopPickerDialog(
                    currentLoopPath:   _selectedLoopMdPath ?? loopFilePath,
                    currentLoopLabel:  GetCurrentLoopLabel(),
                    filteredTasksPath: loopFilePath,
                    otherLoops:        GetOtherLoopEntries(loopFilePath),
                    owner:             this);
                if (dialog.ShowDialog() != true) return;

                if (!string.Equals(dialog.SelectedLoopPath, _selectedLoopMdPath, StringComparison.OrdinalIgnoreCase))
                {
                    _selectedLoopMdPath = dialog.SelectedLoopPath;
                    PersistLoopFileSelection();
                    _loopPanelVisible = true;
                    SyncLoopPanel();
                    PopulateLoopFilePicker();
                    RefreshLoopOptionsPanel();
                }
            }
            else
            {
                // Select the filtered-tasks loop file and persist the choice
                _selectedLoopMdPath = loopFilePath;
                PersistLoopFileSelection();

                // Ensure the loop panel is visible and fully refreshed
                _loopPanelVisible = true;
                SyncLoopPanel();
                PopulateLoopFilePicker();
                RefreshLoopOptionsPanel();
            }

            await QueueOrStartLoopAsync(LoopMode.NativeAgents);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(TasksPanelDoTheseButton_Click), ex); }
    }

    private bool IsFilteredTasksLoopSelected()
    {
        if (_selectedLoopMdPath is null) return false;
        return string.Equals(
            Path.GetFileName(_selectedLoopMdPath),
            "loop-filtered-tasks.md",
            StringComparison.OrdinalIgnoreCase);
    }

    private string GetCurrentLoopLabel()
    {
        var entry = GetSelectedLoopFileEntry();
        if (entry is not null) return entry.DisplayName;
        if (_selectedLoopMdPath is not null) return Path.GetFileName(_selectedLoopMdPath);
        return "loop.md";
    }

    private IReadOnlyList<LoopFileEntry> GetOtherLoopEntries(string filteredTasksPath)
    {
        if (_currentWorkspace is null) return Array.Empty<LoopFileEntry>();
        var allEntries = LoopMdParser.ScanForLoopFiles(_currentWorkspace.SquadFolderPath);
        var result = new List<LoopFileEntry>();
        foreach (var entry in allEntries)
        {
            if (string.Equals(entry.FilePath, _selectedLoopMdPath, StringComparison.OrdinalIgnoreCase))
                continue;
            if (string.Equals(entry.FilePath, filteredTasksPath, StringComparison.OrdinalIgnoreCase))
                continue;
            result.Add(entry);
        }
        return result;
    }

    private void ShowDoTheseWithMenu(FrameworkElement? anchor)
    {
        if (_currentWorkspace is null) return;
        var entries = LoopMdParser.ScanForLoopFiles(_currentWorkspace.SquadFolderPath);
        if (entries.Count == 0) return;

        var menu = new ContextMenu();
        menu.SetResourceReference(ContextMenu.StyleProperty, "ThemedContextMenuStyle");

        var header = new MenuItem {
            Header = "Do these with:",
            IsEnabled = false,
        };
        header.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
        menu.Items.Add(header);

        menu.Items.Add(new Separator { Style = (Style)FindResource("ThemedMenuSeparatorStyle") });

        foreach (var entry in entries)
        {
            var capturedPath = entry.FilePath;
            var item = new MenuItem { Header = entry.DisplayName };
            item.SetResourceReference(MenuItem.StyleProperty, "ThemedMenuItemStyle");
            if (!string.IsNullOrEmpty(entry.TooltipText))
                item.ToolTip = entry.TooltipText;
            item.Click += async (_, _) =>
            {
                try
                {
                    _selectedLoopMdPath = capturedPath;
                    PersistLoopFileSelection();
                    _loopPanelVisible = true;
                    SyncLoopPanel();
                    PopulateLoopFilePicker();
                    RefreshLoopOptionsPanel();
                    await StartLoopImmediateAsync();
                }
                catch (Exception ex) { HandleUiCallbackException("DoTheseWithMenu.Click", ex); }
            };
            menu.Items.Add(item);
        }

        if (anchor != null)
        {
            menu.PlacementTarget = anchor;
            menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
        }
        menu.IsOpen = true;
    }

    private void NotesFilterBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        try
        {
            var text = NotesFilterBox?.Text ?? string.Empty;
            if (NotesFilterClearButton is not null)
                NotesFilterClearButton.Visibility = text.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
            _notesPanel?.SetFilter(text);
            _docsPanelState = (_docsPanelState ?? new WorkspaceDocsPanelState()) with { NotesPanelFilter = text.Length > 0 ? text : string.Empty };
            _settingsSnapshot = _settingsStore.SaveDocsPanelState(_currentWorkspace?.FolderPath, _docsPanelState);
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(NotesFilterBox_TextChanged), ex); }
    }

    private void NotesFilterClearButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            if (NotesFilterBox is not null)
            {
                NotesFilterBox.Text = string.Empty;
                NotesFilterBox.Focus();
            }
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(NotesFilterClearButton_Click), ex); }
    }

    private void ApprovalClearAllButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            _approvalPanel?.OnClearApprovedClicked();
        }
        catch (Exception ex) { HandleUiCallbackException(nameof(ApprovalClearAllButton_Click), ex); }
    }

    private void RefreshTasksStatusWindow(DateTimeOffset now)
    {
        if (_tasksStatusWindow is null)
            return;

        _tasksStatusWindow.UpdateContent(_backgroundTaskPresenter.BuildBackgroundTaskReport(now));
        // Do NOT reposition here — content updates must not move a user-placed window.
    }

    // ── Floating-window positioning ──────────────────────────────────────────────────────────

    /// <summary>
    /// Called when the main window moves. Translates all floating windows by the same delta
    /// so they maintain their relative position. After the move, validates each window is
    /// on-screen; off-screen windows are snapped back to the default position.
    /// </summary>
    private void OnMainWindowMoved()
    {
        PositionTasksStatusWindow();
        PositionTraceWindow();
        PositionScreenshotHealthWindow();
        ValidateFloatingWindowPosition(ref _tasksWindowOffset, _tasksStatusWindow);
        ValidateFloatingWindowPosition(ref _traceWindowOffset, _traceWindow);
        ValidateFloatingWindowPosition(ref _screenshotHealthWindowOffset, _screenshotHealthWindow);
    }

    /// <summary>
    /// Returns the Left/Top of the default snap position for a floating window relative
    /// to the main window's upper-right corner.
    /// </summary>
    private (double left, double top) DefaultFloatingWindowSnap(Window floater, double topOffset = 0)
    {
        const double margin = 18;
        // Use actual on-screen bounds so the snap is correct even when the main window is maximized.
        // WPF's Left/Top return the restore position when maximized, which would produce wrong coords.
        var mainBounds = NativeMethods.GetActualWindowBoundsLogical(this);
        double mainLeft  = mainBounds.Width > 0 ? mainBounds.Left  : Left;
        double mainTop   = mainBounds.Width > 0 ? mainBounds.Top   : Top;
        double mainWidth = mainBounds.Width > 0 ? mainBounds.Width : (ActualWidth > 0 ? ActualWidth : Width);
        double left = mainLeft + Math.Max(margin, mainWidth - floater.Width - margin);
        double top  = mainTop  + SystemParameters.WindowCaptionHeight + margin + topOffset;
        return (left, top);
    }

    /// <summary>
    /// Moves <paramref name="floater"/> to the position described by <paramref name="offset"/>
    /// (offset from main window's Right/Top), or to the default snap position if offset is null.
    /// </summary>
    private void ApplyFloatingWindowPosition(Window floater, Vector? offset, double defaultTopOffset = 0)
    {
        double newLeft, newTop;
        if (offset is { } off)
        {
            // Use actual on-screen bounds: WPF Left/Top return restore coords when maximized.
            var mainBounds = NativeMethods.GetActualWindowBoundsLogical(this);
            double mainRight = mainBounds.Width > 0
                ? mainBounds.Left + mainBounds.Width
                : Left + (ActualWidth > 0 ? ActualWidth : Width);
            double mainTop = mainBounds.Width > 0 ? mainBounds.Top : Top;
            newLeft = mainRight + off.X;
            newTop  = mainTop   + off.Y;

            // Guard: if the saved offset was computed on a different monitor layout (e.g.
            // a narrower display before the user switched to an ultra-wide), the window
            // may land off-screen.  Fall back to the default snap so it's always visible.
            double cx = newLeft + (floater.ActualWidth > 0 ? floater.ActualWidth : floater.Width) / 2;
            double cy = newTop  + (floater.ActualHeight > 0 ? floater.ActualHeight : floater.Height) / 2;
            if (!IsLogicalPositionOnAnyMonitor(cx, cy))
                (newLeft, newTop) = DefaultFloatingWindowSnap(floater, defaultTopOffset);
        }
        else
        {
            (newLeft, newTop) = DefaultFloatingWindowSnap(floater, defaultTopOffset);
        }

        _movingFloatingWindow = true;
        try
        {
            floater.Left = newLeft;
            floater.Top  = newTop;
            // Final safety clamp: ensure the entire window is within a monitor's work area.
            WindowPlacementHelper.EnsureOnScreen(floater, this);
        }
        finally
        {
            _movingFloatingWindow = false;
        }
    }

    /// <summary>
    /// Returns <c>true</c> if the WPF logical coordinate (<paramref name="logicalX"/>,
    /// <paramref name="logicalY"/>) falls on any connected monitor.
    /// Converts to physical pixels using the main window's DPI transform so that the
    /// check is correct under per-monitor DPI scaling.
    /// </summary>
    private bool IsLogicalPositionOnAnyMonitor(double logicalX, double logicalY)
    {
        var src = PresentationSource.FromVisual(this);
        var m   = src?.CompositionTarget?.TransformToDevice ?? Matrix.Identity;
        int physX = (int)(logicalX * m.M11);
        int physY = (int)(logicalY * m.M22);
        return NativeMethods.IsRectOnAnyMonitor(physX, physY, physX + 1, physY + 1);
    }

    /// <summary>
    /// Checks whether the centre of <paramref name="floater"/> falls on any monitor and the
    /// window is fully within that monitor. If not, resets the saved offset to null (which
    /// causes the next position call to snap back to the default).
    /// </summary>
    private void ValidateFloatingWindowPosition(ref Vector? offset, Window? floater)
    {
        if (floater is not { IsLoaded: true })
            return;

        double cx = floater.Left + (floater.ActualWidth > 0 ? floater.ActualWidth : floater.Width) / 2;
        double cy = floater.Top  + (floater.ActualHeight > 0 ? floater.ActualHeight : floater.Height) / 2;

        if (!IsLogicalPositionOnAnyMonitor(cx, cy))
            offset = null;
    }

    private void PositionTasksStatusWindow()
    {
        if (_tasksStatusWindow is not { IsLoaded: true } || WindowState == WindowState.Minimized)
            return;

        ApplyFloatingWindowPosition(_tasksStatusWindow, _tasksWindowOffset);
    }

    private void PositionTraceWindow()
    {
        if (_traceWindow is not { IsLoaded: true } || WindowState == WindowState.Minimized)
            return;

        // If tasks window is at default position, stack trace below it.
        double defaultTopOffset = _tasksWindowOffset is null && _tasksStatusWindow is { IsLoaded: true }
            ? _tasksStatusWindow.Height + 18
            : 0;
        ApplyFloatingWindowPosition(_traceWindow, _traceWindowOffset, defaultTopOffset);
    }

    /// <summary>
    /// Records the floating window's position as an offset from the main window's top-right
    /// corner whenever the user moves it (i.e. not a programmatic move).
    /// </summary>
    private void OnFloatingWindowMoved(Window floater, ref Vector? offsetField)
    {
        if (_movingFloatingWindow)
            return;

        double mainRight = Left + (ActualWidth > 0 ? ActualWidth : Width);
        offsetField = new Vector(floater.Left - mainRight, floater.Top - Top);
    }

    private void OnTasksWindowMoved()
    {
        if (_tasksStatusWindow is not null)
            OnFloatingWindowMoved(_tasksStatusWindow, ref _tasksWindowOffset);
    }

    private void OnTraceWindowMoved()
    {
        if (_traceWindow is not null)
            OnFloatingWindowMoved(_traceWindow, ref _traceWindowOffset);
    }

    private void PositionScreenshotHealthWindow()
    {
        if (_screenshotHealthWindow is not { IsLoaded: true } || WindowState == WindowState.Minimized)
            return;

        ApplyFloatingWindowPosition(_screenshotHealthWindow, _screenshotHealthWindowOffset);
    }

    private void OnScreenshotHealthWindowMoved()
    {
        if (_screenshotHealthWindow is not null)
            OnFloatingWindowMoved(_screenshotHealthWindow, ref _screenshotHealthWindowOffset);
    }

    private void ShowScreenshotOverlay()
    {
        if (!CanShowOwnedWindow())
            return;

        var saveDir = Path.Combine(_workspacePaths.ScreenshotsDirectory, "baseline");
        var overlay = new ScreenshotOverlayWindow(this, saveDir, _activeThemeName, _settingsSnapshot.SpeechRegion ?? string.Empty);
        overlay.ScreenshotSaved += OnInteractiveCaptureCompleted;
        overlay.ScreenshotFailed += (_, error) => Dispatcher.InvokeAsync(() =>
            AppendLine($"[screenshot error] {error}", ThemeBrush("SystemErrorText")));
        overlay.Closed += (_, _) => ResetPttState();
        overlay.Show();
    }

    /// <summary>
    /// Opens the screenshot overlay pre-wired to save the result into a specific doc
    /// image placeholder.  On completion, copies the captured PNG to the placeholder
    /// path, strips the 📸 blockquote from the markdown, reloads the viewer, and
    /// registers the definition with <see cref="DocImagePath"/> set so that the
    /// automated refresh runner can update it later.
    /// </summary>
    private void CaptureScreenshotForDocPlaceholder(string imagePath)
    {
        if (!CanShowOwnedWindow()) return;
        if (string.IsNullOrEmpty(_currentDocPath)) return;

        var saveDir     = Path.Combine(_workspacePaths.ScreenshotsDirectory, "baseline");
        var initialDesc = ExtractDocImageDescription(_currentDocPath, imagePath);
        var overlay     = new ScreenshotOverlayWindow(this, saveDir, _activeThemeName, _settingsSnapshot.SpeechRegion ?? string.Empty, initialDesc);

        overlay.ScreenshotSaved += (sender, e) =>
        {
            _ = RunInteractiveCaptureAsync(e, docImagePath: imagePath);
        };
        overlay.ScreenshotFailed += (_, error) => Dispatcher.InvokeAsync(() =>
            AppendLine($"[screenshot error] {error}", ThemeBrush("SystemErrorText")));
        overlay.Closed += (_, _) => ResetPttState();
        overlay.Show();
    }

    /// <summary>
    /// Returns a pre-fill description for the screenshot overlay by reading
    /// <paramref name="docPath"/> and extracting, in preference order:
    /// (1) the 📸 blockquote description on the line immediately after the image tag, or
    /// (2) the alt text from the image tag itself.
    /// Returns an empty string if neither is found or if the file cannot be read.
    /// </summary>
    private static string ExtractDocImageDescription(string docPath, string imagePath)
    {
        if (!File.Exists(docPath)) return string.Empty;

        string text;
        try { text = File.ReadAllText(docPath); }
        catch { return string.Empty; }

        var normalizedTarget = imagePath.Replace('\\', '/');
        var lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');

        for (var i = 0; i < lines.Length; i++)
        {
            if (!lines[i].Replace('\\', '/').Contains(normalizedTarget)) continue;

            // Prefer the 📸 blockquote description on the next line.
            if (i + 1 < lines.Length)
            {
                var next = lines[i + 1].Trim();
                if (next.Contains("📸") || next.Contains("Screenshot needed"))
                {
                    // Strip "> 📸 *Screenshot needed: " prefix and trailing "*"
                    var stripped = System.Text.RegularExpressions.Regex.Replace(
                        next, @"^>\s*📸\s*\*?Screenshot needed:\s*", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).TrimEnd('*').Trim();
                    if (!string.IsNullOrWhiteSpace(stripped)) return stripped;
                }
            }

            // Fall back to alt text.
            var altMatch = System.Text.RegularExpressions.Regex.Match(
                lines[i], @"!\[([^\]]*)\]\(" + System.Text.RegularExpressions.Regex.Escape(normalizedTarget) + @"\)");
            if (altMatch.Success)
                return altMatch.Groups[1].Value.Trim();

            break;
        }

        return string.Empty;
    }

    // ── Interactive capture completion ───────────────────────────────────────

    /// <summary>
    /// Fired by <see cref="ScreenshotOverlayWindow.ScreenshotSaved"/> after the PNG
    /// has been provisionally saved.  Runs the full post-capture pipeline:
    /// edge-anchor warnings, name suggestion, manifest build + save, definition
    /// registry upsert, PNG rename, and transcript confirmation.
    /// </summary>
    private async void OnInteractiveCaptureCompleted(object? sender, ScreenshotSavedEventArgs e)
        => await RunInteractiveCaptureAsync(e, docImagePath: null);

    /// <summary>
    /// Core post-capture pipeline shared by interactive captures and doc-placeholder captures.
    /// When <paramref name="docImagePath"/> is non-null, also copies the final PNG to the doc
    /// image location, strips the 📸 placeholder from the markdown, and reloads the viewer.
    /// </summary>
    private async Task RunInteractiveCaptureAsync(ScreenshotSavedEventArgs e, string? docImagePath)
    {
        try
        {
            // ── Step 2: Warn about unnamed anchors (non-blocking) ─────────────
            foreach (var anchor in e.Anchors)
            {
                if (anchor.NeedsName)
                {
                    var elementType = anchor.Element?.GetType().Name ?? "unknown";
                    AppendLine(
                        $"⚠️ Screenshot anchor at {anchor.Edge} edge has no x:Name — " +
                        $"element path: {elementType}. Consider naming it.");
                }
            }

            // ── Step 4: Convert EdgeAnchor[] → EdgeAnchorRecord[] ─────────────
            var anchorRecords = e.Anchors
                .Select(a => new Screenshots.EdgeAnchorRecord(
                    Edge: a.Edge,
                    ElementNames: a.UniqueNames,
                    NeedsName: a.NeedsName,
                    ElementLeft: a.ElementBounds.Left,
                    ElementTop: a.ElementBounds.Top,
                    ElementWidth: a.ElementBounds.Width,
                    ElementHeight: a.ElementBounds.Height,
                    DistanceToEdge: a.DistanceToEdge))
                .ToArray();

            var topAnchor = anchorRecords[0];
            var rightAnchor = anchorRecords[1];
            var bottomAnchor = anchorRecords[2];
            var leftAnchor = anchorRecords[3];

            // ── Step 5: Use the name confirmed in the overlay rename UI ──────
            // The overlay's EnterRenameMode() called ScreenshotNamingHelper.SuggestName()
            // and let the user edit/confirm it before capture was taken.
            var acceptedName = e.AcceptedName;

            // ── Step 6: Build ScreenshotManifest ──────────────────────────────
            var dpi = VisualTreeHelper.GetDpi(this);
            var sel = e.SelectionRect;
            var bounds = new Screenshots.CaptureBounds(
                X: sel.X,
                Y: sel.Y,
                Width: sel.Width,
                Height: sel.Height,
                DpiX: dpi.DpiScaleX,
                DpiY: dpi.DpiScaleY);

            // Compute the doc-image path relative to screenshotsDir so the
            // refresh runner can resolve it portably.
            string? docImageRelPath = null;
            if (!string.IsNullOrEmpty(docImagePath) && !string.IsNullOrEmpty(_currentDocPath))
            {
                var docDir = Path.GetDirectoryName(_currentDocPath)!;
                var fullDocImagePath = Path.Combine(docDir, docImagePath.Replace('/', '\\'));
                docImageRelPath = Path.GetRelativePath(_workspacePaths.ScreenshotsDirectory, fullDocImagePath);
            }

            var manifest = new Screenshots.ScreenshotManifest(
                Version: 1,
                Name: acceptedName,
                Description: string.Empty,
                Theme: _activeThemeName,
                Region: e.IsFullWindow ? "full" : "custom",
                CapturedAt: DateTime.UtcNow,
                Bounds: bounds,
                Top: topAnchor,
                Right: rightAnchor,
                Bottom: bottomAnchor,
                Left: leftAnchor,
                ReplayActionId: null,
                FixturePath: null,
                DocImagePath: docImageRelPath);

            // Save manifest sidecar alongside the PNG.
            var screenshotsDir = _workspacePaths.ScreenshotsDirectory;
            var baselineDir = Path.Combine(screenshotsDir, "baseline");
            Directory.CreateDirectory(baselineDir);

            var theme = _activeThemeName.ToLowerInvariant();
            var jsonFileName = $"{acceptedName}-{theme}.json";
            var jsonPath = Path.Combine(baselineDir, jsonFileName);

            var jsonOptions = JsonFileStorage.PrettyPrint;
            await using (var fs = File.Open(jsonPath, FileMode.Create, FileAccess.Write, FileShare.None))
                await JsonSerializer.SerializeAsync(fs, manifest, jsonOptions);

            // Rename the provisional PNG to its final accepted-name path.
            var pngFileName = $"{acceptedName}-{theme}.png";
            var finalPngPath = Path.Combine(baselineDir, pngFileName);
            if (!string.Equals(e.PngPath, finalPngPath, StringComparison.OrdinalIgnoreCase))
                File.Move(e.PngPath, finalPngPath, overwrite: true);

            // ── Step 7: Upsert ScreenshotDefinition into registry ─────────────
            var definition = new Screenshots.ScreenshotDefinition(
                Name: acceptedName,
                Description: string.Empty,
                Theme: _activeThemeName,
                ReplayActionId: null,
                FixturePath: null,
                Top: topAnchor,
                Right: rightAnchor,
                Bottom: bottomAnchor,
                Left: leftAnchor,
                Bounds: bounds,
                DocImagePath: docImageRelPath);

            var registry = await Screenshots.ScreenshotDefinitionRegistry.LoadAsync(screenshotsDir);
            registry.AddOrUpdate(definition);
            await registry.SaveAsync();
            _cachedDefinitionRegistry = registry;  // keep warm for right-click Refresh

            // ── Step 7b: Doc-placeholder post-processing ──────────────────────
            if (!string.IsNullOrEmpty(docImagePath) && !string.IsNullOrEmpty(_currentDocPath))
            {
                var docDir = Path.GetDirectoryName(_currentDocPath)!;
                var fullDocImagePath = Path.Combine(docDir, docImagePath.Replace('/', '\\'));
                Directory.CreateDirectory(Path.GetDirectoryName(fullDocImagePath)!);
                File.Copy(finalPngPath, fullDocImagePath, overwrite: true);

                // Strip the 📸 placeholder line from the markdown.
                var lines = File.ReadAllLines(_currentDocPath).ToList();
                var fwdSlashPath = docImagePath.Replace('\\', '/');
                for (int i = lines.Count - 1; i >= 0; i--)
                {
                    if ((lines[i].Contains("📸") || lines[i].Contains("Screenshot needed")) &&
                        i > 0 && lines[i - 1].Replace('\\', '/').Contains(fwdSlashPath))
                    {
                        lines.RemoveAt(i);
                        if (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
                            lines.RemoveAt(i);
                        break;
                    }
                }
                File.WriteAllLines(_currentDocPath, lines);

                // Reload the viewer.
                var markdown = File.ReadAllText(_currentDocPath);
                var title = (DocTopicsTreeView?.SelectedItem as TreeViewItem)?.Header?.ToString() ?? "Documentation";
                var html = MarkdownHtmlBuilder.Build(markdown, title, filePath: _currentDocPath, isDark: AgentStatusCard.IsDarkTheme);
                DocMarkdownViewer.NavigateToString(html);
            }

            // ── Step 8: (no transcript output for doc screenshots — viewer update is the indicator) ──
        }
        catch (Exception ex)
        {
            AppendLine($"[screenshot error] {ex.Message}", ThemeBrush("SystemErrorText"));

            // Best-effort cleanup: remove the provisional PNG if it still exists.
            if (File.Exists(e.PngPath))
            {
                try { File.Delete(e.PngPath); } catch { /* ignore */ }
            }
        }
    }

    private void ShowTraceWindow()
    {
        if (_traceWindow is null)
        {
            SquadDashTrace.Write("UI", "Showing live trace popup.");
            _traceWindow = new TraceWindow(_settingsStore);
            if (CanShowOwnedWindow())
                _traceWindow.Owner = this;

            _traceWindow.Closed += (_, _) =>
            {
                _coordinatorScrollController.TraceTarget = null;
                foreach (var entry in _primaryAgentTranscriptHosts.Values)
                    entry.ScrollController.TraceTarget = null;
                SquadDashTrace.TraceTarget = null;
                _traceWindow = null;
                _traceWindowOffset = null;
            };
            _traceWindow.LocationChanged += (_, _) => OnTraceWindowMoved();

            _coordinatorScrollController.TraceTarget = _traceWindow;
            foreach (var entry in _primaryAgentTranscriptHosts.Values)
                entry.ScrollController.TraceTarget = _traceWindow;
            SquadDashTrace.TraceTarget = _traceWindow;

            // Position after the first layout pass — IsLoaded is false until then,
            // so calling PositionTraceWindow() before Loaded would be a no-op.
            _traceWindow.Loaded += (_, _) => PositionTraceWindow();
            _traceWindow.Show();
        }
        else
        {
            _traceWindow.Activate();
        }

        PositionTraceWindow();
    }

    private void HideLiveTraceWindow()
    {
        if (_traceWindow is not null)
            SquadDashTrace.Write("UI", "Hiding live trace popup.");
        _traceWindow?.Close();
    }

    private string ResolveAgentAccentHex(AgentStatusCard agentCard, bool isLeadAgent)
    {
        if (_currentWorkspace is not null &&
            _settingsSnapshot.AgentAccentColorsByWorkspace.TryGetValue(_currentWorkspace.FolderPath, out var workspaceColors))
        {
            if (workspaceColors.TryGetValue(agentCard.AccentStorageKey, out var savedByKey) &&
                !string.IsNullOrWhiteSpace(savedByKey))
            {
                return savedByKey;
            }

            if (workspaceColors.TryGetValue(agentCard.Name, out var savedByName) &&
                !string.IsNullOrWhiteSpace(savedByName))
            {
                return savedByName;
            }
        }

        if (agentCard.IsDynamicAgent)
            return DynamicAgentDefaultAccentHex;

        if (isLeadAgent)
            return LeadAgentDefaultAccentHex;

        return ObservedAgentDefaultAccentHex;
    }

    private void ApplyAgentAccent(AgentStatusCard agentCard, string accentHex, bool persist)
    {
        agentCard.AccentColorHex = accentHex;

        if (!persist || _currentWorkspace is null)
            return;

        _settingsSnapshot = _settingsStore.SaveAgentAccentColor(
            _currentWorkspace.FolderPath,
            agentCard.AccentStorageKey,
            accentHex);
    }

    private string? ResolveAgentImagePath(AgentStatusCard card)
    {
        if (_currentWorkspace is not null &&
            _settingsSnapshot.AgentImagePathsByWorkspace.TryGetValue(_currentWorkspace.FolderPath, out var workspaceImages))
        {
            foreach (var candidate in HireAgentWindow.BuildImageKeyCandidates(card.Name).Prepend(card.AccentStorageKey))
            {
                if (workspaceImages.TryGetValue(candidate, out var userImagePath) &&
                    !string.IsNullOrWhiteSpace(userImagePath) &&
                    File.Exists(userImagePath))
                {
                    return userImagePath;
                }
            }
        }

        var bundledPath = AgentImagePathResolver.ResolveBundledPath(card, _workspacePaths.AgentImageAssetsDirectory);
        return bundledPath ?? AgentImagePathResolver.ResolveRoleIconPath(card, _workspacePaths.RoleIconAssetsDirectory);
    }

    private static ImageSource? LoadAgentImageFromPath(string? imagePath)
    {
        if (string.IsNullOrWhiteSpace(imagePath) || !File.Exists(imagePath))
            return null;

        try
        {
            // Use StreamSource instead of UriSource to bypass WPF's internal per-URI
            // bitmap cache.  This ensures a re-selected file is always read fresh from
            // disk even if the same path was used before (e.g. after external edits).
            using var stream = File.OpenRead(imagePath);
            var bitmap = new BitmapImage();
            bitmap.BeginInit();
            bitmap.StreamSource = stream;
            bitmap.CacheOption = BitmapCacheOption.OnLoad;
            bitmap.EndInit();
            bitmap.Freeze();
            return bitmap;
        }
        catch
        {
            return null;
        }
    }

    private void ApplyAgentImage(AgentStatusCard card, string? imagePath, bool persist)
    {
        card.AgentImageSource = LoadAgentImageFromPath(imagePath);
        UpdateAvatarSizes();

        if (!persist || _currentWorkspace is null)
            return;

        _settingsSnapshot = _settingsStore.SaveAgentImagePath(
            _currentWorkspace.FolderPath,
            card.AccentStorageKey,
            imagePath);
    }

    private void UpdateAvatarSizes()
    {
        // Circle size and font are fixed; no dynamic resizing needed.
    }

    private static string GetAgentInitial(string agentName)
    {
        return string.IsNullOrWhiteSpace(agentName)
            ? "?"
            : AgentThreadRegistry.HumanizeAgentName(agentName).Trim()[..1].ToUpperInvariant();
    }

    private static string? TryNormalizeGitHubUrl(string remoteUrl)
    {
        remoteUrl = remoteUrl.Trim();

        if (remoteUrl.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
            return NormalizeGitHubPath(remoteUrl["git@github.com:".Length..]);

        if (Uri.TryCreate(remoteUrl, UriKind.Absolute, out var uri) &&
            string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
            return NormalizeGitHubPath(uri.AbsolutePath);

        return null;
    }

    private static string? NormalizeGitHubPath(string path)
    {
        path = path.Trim().Trim('/');
        if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
            path = path[..^4];

        return string.IsNullOrWhiteSpace(path)
            ? null
            : "https://github.com/" + path;
    }

    private static string? TryResolveGitHubUrl(string workspaceFolderPath)
    {
        try
        {
            var process = Process.Start(new ProcessStartInfo
            {
                FileName = "git",
                Arguments = "remote get-url origin",
                WorkingDirectory = workspaceFolderPath,
                RedirectStandardOutput = true,
                UseShellExecute = false,
                CreateNoWindow = true
            });

            if (process is not null)
            {
                var remoteUrl = process.StandardOutput.ReadToEnd().Trim();
                process.WaitForExit(3000);

                if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(remoteUrl))
                {
                    var result = TryNormalizeGitHubUrl(remoteUrl);
                    if (result is not null)
                        return result;
                }
            }
        }
        catch
        {
        }

        // Fallback: read the remote URL directly from .git/config
        try
        {
            var gitConfigPath = Path.Combine(workspaceFolderPath, ".git", "config");
            if (File.Exists(gitConfigPath))
            {
                var inOriginSection = false;
                foreach (var line in File.ReadAllLines(gitConfigPath))
                {
                    var trimmed = line.Trim();
                    if (trimmed.StartsWith("[", StringComparison.Ordinal))
                    {
                        inOriginSection = trimmed.Equals("[remote \"origin\"]", StringComparison.OrdinalIgnoreCase);
                        continue;
                    }

                    if (inOriginSection && trimmed.StartsWith("url", StringComparison.OrdinalIgnoreCase))
                    {
                        var eqIndex = trimmed.IndexOf('=');
                        if (eqIndex >= 0)
                            return TryNormalizeGitHubUrl(trimmed[(eqIndex + 1)..].Trim());
                    }
                }
            }
        }
        catch
        {
        }

        return null;
    }

    internal void EmergencySave() => EmergencySaveAfterDrainingBridgeEvents("external");

    private async Task OpenExternalLinkWithCommitCheckAsync(string url)
    {
        // Check if this is a GitHub commit URL — verify locally via git before opening
        if (_workspaceGitHubUrl is not null &&
            url.StartsWith(_workspaceGitHubUrl, StringComparison.OrdinalIgnoreCase) &&
            url.Contains("/commit/", StringComparison.OrdinalIgnoreCase))
        {
            var sha = url[(url.LastIndexOf('/') + 1)..];
            if (!string.IsNullOrWhiteSpace(sha) && !await IsCommitOnRemoteAsync(sha).ConfigureAwait(false))
            {
                var push = Dispatcher.Invoke(() => MessageBox.Show(
                    this,
                    "This commit doesn't appear to have been pushed to GitHub yet.\n\nWould you like to push all changes now?",
                    "Commit not found",
                    MessageBoxButton.YesNo,
                    MessageBoxImage.Question) == MessageBoxResult.Yes);

                if (push)
                {
                    var pushed = await PushToOriginAsync().ConfigureAwait(false);
                    if (pushed)
                        _squadCliAdapter.OpenExternalLink(url);
                }

                return;
            }
        }

        _squadCliAdapter.OpenExternalLink(url);
    }

    private async Task<bool> IsCommitOnRemoteAsync(string sha)
    {
        var folderPath = _currentWorkspace?.FolderPath;
        if (string.IsNullOrWhiteSpace(folderPath))
            return true; // can't check — assume pushed to avoid false positives

        try
        {
            var process = Process.Start(new ProcessStartInfo
            {
                FileName = "git",
                Arguments = $"branch -r --contains {sha}",
                WorkingDirectory = folderPath,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            });

            if (process is null)
                return true;

            var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
            await process.WaitForExitAsync().ConfigureAwait(false);

            // Any output means the commit is on at least one remote branch
            return !string.IsNullOrWhiteSpace(stdout);
        }
        catch
        {
            return true; // can't check — assume pushed to avoid false positives
        }
    }

    private void AppendSystemLineOrDefer(string text, Brush? brush = null)
    {
        if (_isPromptRunning)
            _deferredSystemLines.Enqueue((text, brush));
        else
            AppendLine(text, brush);
    }

    private void FlushDeferredSystemLines()
    {
        while (_deferredSystemLines.TryDequeue(out var item))
            AppendLine(item.Text, item.Brush);
    }

    private async Task<bool> PushToOriginAsync()
    {
        var folderPath = _currentWorkspace?.FolderPath;
        if (string.IsNullOrWhiteSpace(folderPath))
        {
            Dispatcher.Invoke(() => AppendSystemLineOrDefer("⚠ No workspace folder — cannot push.", ThemeBrush("SystemErrorText")));
            return false;
        }

        Dispatcher.Invoke(() => AppendSystemLineOrDefer("Pushing to origin…"));

        try
        {
            var process = Process.Start(new ProcessStartInfo
            {
                FileName = "git",
                Arguments = "push",
                WorkingDirectory = folderPath,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            });

            if (process is null)
            {
                Dispatcher.Invoke(() => AppendSystemLineOrDefer("⚠ Failed to start git process.", ThemeBrush("SystemErrorText")));
                return false;
            }

            var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
            var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
            await process.WaitForExitAsync().ConfigureAwait(false);

            Dispatcher.Invoke(() =>
            {
                if (process.ExitCode == 0)
                    AppendSystemLineOrDefer("✓ Pushed successfully.");
                else
                    AppendSystemLineOrDefer($"⚠ git push failed (exit {process.ExitCode}): {stderr.Trim()}", ThemeBrush("SystemErrorText"));
            });

            return process.ExitCode == 0;
        }
        catch (Exception ex)
        {
            Dispatcher.Invoke(() => AppendSystemLineOrDefer($"⚠ Push error: {ex.Message}", ThemeBrush("SystemErrorText")));
            return false;
        }
    }

    // ── Transcript search ─────────────────────────────────────────────────────

    /// <summary>
    /// Runs a search against the active transcript, updates match state, and
    /// navigates to the first result.  Cancels any in-flight search first so
    /// rapid typing does not stack results.
    /// </summary>
    private async Task ExecuteSearchAsync(string query)
    {
        // Cancel the previous search and dispose before starting a new one.
        var previous = _searchCts;
        _searchCts = null;
        previous?.Cancel();
        previous?.Dispose();

        if (string.IsNullOrEmpty(query) || query.Length < 3)
        {
            _searchMatches = [];
            _searchMatchCursor = -1;
            _searchAdorner?.Clear();
            _scrollbarAdorner?.Clear();
            _cachedSearchPointers = null;
            ClearBucCellHighlights();
            UpdateSearchUi();
            return;
        }

        var cts = new CancellationTokenSource();
        _searchCts = cts;
        _cachedSearchPointers = null;  // Invalidate stale pointer cache from previous search.

        try
        {
            // Always search the coordinator transcript first (async, may be large).
            var coordinatorMatches = await _conversationManager.SearchTurnsAsync(query, cts.Token);
            if (cts.IsCancellationRequested) return;

            // Show coordinator results immediately so the user gets fast feedback.
            _searchMatches = coordinatorMatches;
            _searchMatchCursor = coordinatorMatches.Count > 0 ? 0 : -1;
            UpdateSearchUi();

            // Then search all agent threads synchronously (all turns already in memory).
            var allMatches = new List<TurnSearchMatch>(coordinatorMatches);
            foreach (var agentThread in _agentThreadRegistry.ThreadOrder)
            {
                if (cts.IsCancellationRequested) return;
                var agentMatches = SearchAgentThread(agentThread, query, agentThread);
                allMatches.AddRange(agentMatches);
            }

            if (cts.IsCancellationRequested) return;

            // Sort all matches in document order (oldest/topmost first).
            // Coordinator matches are already in TurnIndex order, but agent-thread matches
            // are appended after all coordinator matches even though they render visually
            // at the thread's launch point — which may be much earlier in the document.
            // Sorting by each match's turn StartedAt timestamp puts everything in the
            // correct top-to-bottom order the user expects ("1 of N" = topmost hit).
            allMatches.Sort((a, b) =>
            {
                DateTimeOffset TimestampOf(TurnSearchMatch m)
                {
                    if (m.Thread is null)
                        return _conversationManager.GetCoordinatorTurnStartedAt(m.TurnIndex) ?? DateTimeOffset.MinValue;
                    var savedTurns = m.Thread.SavedTurns;
                    return m.TurnIndex >= 0 && m.TurnIndex < savedTurns.Count
                        ? savedTurns[m.TurnIndex].StartedAt
                        : m.Thread.StartedAt;
                }
                return TimestampOf(a).CompareTo(TimestampOf(b));
            });

            _searchMatches = allMatches;
            _searchMatchCursor = allMatches.Count > 0 ? 0 : -1;
            UpdateSearchUi();

            if (allMatches.Count > 0)
                await NavigateToMatchAsync(0);
        }
        catch (OperationCanceledException)
        {
            // Expected when superseded by a newer search — safe to ignore.
        }
        catch (Exception ex)
        {
            SquadDashTrace.Write("Search", $"ExecuteSearchAsync failed: {ex.Message}");
        }
    }

    /// <summary>
    /// Searches <paramref name="thread"/>.SavedTurns for <paramref name="query"/>
    /// using the same case-insensitive excerpt format as
    /// <see cref="TranscriptConversationManager.SearchTurnsAsync"/>.
    /// Agent threads are fully rendered at selection time so no async is needed.
    /// <paramref name="sourceThread"/> is stored on each match so
    /// <see cref="NavigateToMatchAsync"/> can switch transcripts when needed.
    /// </summary>
    private static IReadOnlyList<TurnSearchMatch> SearchAgentThread(
        TranscriptThreadState thread, string query, TranscriptThreadState? sourceThread = null)
    {
        if (string.IsNullOrEmpty(query))
            return [];

        var results = new List<TurnSearchMatch>();
        const StringComparison cmp = StringComparison.OrdinalIgnoreCase;
        const int MaxExcerptLength = 120;
        const int ExcerptPad = 40;

        for (var i = 0; i < thread.SavedTurns.Count; i++)
        {
            var turn = thread.SavedTurns[i];
            ScanSearchField(turn.Prompt ?? string.Empty, "user", i, query, cmp, MaxExcerptLength, ExcerptPad, results, sourceThread);
            ScanSearchField(turn.ResponseText ?? string.Empty, "assistant", i, query, cmp, MaxExcerptLength, ExcerptPad, results, sourceThread);
        }

        return results;
    }

    private static void ScanSearchField(
        string text,
        string role,
        int turnIndex,
        string query,
        StringComparison cmp,
        int maxExcerptLength,
        int excerptPad,
        List<TurnSearchMatch> results,
        TranscriptThreadState? thread = null)
    {
        var searchFrom = 0;
        while (searchFrom < text.Length)
        {
            var offset = text.IndexOf(query, searchFrom, cmp);
            if (offset < 0) break;

            var excerptStart = Math.Max(0, offset - excerptPad);
            var excerptEnd = Math.Min(text.Length, offset + query.Length + excerptPad);
            var rawExcerpt = text[excerptStart..excerptEnd];

            string excerpt;
            if (rawExcerpt.Length > maxExcerptLength)
            {
                excerpt = rawExcerpt[..maxExcerptLength] + "…";
            }
            else
            {
                var prefix = excerptStart > 0 ? "…" : string.Empty;
                var suffix = excerptEnd < text.Length ? "…" : string.Empty;
                excerpt = prefix + rawExcerpt + suffix;
            }

            results.Add(new TurnSearchMatch(turnIndex, role, excerpt, offset, thread));
            searchFrom = offset + query.Length;
        }
    }

    /// <summary>
    /// Navigates to the match at <paramref name="index"/> (wraps around),
    /// ensuring the turn is rendered and scrolling the match into view.
    /// Uses a pointer cache to skip the full document re-walk when the turn is
    /// already rendered, keeping navigation latency well under 100 ms.
    /// </summary>
    private async Task NavigateToMatchAsync(int index)
    {
        if (_searchMatches.Count == 0) return;

        index = ((index % _searchMatches.Count) + _searchMatches.Count) % _searchMatches.Count;
        _searchMatchCursor = index;
        UpdateSearchUi();

        var match = _searchMatches[index];
        var matchThread = match.Thread;  // null = coordinator

        // If the match is in a different thread than currently displayed, switch to it.
        // _searchNavigating suppresses the search-state clear inside SelectTranscriptThread.
        var activeThread = _selectedTranscriptThread ?? CoordinatorThread;
        var targetThread = matchThread ?? CoordinatorThread;
        if (!ReferenceEquals(activeThread, targetThread))
        {
            _searchNavigating = true;
            try
            {
                SelectTranscriptThread(targetThread);
                _cachedSearchPointers = null;  // Pointers are document-specific; invalidate after switch.
                // Allow the document assignment and layout to settle before adorner rebuild.
                await Dispatcher.BeginInvoke(DispatcherPriority.Loaded, static () => { }).Task;
            }
            finally
            {
                _searchNavigating = false;
            }
            activeThread = targetThread;

            // Flash the border of the newly-loaded transcript so the user knows which
            // panel has become active — mirrors the flash used when opening a secondary panel.
            if (ReferenceEquals(targetThread, CoordinatorThread))
            {
                FlashGlowHighlight(MainTranscriptBorder, Colors.CornflowerBlue);
            }
            else
            {
                var entry = _secondaryTranscripts.FirstOrDefault(e => ReferenceEquals(e.Thread, targetThread));
                if (entry is not null)
                    FlashGlowHighlight(entry.PanelBorder, ColorFromHex(entry.Agent.AccentColorHex));
                else
                    FlashGlowHighlight(MainTranscriptBorder, Colors.CornflowerBlue);
            }
        }

        // Fast path: pointer cache is valid and the turn is already in the FlowDocument.
        // Just nudge the adorner cursor — no document walk needed.
        if (_cachedSearchPointers is not null
            && (activeThread.Kind != TranscriptThreadKind.Coordinator
                || _conversationManager.IsTurnRendered(match.TurnIndex)))
        {
            var cursorInList = index < _cachedMatchToCursor.Length ? _cachedMatchToCursor[index] : -1;
            _searchAdorner?.UpdateCurrentIndex(cursorInList);
            UpdateBucActiveHighlight(index);
            ScrollToMatchPointerIfNeeded(
                index < _cachedMatchScrollPointer.Length ? _cachedMatchScrollPointer[index] : null);
            SyncPromptNavButtons();
            return;
        }

        // Slow path: the turn may need to be prepended into the FlowDocument.
        if (activeThread.Kind == TranscriptThreadKind.Coordinator && matchThread is null)
        {
            // Invalidate the cache before prepending so stale pointers aren't used.
            if (!_conversationManager.IsTurnRendered(match.TurnIndex))
                _cachedSearchPointers = null;

            // Guard search state during the async prepend: any SelectTranscriptThread
            // call that fires in this gap (e.g. a pending deferred callback) must not
            // wipe _searchMatches or SearchBox.Text.
            var savedNavigating = _searchNavigating;
            _searchNavigating = true;
            try
            {
                await _conversationManager.EnsureTurnRenderedAsync(match.TurnIndex);
            }
            finally
            {
                _searchNavigating = savedNavigating;
            }
        }

        // Schedule a full adorner rebuild once layout has settled.
        _ = Dispatcher.BeginInvoke(DispatcherPriority.Loaded, RefreshAdornerHighlights);
    }

    /// <summary>
    /// Walks the FlowDocument forward from <paramref name="start"/>, returning a
    /// <see cref="TextRange"/> spanning the first case-insensitive occurrence of
    /// <paramref name="searchText"/>, or <c>null</c> if not found.
    /// </summary>
    private static TextRange? FindTextFromPointer(TextPointer start, string searchText)
    {
        if (string.IsNullOrEmpty(searchText)) return null;

        var navigator = start;
        while (navigator != null)
        {
            if (navigator.GetPointerContext(LogicalDirection.Forward) == TextPointerContext.Text)
            {
                var runText = navigator.GetTextInRun(LogicalDirection.Forward);
                var idx = runText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase);
                if (idx >= 0)
                {
                    var matchStart = navigator.GetPositionAtOffset(idx, LogicalDirection.Forward);
                    var matchEnd = matchStart?.GetPositionAtOffset(searchText.Length, LogicalDirection.Forward);
                    if (matchStart != null && matchEnd != null)
                        return new TextRange(matchStart, matchEnd);
                }
            }
            navigator = navigator.GetNextContextPosition(LogicalDirection.Forward);
        }
        return null;
    }

    /// <summary>
    /// Stateful forward-walking search helper that correctly handles
    /// <see cref="BlockUIContainer"/> elements (rendered code blocks and tables).
    /// Unlike chained <see cref="FindTextFromPointer"/> calls, this walker counts
    /// occurrences inside UIElement containers so that skip counts remain accurate
    /// even when some matches cannot be highlighted.
    /// </summary>
    private sealed class SearchWalker
    {
        private TextPointer? _cursor;
        // When a BlockUIContainer holds N occurrences, we return ContentEnd N times
        // (one per occurrence) so the caller's skip count stays correct.
        private int _pendingBucCount;
        private TextPointer? _pendingBucEnd;
        // Tracks the BUC element and occurrence index of the most-recently returned BUC match.
        private BlockUIContainer? _lastBucElement;
        private int _lastBucOccurrenceIndex;
        private int _lastBucTotalCount;

        public BlockUIContainer? LastBucElement => _lastBucElement;
        public int LastBucOccurrenceIndex => _lastBucOccurrenceIndex;

        public SearchWalker(TextPointer start) => _cursor = start;

        /// <summary>
        /// Returns the <see cref="TextRange"/> of the next occurrence of
        /// <paramref name="searchText"/>, or <c>null</c> when exhausted.
        /// A <b>zero-length</b> range (Start == End) signals a match inside a
        /// <see cref="BlockUIContainer"/> — the cursor is advanced correctly
        /// but the range cannot be drawn by the adorner.
        /// </summary>
        public TextRange? FindNext(string searchText)
        {
            // Drain remaining occurrences that were inside the last BUC.
            if (_pendingBucCount > 0)
            {
                _pendingBucCount--;
                _lastBucOccurrenceIndex = _lastBucTotalCount - _pendingBucCount - 1;
                return _pendingBucEnd is not null
                    ? new TextRange(_pendingBucEnd, _pendingBucEnd)
                    : null;
            }

            if (_cursor is null) return null;

            var nav = _cursor;
            while (nav is not null)
            {
                var ctx = nav.GetPointerContext(LogicalDirection.Forward);

                if (ctx == TextPointerContext.Text)
                {
                    var runText = nav.GetTextInRun(LogicalDirection.Forward);
                    var idx = runText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase);
                    if (idx >= 0)
                    {
                        var matchStart = nav.GetPositionAtOffset(idx, LogicalDirection.Forward);
                        var matchEnd = matchStart?.GetPositionAtOffset(searchText.Length, LogicalDirection.Forward);
                        if (matchStart is not null && matchEnd is not null)
                        {
                            _cursor = matchEnd;
                            return new TextRange(matchStart, matchEnd);
                        }
                    }
                }
                else if (ctx == TextPointerContext.ElementStart)
                {
                    // Detect BlockUIContainer (rendered table or code block).
                    var elem = nav.GetAdjacentElement(LogicalDirection.Forward);
                    if (elem is BlockUIContainer buc)
                    {
                        var bucText = GetBlockUIContainerText(buc);
                        if (!string.IsNullOrEmpty(bucText))
                        {
                            var count = CountOccurrences(bucText, searchText);
                            if (count > 0)
                            {
                                var bucEnd = buc.ContentEnd;
                                _cursor = bucEnd;
                                _pendingBucEnd = bucEnd;
                                _pendingBucCount = count - 1;
                                _lastBucElement = buc;
                                _lastBucOccurrenceIndex = 0;
                                _lastBucTotalCount = count;
                                // Zero-length range signals "found in UIElement, cannot highlight via adorner".
                                return new TextRange(bucEnd, bucEnd);
                            }
                        }
                    }
                }

                nav = nav.GetNextContextPosition(LogicalDirection.Forward);
            }

            _cursor = null;
            return null;
        }

        private static string? GetBlockUIContainerText(BlockUIContainer buc) =>
            buc.Child switch
            {
                StackPanel { Tag: string tableText } => tableText,
                TextBox tb => tb.Text,
                _ => null,
            };

        private static int CountOccurrences(string text, string search)
        {
            var count = 0;
            var from = 0;
            while (true)
            {
                var idx = text.IndexOf(search, from, StringComparison.OrdinalIgnoreCase);
                if (idx < 0) break;
                count++;
                from = idx + search.Length;
            }
            return count;
        }
    }

    /// <summary>
    /// Rebuilds the adorner highlight list from all currently-rendered matches and
    /// updates the current-match index.  Skips matches whose turns are not yet in
    /// the FlowDocument.  Also highlights matching table cells inside
    /// <see cref="BlockUIContainer"/> elements by applying a background brush to
    /// the cell's TextBlock.  Populates the pointer cache used by
    /// <see cref="NavigateToMatchAsync"/> for fast cursor-update navigation.
    /// </summary>
    private void RefreshAdornerHighlights()
    {
        if (_searchAdorner is null) return;

        var query = SearchBox.Text;
        if (_searchMatches.Count == 0 || string.IsNullOrEmpty(query) || query.Length < 3)
        {
            _searchAdorner.Clear();
            _scrollbarAdorner?.Clear();
            _cachedSearchPointers = null;
            ClearBucCellHighlights();
            return;
        }

        var activeThread = _selectedTranscriptThread ?? CoordinatorThread;
        var pointers = new List<(TextPointer Start, TextPointer End, string Text)>(_searchMatches.Count);
        var cursorInList = -1;

        // Per-match cache arrays — rebuilt on every full refresh, then reused by the fast path.
        var matchToCursor = new int[_searchMatches.Count];
        var matchScrollPointer = new TextPointer?[_searchMatches.Count];
        var matchBucCell = new TextBlock?[_searchMatches.Count];
        Array.Fill(matchToCursor, -1);

        // Clear previously-highlighted BUC table cells before re-applying them.
        ClearBucCellHighlights();

        var walkerByKey = new Dictionary<(int TurnIndex, string Role), SearchWalker>();
        TextPointer? currentMatchPointer = null;

        for (var i = 0; i < _searchMatches.Count; i++)
        {
            var match = _searchMatches[i];
            var key = (match.TurnIndex, match.TurnRole);

            if (!walkerByKey.TryGetValue(key, out var walker))
            {
                // Only highlight matches that belong to the currently visible thread.
                var matchThread = match.Thread ?? CoordinatorThread;
                if (!ReferenceEquals(matchThread, activeThread))
                {
                    matchToCursor[i] = -1;
                    continue;
                }
                var searchFrom = GetSearchFromPointerSync(match, activeThread);
                if (searchFrom is null)
                {
                    SquadDashTrace.Write(TraceCategory.UI,
                        $"SEARCH_HIGHLIGHT[{i}] turn={match.TurnIndex} role={match.TurnRole} SKIPPED(unrendered)");
                    continue;
                }
                walker = new SearchWalker(searchFrom);
                walkerByKey[key] = walker;
            }

            var range = walker.FindNext(query);
            if (range is null)
            {
                SquadDashTrace.Write(TraceCategory.UI,
                    $"SEARCH_HIGHLIGHT[{i}] turn={match.TurnIndex} role={match.TurnRole} SKIPPED(walker_exhausted) cursor={i == _searchMatchCursor}");
                continue;
            }

            matchScrollPointer[i] = range.Start;

            // Zero-length range = match inside a BlockUIContainer (table / code block).
            // Cannot highlight via the adorner; apply a background brush to the cell instead.
            if (range.Start.CompareTo(range.End) == 0)
            {
                SquadDashTrace.Write(TraceCategory.UI,
                    $"SEARCH_HIGHLIGHT[{i}] turn={match.TurnIndex} role={match.TurnRole} BUC_MATCH cursor={i == _searchMatchCursor}");
                if (walker.LastBucElement is not null)
                {
                    var bucCell = GetTableCellByOccurrence(walker.LastBucElement, walker.LastBucOccurrenceIndex, query);
                    if (bucCell is not null)
                    {
                        _bucHighlightedCells.Add(bucCell);
                        matchBucCell[i] = bucCell;
                    }
                }
                if (i == _searchMatchCursor)
                    currentMatchPointer = range.Start;
                continue;
            }

            if (i == _searchMatchCursor)
            {
                cursorInList = pointers.Count;
                currentMatchPointer = range.Start;
            }

            matchToCursor[i] = pointers.Count;
            var actualText = new TextRange(range.Start, range.End).Text;
            SquadDashTrace.Write(TraceCategory.UI,
                $"SEARCH_HIGHLIGHT[{i}] turn={match.TurnIndex} role={match.TurnRole} TEXT_MATCH listIdx={pointers.Count} cursor={i == _searchMatchCursor} text='{actualText}'");
            pointers.Add((range.Start, range.End, string.IsNullOrEmpty(actualText) ? query : actualText));
        }

        // Apply BUC cell backgrounds + dark text: all inactive first, then the active cell on top.
        // Read brushes from the current theme resources (same as SearchHighlightAdorner) so
        // that BUC highlights automatically match the active theme.
        var bucInactiveBg = GetThemeBrush("SearchHighlight", Color.FromRgb(98, 84, 44));
        var bucActiveBg = GetThemeBrush("SearchHighlightCurrent", Color.FromRgb(255, 229, 122));
        var bucInactiveFg = GetThemeBrush("SearchHighlightText", Color.FromRgb(18, 13, 0));
        var bucActiveFg = GetThemeBrush("SearchHighlightTextCurrent", Color.FromRgb(0, 0, 0));
        foreach (var cell in _bucHighlightedCells)
        {
            cell.Background = bucInactiveBg;
            cell.Foreground = bucInactiveFg;
        }
        if (_searchMatchCursor >= 0 && _searchMatchCursor < matchBucCell.Length
            && matchBucCell[_searchMatchCursor] is { } activeBucCell)
        {
            activeBucCell.Background = bucActiveBg;
            activeBucCell.Foreground = bucActiveFg;
        }

        _searchAdorner.SetMatches(pointers, cursorInList);

        // Persist the pointer cache for fast-path navigation.
        _cachedSearchPointers = pointers;
        _cachedMatchToCursor = matchToCursor;
        _cachedMatchScrollPointer = matchScrollPointer;
        _cachedMatchBucCell = matchBucCell;

        // Compute scrollbar marker positions BEFORE scrolling.  The fractions are
        // absolute document proportions (independent of the current scroll offset),
        // so they must be computed while the layout still reflects the pre-scroll
        // state.  Moving this after ScrollToMatchPointerIfNeeded would mix a stale
        // GetCharacterRect() (pre-layout-update) with a new VerticalOffset
        // (updated synchronously by ScrollToVerticalOffset), producing wrong docY values.
        RefreshScrollbarMarkerPositions();

        // Scroll to the current match.  Fall back to the first rendered match if the
        // current match is unrendered (handles auto-scroll when typing finds match 0
        // in an older, not-yet-prepended turn).
        if (currentMatchPointer is null && pointers.Count > 0)
            currentMatchPointer = pointers[0].Start;
        ScrollToMatchPointerIfNeeded(currentMatchPointer);

        SyncPromptNavButtons();
    }

    // ── Search helper methods ──────────────────────────────────────────────────

    /// <summary>
    /// Recomputes proportional positions for the scrollbar marker adorner using
    /// the cached search pointer list and the current <see cref="_transcriptScrollViewer"/>
    /// extent.  Safe to call whenever <c>ExtentHeight</c> changes (e.g. streaming
    /// content added) so that tick marks stay anchored to the correct document
    /// positions without a full adorner rebuild.
    /// </summary>
    private void RefreshScrollbarMarkerPositions()
    {
        if (_scrollbarAdorner is null || _transcriptScrollViewer is null)
            return;

        var pointers = _cachedSearchPointers;
        if (pointers is null || pointers.Count == 0)
        {
            _scrollbarAdorner.Clear();
            return;
        }

        var totalHeight = _transcriptScrollViewer.ExtentHeight;
        if (totalHeight <= 0)
        {
            _scrollbarAdorner.Clear();
            return;
        }

        var positions = new List<double>(pointers.Count);
        foreach (var (s, _, _) in pointers)
        {
            if (s is null) continue;
            var rect = s.GetCharacterRect(LogicalDirection.Forward);
            if (rect.IsEmpty) continue;
            var docY = rect.Top + _transcriptScrollViewer.VerticalOffset;
            positions.Add(docY / totalHeight);
        }
        _scrollbarAdorner.SetPositions(positions);
    }

    /// <summary>
    /// Scrolls the transcript so that <paramref name="pointer"/> is visible.
    /// If the pointer is already fully visible, does nothing.
    /// </summary>
    private void ScrollToMatchPointerIfNeeded(TextPointer? pointer)
    {
        if (pointer is null) return;
        var activeBox = ActiveTranscriptBox;
        var sv = activeBox.Template?.FindName("PART_ContentHost", activeBox) as ScrollViewer;
        if (sv is null) return;

        var rect = pointer.GetCharacterRect(LogicalDirection.Forward);
        if (rect.IsEmpty)
        {
            var para = pointer.Paragraph;
            if (para is not null)
                rect = para.ContentStart.GetCharacterRect(LogicalDirection.Forward);
        }

        if (rect.IsEmpty) return;

        var isFullyVisible = rect.Top >= 0 && rect.Bottom <= sv.ViewportHeight;
        SquadDashTrace.Write(TraceCategory.UI,
            $"SEARCH_SCROLL cursor={_searchMatchCursor} rectTop={rect.Top:F0} rectBottom={rect.Bottom:F0} vp={sv.ViewportHeight:F0} offset={sv.VerticalOffset:F0} fullyVisible={isFullyVisible}");
        if (!isFullyVisible)
            ActiveScrollController.ScrollToOffset(sv.VerticalOffset + rect.Top);
    }

    /// <summary>
    /// Resets all highlighted BUC table cells to the inactive brush, then marks the
    /// cell at <paramref name="matchIndex"/> as the active (bright) cell.
    /// </summary>
    private void UpdateBucActiveHighlight(int matchIndex)
    {
        var bucInactiveBg = GetThemeBrush("SearchHighlight", Color.FromRgb(98, 84, 44));
        var bucActiveBg = GetThemeBrush("SearchHighlightCurrent", Color.FromRgb(255, 229, 122));
        var bucInactiveFg = GetThemeBrush("SearchHighlightText", Color.FromRgb(18, 13, 0));
        var bucActiveFg = GetThemeBrush("SearchHighlightTextCurrent", Color.FromRgb(0, 0, 0));
        foreach (var cell in _bucHighlightedCells)
        {
            cell.Background = bucInactiveBg;
            cell.Foreground = bucInactiveFg;
        }
        if (_cachedMatchBucCell is not null
            && matchIndex >= 0 && matchIndex < _cachedMatchBucCell.Length
            && _cachedMatchBucCell[matchIndex] is { } active)
        {
            active.Background = bucActiveBg;
            active.Foreground = bucActiveFg;
        }
    }

    /// <summary>
    /// Removes all BUC cell background highlights and clears the tracked set.
    /// Also restores each cell's Foreground to the theme-defined value via ClearValue.
    /// </summary>
    private void ClearBucCellHighlights()
    {
        foreach (var cell in _bucHighlightedCells)
        {
            cell.Background = null;
            cell.ClearValue(TextBlock.ForegroundProperty);
        }
        _bucHighlightedCells.Clear();
    }

    /// <summary>
    /// Finds the <see cref="TextBlock"/> in <paramref name="buc"/>'s StackPanel whose
    /// cumulative occurrence of <paramref name="query"/> equals
    /// <paramref name="occurrenceIndex"/> (0-based across all cells, left→right, top→bottom).
    /// Returns <c>null</c> if the cell cannot be located.
    /// </summary>
    private static TextBlock? GetTableCellByOccurrence(BlockUIContainer buc, int occurrenceIndex, string query)
    {
        if (buc.Child is not StackPanel sp) return null;
        var count = 0;
        foreach (var rowChild in sp.Children)
        {
            if (rowChild is not Grid grid) continue;
            foreach (var colChild in grid.Children)
            {
                if (colChild is not Border border || border.Child is not TextBlock tb) continue;
                var cellText = GetTextBlockContent(tb);
                var cellCount = CountSubstringOccurrences(cellText, query);
                if (count + cellCount > occurrenceIndex)
                    return tb;  // target occurrence is inside this cell
                count += cellCount;
            }
        }
        return null;
    }

    /// <summary>
    /// Returns the text content of a <see cref="TextBlock"/>.  Uses the Inlines tree
    /// if populated (as done by <c>AppendTextRuns</c>), otherwise falls back to
    /// <see cref="TextBlock.Text"/>.
    /// </summary>
    private static string GetTextBlockContent(TextBlock tb)
    {
        if (tb.Inlines.Count == 0)
            return tb.Text;
        var sb = new System.Text.StringBuilder();
        AppendInlineText(tb.Inlines, sb);
        return sb.ToString();
    }

    /// <summary>
    /// Recursively appends the plain text of all <see cref="Run"/> and container
    /// <see cref="Span"/> elements within <paramref name="inlines"/>.
    /// </summary>
    private static void AppendInlineText(InlineCollection inlines, System.Text.StringBuilder sb)
    {
        foreach (var inline in inlines)
        {
            switch (inline)
            {
                case Run run:
                    sb.Append(run.Text);
                    break;
                case Span span:
                    AppendInlineText(span.Inlines, sb);
                    break;
            }
        }
    }

    /// <summary>
    /// Counts the number of non-overlapping case-insensitive occurrences of
    /// <paramref name="query"/> inside <paramref name="text"/>.
    /// </summary>
    private static int CountSubstringOccurrences(string text, string query)
    {
        if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(query)) return 0;
        var count = 0;
        var idx = 0;
        while ((idx = text.IndexOf(query, idx, StringComparison.OrdinalIgnoreCase)) >= 0)
        {
            count++;
            idx += query.Length;
        }
        return count;
    }

    private static SolidColorBrush MakeFrozenBrush(Color c)
    {
        var b = new SolidColorBrush(c);
        b.Freeze();
        return b;
    }

    /// <summary>
    /// Looks up a <see cref="Brush"/> from the current application theme resources by
    /// <paramref name="key"/>.  Falls back to a new brush with <paramref name="fallback"/>
    /// if the key is not found (e.g. in tests or before resources load).
    /// </summary>
    private static Brush GetThemeBrush(string key, Color fallback)
        => Application.Current?.Resources[key] as Brush ?? new SolidColorBrush(fallback);

    private static ScrollViewer? FindScrollViewer(DependencyObject parent)
    {
        for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child is ScrollViewer sv) return sv;
            var found = FindScrollViewer(child);
            if (found is not null) return found;
        }
        return null;
    }

    private static ScrollBar? FindVerticalScrollBar(DependencyObject parent)
    {
        for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child is ScrollBar sb && sb.Orientation == Orientation.Vertical) return sb;
            var found = FindVerticalScrollBar(child);
            if (found is not null) return found;
        }
        return null;
    }

    /// <summary>
    /// Returns the TextPointer from which to begin scanning for <paramref name="match"/>,
    /// using only already-rendered paragraphs (no async rendering).
    /// Returns <c>null</c> if the turn is not yet in the document.
    /// </summary>
    private TextPointer? GetSearchFromPointerSync(TurnSearchMatch match, TranscriptThreadState thread)
    {
        if (thread.Kind == TranscriptThreadKind.Coordinator)
        {
            var startedAt = _conversationManager.GetCoordinatorTurnStartedAt(match.TurnIndex);
            if (!startedAt.HasValue) return null;
            var entry = CoordinatorThread.PromptParagraphs.FirstOrDefault(e => e.Timestamp == startedAt.Value);
            if (entry is null) return null;
            return match.TurnRole == "assistant" ? entry.Paragraph.ContentEnd : entry.Paragraph.ContentStart;
        }
        else
        {
            if (match.TurnIndex < 0 || match.TurnIndex >= thread.PromptParagraphs.Count) return null;
            var entry = thread.PromptParagraphs[match.TurnIndex];
            return match.TurnRole == "assistant" ? entry.Paragraph.ContentEnd : entry.Paragraph.ContentStart;
        }
    }

    private void ClearSearchButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            ClearSearch();
        }
        catch (Exception ex)
        {
            HandleUiCallbackException(nameof(ClearSearchButton_Click), ex);
        }
    }

    private void ClearSearch()
    {
        _searchCts?.Cancel();
        _searchDebounceTimer?.Stop();
        _searchMatches = [];
        _searchMatchCursor = 0;
        SearchBox.Text = string.Empty;
        _searchAdorner?.Clear();
        _scrollbarAdorner?.Clear();
        _cachedSearchPointers = null;
        ClearBucCellHighlights();
        UpdateSearchUi();
    }

    /// <summary>
    /// Updates the visibility and text of the FindPrev / FindNext buttons and the
    /// match-count label based on current <see cref="_searchMatches"/> state.
    /// Must be called on the UI thread.
    /// </summary>
    private void UpdateSearchUi()
    {
        if (string.IsNullOrEmpty(SearchBox.Text))
        {
            // No active search — hide all navigation chrome.
            FindPrevButton.Visibility = Visibility.Collapsed;
            FindNextButton.Visibility = Visibility.Collapsed;
            SearchMatchCountText.Visibility = Visibility.Collapsed;
            ClearSearchButton.Visibility = Visibility.Collapsed;
        }
        else if (SearchBox.Text.Length < 3)
        {
            // Query too short to search — prompt the user.
            FindPrevButton.Visibility = Visibility.Collapsed;
            FindNextButton.Visibility = Visibility.Collapsed;
            SearchMatchCountText.Visibility = Visibility.Visible;
            SearchMatchCountText.Text = "Type at least 3 characters";
            ClearSearchButton.Visibility = Visibility.Visible;
        }
        else if (_searchMatches.Count == 0)
        {
            // Query entered but no matches found.
            FindPrevButton.Visibility = Visibility.Collapsed;
            FindNextButton.Visibility = Visibility.Collapsed;
            SearchMatchCountText.Visibility = Visibility.Visible;
            SearchMatchCountText.Text = "No matches";
            ClearSearchButton.Visibility = Visibility.Visible;
        }
        else
        {
            // One or more matches — show full navigation chrome.
            FindPrevButton.Visibility = Visibility.Visible;
            FindNextButton.Visibility = Visibility.Visible;
            SearchMatchCountText.Visibility = Visibility.Visible;
            SearchMatchCountText.Text = $"{_searchMatchCursor + 1} of {_searchMatches.Count}";
            ClearSearchButton.Visibility = Visibility.Visible;
        }
    }
}

/// <summary>
/// COM-visible scripting bridge that allows JavaScript in the docs <see cref="System.Windows.Controls.WebBrowser"/>
/// to call back into <see cref="MainWindow"/>.  Set as
/// <c>DocMarkdownViewer.ObjectForScripting</c> so that <c>window.external.*</c>
/// calls in the rendered HTML reach this object.
/// </summary>
[ComVisible(true)]
[System.Runtime.InteropServices.ClassInterface(System.Runtime.InteropServices.ClassInterfaceType.AutoDispatch)]
public sealed class DocViewerScriptingBridge
{
    private readonly MainWindow _window;

    public DocViewerScriptingBridge(MainWindow window) => _window = window;

    /// <summary>Handles hyperlink navigation forwarded from the JS click handler.</summary>
    public void Navigate(string href)
    {
        _window.Dispatcher.BeginInvoke(() => _window.InvokeDocNavigation(href));
    }

    /// <summary>
    /// Called when the user right-clicks a 📸 placeholder blockquote.
    /// <paramref name="imagePath"/> is the relative path from the <c>src</c>
    /// attribute of the <c>&lt;img&gt;</c> immediately preceding the blockquote.
    /// </summary>
    public void ShowScreenshotMenu(string imagePath)
    {
        SquadDashTrace.Write("DocViewer", $"ShowScreenshotMenu called: {imagePath}");
        _window.Dispatcher.BeginInvoke(() => _window.ShowDocScreenshotContextMenu(imagePath));
    }

    /// <summary>
    /// Called when the user right-clicks an existing image in the docs viewer.
    /// <paramref name="imagePath"/> is the relative path from the <c>src</c> attribute.
    /// </summary>
    public void ShowImageMenu(string imagePath)
    {
        SquadDashTrace.Write("DocViewer", $"ShowImageMenu called: {imagePath}");
        _window.Dispatcher.BeginInvoke(() => _window.ShowImageContextMenu(imagePath));
    }

    /// <summary>
    /// Feature 3: Called when hovering over an element in the doc viewer.
    /// <paramref name="lineHint"/> is the line number from data-source-line attribute.
    /// </summary>
    public void HoverElement(string lineHint)
    {
        _window.Dispatcher.BeginInvoke(() => _window.HighlightDocSourceFromHover(lineHint));
    }

    /// <summary>
    /// Called when the user clicks an element in the doc viewer preview.
    /// Scrolls the source editor to the corresponding line and highlights it.
    /// </summary>
    public void ClickElement(string lineHint)
    {
        _window.Dispatcher.BeginInvoke(() => _window.ScrollDocSourceToLine(lineHint));
    }
}
