From 3cb25a98ab812c71cf9c542a8146f5597309e09d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?=
<66911217+jiangsir-tech@users.noreply.github.com>
Date: Thu, 13 Aug 2026 10:46:16 +0800
Subject: [PATCH 1/4] Prepare CodexNotes 1.4.83
---
AppBundle/Info.plist | 4 +-
README.md | 10 +-
.../CodexRightPanelStateReader.swift | 144 ++
Sources/CodexNotesCore/Localization.swift | 12 +
.../Resources/en.lproj/Localizable.strings | 12 +
.../zh-Hans.lproj/Localizable.strings | 12 +
.../RightPanelAvoidancePreference.swift | 37 +
.../CodexAccessibilityRightPanelReader.swift | 617 ++++++
.../CodexNotesProbe/CodexNotesProbeApp.swift | 8 +
Sources/CodexNotesProbe/ContentView.swift | 254 ++-
.../MainWindowChromePolicy.swift | 5 +-
.../MainWindowCompactController.swift | 976 +++++++++
.../MainWindowCompactState.swift | 174 ++
.../PlainMarkdownTextView.swift | 24 +
Sources/CodexNotesProbe/ProbeViewModel.swift | 106 +
Sources/CodexNotesProbe/SettingsView.swift | 130 ++
.../CodexRightPanelStateReaderTests.swift | 363 ++++
.../RightPanelAvoidancePreferenceTests.swift | 79 +
...exAccessibilityRightPanelReaderTests.swift | 382 ++++
.../MainWindowChromePolicyTests.swift | 24 +
.../MainWindowCompactControllerTests.swift | 1800 +++++++++++++++++
.../MainWindowCompactStateTests.swift | 510 +++++
.../PlainMarkdownTextViewTests.swift | 51 +-
...obeViewModelRightPanelAvoidanceTests.swift | 31 +
...RightPanelAvoidancePresentationTests.swift | 85 +
25 files changed, 5823 insertions(+), 27 deletions(-)
create mode 100644 Sources/CodexNotesCore/CodexRightPanelStateReader.swift
create mode 100644 Sources/CodexNotesCore/RightPanelAvoidancePreference.swift
create mode 100644 Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift
create mode 100644 Sources/CodexNotesProbe/MainWindowCompactController.swift
create mode 100644 Sources/CodexNotesProbe/MainWindowCompactState.swift
create mode 100644 Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift
create mode 100644 Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
create mode 100644 Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift
create mode 100644 Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
create mode 100644 Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift
create mode 100644 Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift
create mode 100644 Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift
diff --git a/AppBundle/Info.plist b/AppBundle/Info.plist
index a02de10..d6d99c2 100644
--- a/AppBundle/Info.plist
+++ b/AppBundle/Info.plist
@@ -24,9 +24,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.71
+ 1.4.83
CFBundleVersion
- 91
+ 103
LSUIElement
LSMinimumSystemVersion
diff --git a/README.md b/README.md
index 6774e4e..f49eb00 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ CodexNotes 是一个跟随 Codex 当前任务自动切换的原生 macOS Markdow
- Apple Silicon(arm64)或 Intel(x86_64)Mac
- macOS 14.0 或更高版本
- 正式 Release 为 Universal 2,同一份应用同时包含 arm64 与 x86_64
-- 当前已验证兼容 Codex `26.803.41515`
+- 当前已验证兼容 Codex `26.803.81509`
CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背书关系。
@@ -21,6 +21,8 @@ CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背
2. 解压后,把 `CodexNotes.app` 移到 `/Applications` 或 `~/Applications`。
3. 首次打开后,CodexNotes 会常驻菜单栏,不会在 Dock 显示图标。
+“自动避让 Codex 侧边栏”默认关闭,可在设置中主动开启。若要精确识别尚未打开任何网页、终端或文件的空侧边栏,需要按设置页提示授予辅助功能权限;即使不开启自动避让,仍可使用笔记窗口右上角按钮手动折叠和展开。
+
正式 Release 使用 Developer ID 签名并通过 Apple notarization。你可以在终端验证:
```sh
@@ -42,6 +44,8 @@ spctl --assess --type exec --verbose=4 /Applications/CodexNotes.app
- 支持系统原色及六套自定义主题,并可调整字号与行间距
- 支持跟随系统、简体中文和 English 三种语言选项
- 支持自定义全局显示/隐藏快捷键;只在 Codex 正在运行且未隐藏时生效
+- 支持手动把笔记窗口折叠为右上角锚定的半透明紧凑胶囊,只保留应用名称和展开按钮
+- 可选“自动避让 Codex 侧边栏”:每轮侧边栏打开时自动收起一次,关闭时优先恢复打开侧边栏前的展开状态
- 可选择登录 Mac 时启动;该功能默认关闭并使用 macOS 原生登录项
- 记住窗口位置和大小;隐藏、重新显示或重启后不会漂移
- 关于页显示版本、作者与项目链接,并支持手动检查或主动开启每日 GitHub Release 更新检查
@@ -61,6 +65,8 @@ Markdown 原文是唯一数据源。图形复选框、图片预览、粗体和
CodexNotes 不上传笔记、不提供云同步,也不包含遥测或分析服务。应用只读取 Codex 写在本机的状态与导航日志,用来识别当前任务。
+自动避让开启后,CodexNotes 会优先通过 macOS 辅助功能读取 Codex 主窗口的布局,以判断右侧边栏是否展开;它不读取键盘输入,也不会把窗口结构发送到网络。未授予权限时会降级读取 Codex 本机状态文件,但无法可靠识别没有标签内容的空侧边栏。该功能默认关闭,关闭后不会进行上述侧边栏检测,手动折叠仍然可用。
+
只有当你手动点击“检查更新”,或主动开启“自动检查更新”后,应用才会访问 GitHub Releases API。成功检查后间隔 24 小时;网络失败时会静默退避重试。应用只检查版本,不会自动下载或安装;请求不包含笔记内容、设备标识或遥测数据。
## 快捷键
@@ -103,7 +109,7 @@ zsh scripts/release-notarized.sh
## 兼容性说明
-自动跟随目前依赖 Codex `26.803.41515` 写出的本机主窗口导航日志。该日志不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试。
+自动跟随和侧边栏避让目前依赖 Codex `26.803.81509` 的本机导航状态及窗口结构。它们不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试;识别失败时,侧边栏避让会保持窗口展开,不会猜测性收起。
Universal 2 产物会验证 arm64 与 x86_64 两个架构。当前 Codex 联动验证以 Apple Silicon 真机为主;Intel 版已包含原生 x86_64 架构代码,完整联动兼容性将在真实 Intel Mac 上持续验证,交叉编译或 Rosetta 启动仅作为补充。
diff --git a/Sources/CodexNotesCore/CodexRightPanelStateReader.swift b/Sources/CodexNotesCore/CodexRightPanelStateReader.swift
new file mode 100644
index 0000000..2919b4c
--- /dev/null
+++ b/Sources/CodexNotesCore/CodexRightPanelStateReader.swift
@@ -0,0 +1,144 @@
+import CoreFoundation
+import Foundation
+
+public enum CodexRightPanelState: String, Codable, Equatable, Sendable {
+ case unknown
+ case closed
+ case open
+}
+
+/// Parses Codex's persisted right-panel state for one exact thread.
+///
+/// The generic panel state is authoritative when it contains a strict Boolean.
+/// If that private schema is unavailable, the browser-only state is a backward
+/// compatible fallback. A valid atom store with neither exact-thread key means
+/// the panel is still in its default closed state. Present but drifted values
+/// remain unknown and never borrow another thread's state or rely on truthiness.
+public enum CodexRightPanelStateParser {
+ private static let atomStateKey = "electron-persisted-atom-state"
+ private static let genericThreadKeyPrefix = "thread-tab-routes-v1:"
+ private static let browserThreadKeyPrefix = "thread-browser-tabs-v1:"
+
+ public static func state(
+ for threadID: String,
+ in data: Data
+ ) -> CodexRightPanelState {
+ guard !threadID.isEmpty,
+ let root = try? JSONSerialization.jsonObject(with: data),
+ let rootObject = root as? [String: Any],
+ let atomState = rootObject[atomStateKey] as? [String: Any]
+ else {
+ return .unknown
+ }
+
+ let genericKey = genericPersistedKey(for: threadID)
+ let browserKey = browserPersistedKey(for: threadID)
+ let genericValue = atomState[genericKey]
+ let browserValue = atomState[browserKey]
+
+ if let genericThreadState = genericValue as? [String: Any],
+ let topology = genericThreadState["topology"] as? [String: Any],
+ let right = topology["right"] as? [String: Any],
+ let state = strictBooleanState(right["open"]) {
+ return state
+ }
+
+ if let browserThreadState = browserValue as? [String: Any],
+ let state = strictBooleanState(
+ browserThreadState["rightPanelOpen"]
+ ) {
+ return state
+ }
+
+ if genericValue == nil, browserValue == nil {
+ return .closed
+ }
+
+ return .unknown
+ }
+
+ /// The legacy browser-only key, retained for source compatibility.
+ public static func persistedKey(for threadID: String) -> String {
+ browserPersistedKey(for: threadID)
+ }
+
+ /// Matches Codex's generic right-panel persisted key.
+ public static func genericPersistedKey(for threadID: String) -> String {
+ genericThreadKeyPrefix + encodeURIComponent(threadID)
+ }
+
+ /// Matches Codex's browser-only right-panel persisted key.
+ public static func browserPersistedKey(for threadID: String) -> String {
+ browserThreadKeyPrefix + encodeURIComponent(threadID)
+ }
+
+ private static func strictBooleanState(
+ _ value: Any?
+ ) -> CodexRightPanelState? {
+ guard let number = value as? NSNumber,
+ CFGetTypeID(number) == CFBooleanGetTypeID()
+ else {
+ return nil
+ }
+
+ return number.boolValue ? .open : .closed
+ }
+
+ private static func encodeURIComponent(_ value: String) -> String {
+ var result = ""
+ result.reserveCapacity(value.utf8.count)
+
+ for byte in value.utf8 {
+ if isEncodeURIComponentUnescaped(byte) {
+ result.unicodeScalars.append(UnicodeScalar(byte))
+ } else {
+ result.append(String(format: "%%%02X", byte))
+ }
+ }
+ return result
+ }
+
+ private static func isEncodeURIComponentUnescaped(_ byte: UInt8) -> Bool {
+ switch byte {
+ case 0x30 ... 0x39, // 0-9
+ 0x41 ... 0x5A, // A-Z
+ 0x61 ... 0x7A, // a-z
+ 0x21, // !
+ 0x27, // '
+ 0x28, // (
+ 0x29, // )
+ 0x2A, // *
+ 0x2D, // -
+ 0x2E, // .
+ 0x5F, // _
+ 0x7E: // ~
+ return true
+ default:
+ return false
+ }
+ }
+}
+
+/// Reads a fresh snapshot on each call so atomic replacements of Codex's state
+/// file are observed without retaining stale panel state.
+public actor CodexRightPanelStateReader {
+ private let globalStateURL: URL
+
+ public init(
+ globalStateURL: URL = CodexEnvironment.defaultStateDatabase
+ .deletingLastPathComponent()
+ .appendingPathComponent(".codex-global-state.json")
+ ) {
+ self.globalStateURL = globalStateURL
+ }
+
+ public func state(for threadID: String) -> CodexRightPanelState {
+ guard let data = try? Data(
+ contentsOf: globalStateURL,
+ options: .mappedIfSafe
+ ) else {
+ return .unknown
+ }
+ return CodexRightPanelStateParser.state(for: threadID, in: data)
+ }
+}
diff --git a/Sources/CodexNotesCore/Localization.swift b/Sources/CodexNotesCore/Localization.swift
index 284e80a..9aaa76c 100644
--- a/Sources/CodexNotesCore/Localization.swift
+++ b/Sources/CodexNotesCore/Localization.swift
@@ -69,6 +69,14 @@ public enum L10n {
case settingsLaunchAtLoginRequiresApproval = "settings.launch_at_login.requires_approval"
case settingsLaunchAtLoginOpenSystemSettings = "settings.launch_at_login.open_system_settings"
case settingsLaunchAtLoginError = "settings.launch_at_login.error"
+ case settingsRightPanelAvoidanceTitle = "settings.right_panel_avoidance.title"
+ case settingsRightPanelAvoidanceDescription = "settings.right_panel_avoidance.description"
+ case settingsRightPanelAvoidancePermissionTitle = "settings.right_panel_avoidance.permission_title"
+ case settingsRightPanelAvoidanceAuthorized = "settings.right_panel_avoidance.authorized"
+ case settingsRightPanelAvoidanceNotAuthorized = "settings.right_panel_avoidance.not_authorized"
+ case settingsRightPanelAvoidancePermissionDescription = "settings.right_panel_avoidance.permission_description"
+ case settingsRightPanelAvoidanceRequestPermission = "settings.right_panel_avoidance.request_permission"
+ case settingsRightPanelAvoidanceOpenSystemSettings = "settings.right_panel_avoidance.open_system_settings"
case settingsGlobalHotKeyTitle = "settings.global_hot_key.title"
case settingsGlobalHotKeyDescription = "settings.global_hot_key.description"
case settingsGlobalHotKeyRecord = "settings.global_hot_key.record"
@@ -349,6 +357,10 @@ public enum L10n {
case statusItemQuit = "status_item.quit"
case mainWindowCloseAccessibilityLabel = "main_window.close.accessibility_label"
case mainWindowCloseAccessibilityHelp = "main_window.close.accessibility_help"
+ case mainWindowCollapseAccessibilityLabel = "main_window.collapse.accessibility_label"
+ case mainWindowCollapseAccessibilityHelp = "main_window.collapse.accessibility_help"
+ case mainWindowExpandAccessibilityLabel = "main_window.expand.accessibility_label"
+ case mainWindowExpandAccessibilityHelp = "main_window.expand.accessibility_help"
case windowCloseHoverHint = "window.close.hover_hint"
case appCommandSaveNow = "app.command.save_now"
diff --git a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
index f19ab58..508658c 100644
--- a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
+++ b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings
@@ -9,6 +9,14 @@
"settings.launch_at_login.requires_approval" = "CodexNotes was added as a login item but isn’t active yet. Allow it in System Settings.";
"settings.launch_at_login.open_system_settings" = "Open System Settings";
"settings.launch_at_login.error" = "Couldn’t change the launch-at-login setting. Try again.";
+"settings.right_panel_avoidance.title" = "Automatically Avoid the Codex Sidebar";
+"settings.right_panel_avoidance.description" = "When enabled, CodexNotes collapses once per right-sidebar cycle and gives priority to the pre-sidebar expanded state when the sidebar closes. When automatic avoidance is off, the top-right button still collapses and expands CodexNotes manually.";
+"settings.right_panel_avoidance.permission_title" = "Accessibility Permission";
+"settings.right_panel_avoidance.authorized" = "Allowed";
+"settings.right_panel_avoidance.not_authorized" = "Not Allowed";
+"settings.right_panel_avoidance.permission_description" = "Detecting an empty sidebar requires Accessibility permission. CodexNotes only reads the Codex window layout; it does not read keyboard input.";
+"settings.right_panel_avoidance.request_permission" = "Allow Accessibility Permission";
+"settings.right_panel_avoidance.open_system_settings" = "Open System Settings";
"settings.global_hot_key.title" = "Show/Hide CodexNotes";
"settings.global_hot_key.description" = "Available from any app while Codex is running and visible. If another app also responds, or nothing happens, choose a different shortcut.";
"settings.global_hot_key.record" = "Set Shortcut";
@@ -289,6 +297,10 @@
"status_item.quit" = "Quit CodexNotes";
"main_window.close.accessibility_label" = "Hide CodexNotes";
"main_window.close.accessibility_help" = "Hides the notes window. CodexNotes will keep running in the menu bar.";
+"main_window.collapse.accessibility_label" = "Collapse CodexNotes";
+"main_window.collapse.accessibility_help" = "Collapses the note content and leaves only the title bar.";
+"main_window.expand.accessibility_label" = "Expand CodexNotes";
+"main_window.expand.accessibility_help" = "Expands the complete notes window.";
"window.close.hover_hint" = "Hide CodexNotes";
"app.command.save_now" = "Save Now";
diff --git a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
index 714e45d..fb9fdd9 100644
--- a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
+++ b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings
@@ -9,6 +9,14 @@
"settings.launch_at_login.requires_approval" = "CodexNotes 已添加为登录项,但尚未生效;请在系统设置中允许。";
"settings.launch_at_login.open_system_settings" = "打开系统设置";
"settings.launch_at_login.error" = "无法更改登录启动设置,请重试。";
+"settings.right_panel_avoidance.title" = "自动避让 Codex 侧边栏";
+"settings.right_panel_avoidance.description" = "开启后,右侧边栏打开时每轮自动收起一次,关闭时优先恢复打开边栏前的展开状态。关闭自动避让后,仍可使用右上角按钮手动折叠和展开。";
+"settings.right_panel_avoidance.permission_title" = "辅助功能权限";
+"settings.right_panel_avoidance.authorized" = "已允许";
+"settings.right_panel_avoidance.not_authorized" = "未允许";
+"settings.right_panel_avoidance.permission_description" = "精确识别空侧边栏需要辅助功能权限。CodexNotes 只读取 Codex 窗口布局,不读取键盘输入。";
+"settings.right_panel_avoidance.request_permission" = "允许辅助功能权限";
+"settings.right_panel_avoidance.open_system_settings" = "打开系统设置";
"settings.global_hot_key.title" = "显示/隐藏 CodexNotes";
"settings.global_hot_key.description" = "Codex 正在显示时,可在任意应用中使用;若同时触发其他应用或无响应,请换一个";
"settings.global_hot_key.record" = "设置快捷键";
@@ -289,6 +297,10 @@
"status_item.quit" = "退出 CodexNotes";
"main_window.close.accessibility_label" = "隐藏 CodexNotes";
"main_window.close.accessibility_help" = "隐藏笔记窗口,应用继续在状态栏运行";
+"main_window.collapse.accessibility_label" = "折叠 CodexNotes";
+"main_window.collapse.accessibility_help" = "收起笔记内容,只保留标题栏";
+"main_window.expand.accessibility_label" = "展开 CodexNotes";
+"main_window.expand.accessibility_help" = "展开完整笔记窗口";
"window.close.hover_hint" = "隐藏 CodexNotes";
"app.command.save_now" = "立即保存";
diff --git a/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift b/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift
new file mode 100644
index 0000000..7356f52
--- /dev/null
+++ b/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+/// Shared persistence contract for the optional Codex right-panel avoidance.
+///
+/// Installations that have already saved an explicit choice retain it. Every
+/// installation without this key starts with the optional behavior disabled,
+/// including upgrades from public versions that predate automatic avoidance.
+/// `migrateIfNeeded` persists that decision before any `@AppStorage` reader is
+/// created, so every window observes one stable value for the entire launch.
+public enum RightPanelAvoidancePreference {
+ public static let key = "rightPanelAvoidanceEnabled"
+ public static let defaultValue = false
+
+ @discardableResult
+ public static func migrateIfNeeded(
+ defaults: UserDefaults = .standard,
+ persistentDomainName: String
+ ) -> Bool {
+ let persistentDomain = defaults.persistentDomain(
+ forName: persistentDomainName
+ )
+
+ if let storedValue = persistentDomain?[key] as? NSNumber {
+ return storedValue.boolValue
+ }
+
+ defaults.set(defaultValue, forKey: key)
+ return defaultValue
+ }
+
+ public static func load(from defaults: UserDefaults = .standard) -> Bool {
+ guard defaults.object(forKey: key) != nil else {
+ return defaultValue
+ }
+ return defaults.bool(forKey: key)
+ }
+}
diff --git a/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift b/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift
new file mode 100644
index 0000000..0c2a6dc
--- /dev/null
+++ b/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift
@@ -0,0 +1,617 @@
+import AppKit
+import ApplicationServices
+import CodexNotesCore
+import CoreFoundation
+import Foundation
+
+enum CodexAccessibilityPermissionState: Equatable, Sendable {
+ case authorized
+ case denied
+}
+
+struct CodexAccessibilityRightPanelSample: Equatable, Sendable {
+ let state: CodexRightPanelState
+ let permission: CodexAccessibilityPermissionState
+}
+
+struct CodexAccessibilityElementSnapshot: Equatable, Sendable {
+ let role: String
+ let subrole: String?
+ let frame: CGRect?
+ let classTokens: Set
+ let children: [CodexAccessibilityElementSnapshot]
+
+ init(
+ role: String,
+ subrole: String? = nil,
+ frame: CGRect? = nil,
+ classTokens: Set = [],
+ children: [CodexAccessibilityElementSnapshot] = []
+ ) {
+ self.role = role
+ self.subrole = subrole
+ self.frame = frame
+ self.classTokens = classTokens
+ self.children = children
+ }
+}
+
+enum CodexAccessibilityRightPanelClassifier {
+ static let shellRowClassTokens: Set = [
+ "relative", "isolate", "flex", "min-h-0", "flex-1",
+ "overflow-hidden",
+ ]
+ static let asideClassTokens: Set = [
+ "relative", "z-[41]", "h-full", "min-h-0", "min-w-0",
+ "shrink-0", "overflow-visible",
+ ]
+ private static let mainSubrole = "AXLandmarkMain"
+ private static let asideSubrole = "AXLandmarkComplementary"
+ private static let minimumMainWidth: CGFloat = 600
+ private static let minimumMainHeight: CGFloat = 400
+ private static let minimumAsideWidth: CGFloat = 240
+ private static let maximumAsideWidth: CGFloat = 1_200
+ private static let frameTolerance: CGFloat = 3
+
+ static func state(
+ in roots: [CodexAccessibilityElementSnapshot]
+ ) -> CodexRightPanelState {
+ let mains = descendants(in: roots).filter { element in
+ element.subrole == mainSubrole
+ && element.frame.map {
+ $0.width >= minimumMainWidth
+ && $0.height >= minimumMainHeight
+ } == true
+ }
+ guard let main = mains.max(by: {
+ frameArea($0.frame) < frameArea($1.frame)
+ }), let mainFrame = main.frame else {
+ return .unknown
+ }
+
+ let shellRows = shellRows(in: main)
+ guard shellRows.count == 1, let shellRow = shellRows.first else {
+ return .unknown
+ }
+
+ let viewports = shellRow.children.filter {
+ $0.classTokens.contains(where: {
+ $0.hasPrefix("_MainContentViewport_")
+ })
+ }
+ guard viewports.count == 1,
+ let viewport = viewports.first,
+ let viewportFrame = viewport.frame,
+ isVerticallyAligned(viewportFrame, with: mainFrame)
+ else {
+ return .unknown
+ }
+
+ let asideCandidates = shellRow.children.filter {
+ $0.subrole == asideSubrole
+ || asideClassTokens.isSubset(of: $0.classTokens)
+ }
+ if asideCandidates.isEmpty {
+ return framesApproximatelyEqual(viewportFrame, mainFrame)
+ ? .closed
+ : .unknown
+ }
+
+ guard asideCandidates.count == 1,
+ let aside = asideCandidates.first,
+ aside.subrole == asideSubrole,
+ asideClassTokens.isSubset(of: aside.classTokens),
+ let asideFrame = aside.frame,
+ validOpenGeometry(
+ main: mainFrame,
+ viewport: viewportFrame,
+ aside: asideFrame
+ ) else {
+ return .unknown
+ }
+ return .open
+ }
+
+ static func shellRows(
+ in main: CodexAccessibilityElementSnapshot
+ ) -> [CodexAccessibilityElementSnapshot] {
+ guard let mainFrame = main.frame else { return [] }
+ return descendants(in: main.children).filter {
+ shellRowClassTokens.isSubset(of: $0.classTokens)
+ && framesApproximatelyEqual($0.frame, mainFrame)
+ }
+ }
+
+ private static func descendants(
+ in roots: [CodexAccessibilityElementSnapshot]
+ ) -> [CodexAccessibilityElementSnapshot] {
+ var result: [CodexAccessibilityElementSnapshot] = []
+ var queue = roots
+ while !queue.isEmpty {
+ let element = queue.removeFirst()
+ result.append(element)
+ queue.append(contentsOf: element.children)
+ }
+ return result
+ }
+
+ private static func validOpenGeometry(
+ main: CGRect,
+ viewport: CGRect,
+ aside: CGRect
+ ) -> Bool {
+ guard aside.width >= minimumAsideWidth,
+ aside.width <= min(maximumAsideWidth, main.width * 0.75),
+ viewport.width > 0,
+ isVerticallyAligned(viewport, with: main),
+ isVerticallyAligned(aside, with: main),
+ approximatelyEqual(viewport.minX, main.minX),
+ approximatelyEqual(viewport.maxX, aside.minX),
+ approximatelyEqual(aside.maxX, main.maxX),
+ approximatelyEqual(viewport.width + aside.width, main.width)
+ else { return false }
+ return true
+ }
+
+ private static func isVerticallyAligned(
+ _ frame: CGRect,
+ with container: CGRect
+ ) -> Bool {
+ approximatelyEqual(frame.minY, container.minY)
+ && approximatelyEqual(frame.maxY, container.maxY)
+ }
+
+ private static func framesApproximatelyEqual(
+ _ lhs: CGRect?,
+ _ rhs: CGRect
+ ) -> Bool {
+ guard let lhs else { return false }
+ return approximatelyEqual(lhs.minX, rhs.minX)
+ && approximatelyEqual(lhs.minY, rhs.minY)
+ && approximatelyEqual(lhs.maxX, rhs.maxX)
+ && approximatelyEqual(lhs.maxY, rhs.maxY)
+ }
+
+ private static func approximatelyEqual(
+ _ lhs: CGFloat,
+ _ rhs: CGFloat
+ ) -> Bool {
+ abs(lhs - rhs) <= frameTolerance
+ }
+
+ private static func frameArea(_ frame: CGRect?) -> CGFloat {
+ guard let frame else { return 0 }
+ return frame.width * frame.height
+ }
+}
+
+struct CodexRightPanelDebouncer: Equatable, Sendable {
+ private let requiredConsecutive: Int
+ private var candidate: CodexRightPanelState?
+ private var consecutiveCount = 0
+ private(set) var lastPublished: CodexRightPanelState?
+
+ init(requiredConsecutive: Int = 2) {
+ precondition(requiredConsecutive > 0)
+ self.requiredConsecutive = requiredConsecutive
+ }
+
+ mutating func observe(
+ _ state: CodexRightPanelState
+ ) -> CodexRightPanelState? {
+ guard state != .unknown else {
+ candidate = nil
+ consecutiveCount = 0
+ return nil
+ }
+ guard state != lastPublished else {
+ candidate = nil
+ consecutiveCount = 0
+ return nil
+ }
+ if candidate == state {
+ consecutiveCount += 1
+ } else {
+ candidate = state
+ consecutiveCount = 1
+ }
+ guard consecutiveCount >= requiredConsecutive else { return nil }
+ lastPublished = state
+ candidate = nil
+ consecutiveCount = 0
+ return state
+ }
+
+ mutating func reset() {
+ candidate = nil
+ consecutiveCount = 0
+ lastPublished = nil
+ }
+}
+
+protocol CodexAccessibilityReading: Sendable {
+ func isTrusted() -> Bool
+ func codexProcessIdentifier() -> pid_t?
+ func rootSnapshots(
+ for processIdentifier: pid_t,
+ budget: CodexAccessibilityTraversalBudget
+ ) -> [CodexAccessibilityElementSnapshot]?
+}
+
+struct CodexAccessibilityTraversalBudget: Sendable {
+ let maximumNodes: Int
+ let maximumDepth: Int
+ let maximumDuration: Duration
+
+ init(
+ maximumNodes: Int = 80,
+ maximumDepth: Int = 18,
+ maximumDuration: Duration = .milliseconds(20)
+ ) {
+ precondition(maximumNodes > 0)
+ precondition(maximumDepth >= 0)
+ self.maximumNodes = maximumNodes
+ self.maximumDepth = maximumDepth
+ self.maximumDuration = maximumDuration
+ }
+}
+
+enum CodexAccessibilityTargetedTraversal {
+ static func uniqueMatch(
+ among roots: [Node],
+ startingDepth: Int,
+ nodeCount: inout Int,
+ budget: CodexAccessibilityTraversalBudget,
+ isBeforeDeadline: () -> Bool,
+ isMatch: (Node) -> Bool,
+ children: (Node) -> [Node]?,
+ shouldTraverse: (Node) -> Bool
+ ) -> Node? {
+ guard startingDepth <= budget.maximumDepth else { return nil }
+ var queue = roots.map { (node: $0, depth: startingDepth) }
+ var match: Node?
+
+ while !queue.isEmpty {
+ guard nodeCount < budget.maximumNodes,
+ isBeforeDeadline() else { return nil }
+ let item = queue.removeFirst()
+ nodeCount += 1
+
+ if isMatch(item.node) {
+ guard match == nil else { return nil }
+ match = item.node
+ continue
+ }
+
+ guard item.depth < budget.maximumDepth,
+ let descendants = children(item.node) else { continue }
+ for descendant in descendants where shouldTraverse(descendant) {
+ queue.append((descendant, item.depth + 1))
+ }
+ }
+
+ guard isBeforeDeadline() else { return nil }
+ return match
+ }
+}
+
+actor CodexAccessibilityRightPanelReader {
+ private let backend: any CodexAccessibilityReading
+ private let budget: CodexAccessibilityTraversalBudget
+
+ init() {
+ backend = SystemCodexAccessibilityBackend()
+ budget = CodexAccessibilityTraversalBudget()
+ }
+
+ init(
+ backend: any CodexAccessibilityReading,
+ budget: CodexAccessibilityTraversalBudget = .init()
+ ) {
+ self.backend = backend
+ self.budget = budget
+ }
+
+ func sample() -> CodexAccessibilityRightPanelSample {
+ guard backend.isTrusted() else {
+ return CodexAccessibilityRightPanelSample(
+ state: .unknown,
+ permission: .denied
+ )
+ }
+ guard let processIdentifier = backend.codexProcessIdentifier(),
+ let roots = backend.rootSnapshots(
+ for: processIdentifier,
+ budget: budget
+ ) else {
+ return CodexAccessibilityRightPanelSample(
+ state: .unknown,
+ permission: .authorized
+ )
+ }
+ return CodexAccessibilityRightPanelSample(
+ state: CodexAccessibilityRightPanelClassifier.state(in: roots),
+ permission: .authorized
+ )
+ }
+}
+
+private final class SystemCodexAccessibilityBackend:
+ CodexAccessibilityReading,
+ @unchecked Sendable
+{
+ private let clock = ContinuousClock()
+
+ func isTrusted() -> Bool {
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false]
+ as CFDictionary
+ return AXIsProcessTrustedWithOptions(options)
+ }
+
+ func codexProcessIdentifier() -> pid_t? {
+ NSRunningApplication.runningApplications(
+ withBundleIdentifier: "com.openai.codex"
+ ).first(where: { !$0.isTerminated })?.processIdentifier
+ }
+
+ func rootSnapshots(
+ for processIdentifier: pid_t,
+ budget: CodexAccessibilityTraversalBudget
+ ) -> [CodexAccessibilityElementSnapshot]? {
+ let application = AXUIElementCreateApplication(processIdentifier)
+ _ = AXUIElementSetMessagingTimeout(application, 0.05)
+ guard let window = selectedStandardWindow(in: application),
+ let windowFrame = frameValue(window) else {
+ return nil
+ }
+
+ let deadline = clock.now.advanced(by: budget.maximumDuration)
+ var nodeCount = 0
+ guard let mainResult = findMainLandmark(
+ from: window,
+ inside: windowFrame,
+ nodeCount: &nodeCount,
+ deadline: deadline,
+ budget: budget
+ ), let mainFrame = frameValue(mainResult.element),
+ let shellRow = findUniqueShellRow(
+ below: mainResult.element,
+ mainFrame: mainFrame,
+ startingDepth: mainResult.depth,
+ nodeCount: &nodeCount,
+ deadline: deadline,
+ budget: budget
+ ),
+ let shellChildren = elementArrayValue(
+ shellRow,
+ kAXChildrenAttribute
+ )
+ else { return nil }
+
+ guard nodeCount + shellChildren.count <= budget.maximumNodes,
+ clock.now < deadline else { return nil }
+ nodeCount += shellChildren.count
+
+ let childSnapshots = shellChildren.map { shallowSnapshot($0) }
+ let shellSnapshot = shallowSnapshot(
+ shellRow,
+ children: childSnapshots
+ )
+ guard clock.now < deadline else { return nil }
+ let mainSnapshot = shallowSnapshot(
+ mainResult.element,
+ children: [shellSnapshot]
+ )
+ return [mainSnapshot]
+ }
+
+ private func selectedStandardWindow(
+ in application: AXUIElement
+ ) -> AXUIElement? {
+ let focused = elementValue(application, kAXFocusedWindowAttribute)
+ if let focused, isStandardWindow(focused) {
+ return focused
+ }
+ guard let windows = elementArrayValue(application, kAXWindowsAttribute)
+ else { return nil }
+ return windows.filter(isStandardWindow).max(by: {
+ area(frameValue($0)) < area(frameValue($1))
+ })
+ }
+
+ private func findMainLandmark(
+ from root: AXUIElement,
+ inside windowFrame: CGRect,
+ nodeCount: inout Int,
+ deadline: ContinuousClock.Instant,
+ budget: CodexAccessibilityTraversalBudget
+ ) -> (element: AXUIElement, depth: Int)? {
+ var queue: [(element: AXUIElement, depth: Int)] = [(root, 0)]
+ while !queue.isEmpty {
+ guard nodeCount < budget.maximumNodes,
+ clock.now < deadline else { return nil }
+ let item = queue.removeFirst()
+ nodeCount += 1
+
+ let role = stringValue(item.element, kAXRoleAttribute)
+ let subrole = stringValue(item.element, kAXSubroleAttribute)
+ let frame = frameValue(item.element)
+ if subrole == "AXLandmarkMain",
+ let frame,
+ frame.width >= windowFrame.width * 0.5,
+ frame.height >= windowFrame.height * 0.5 {
+ return item
+ }
+
+ guard item.depth < budget.maximumDepth,
+ role == kAXWindowRole
+ || role == kAXGroupRole
+ || role == "AXWebArea"
+ || role == kAXScrollAreaRole,
+ let children = elementArrayValue(
+ item.element,
+ kAXChildrenAttribute
+ ) else { continue }
+ for child in children where shouldTraverse(
+ child,
+ inside: windowFrame
+ ) {
+ queue.append((child, item.depth + 1))
+ }
+ }
+ return nil
+ }
+
+ private func findUniqueShellRow(
+ below main: AXUIElement,
+ mainFrame: CGRect,
+ startingDepth: Int,
+ nodeCount: inout Int,
+ deadline: ContinuousClock.Instant,
+ budget: CodexAccessibilityTraversalBudget
+ ) -> AXUIElement? {
+ guard startingDepth < budget.maximumDepth,
+ let children = elementArrayValue(main, kAXChildrenAttribute)
+ else { return nil }
+
+ return CodexAccessibilityTargetedTraversal.uniqueMatch(
+ among: children,
+ startingDepth: startingDepth + 1,
+ nodeCount: &nodeCount,
+ budget: budget,
+ isBeforeDeadline: { self.clock.now < deadline },
+ isMatch: { element in
+ let classTokens = Set(
+ self.stringArrayValue(element, "AXDOMClassList") ?? []
+ )
+ return CodexAccessibilityRightPanelClassifier
+ .shellRowClassTokens.isSubset(of: classTokens)
+ && self.approximatelyEqual(
+ self.frameValue(element),
+ mainFrame
+ )
+ },
+ children: {
+ self.elementArrayValue($0, kAXChildrenAttribute)
+ },
+ shouldTraverse: {
+ self.shouldTraverse($0, inside: mainFrame)
+ }
+ )
+ }
+
+ private func shouldTraverse(
+ _ element: AXUIElement,
+ inside windowFrame: CGRect
+ ) -> Bool {
+ guard let role = stringValue(element, kAXRoleAttribute),
+ role == kAXGroupRole
+ || role == "AXWebArea"
+ || role == kAXScrollAreaRole else { return false }
+ guard let frame = frameValue(element) else { return true }
+ guard frame.intersects(windowFrame), frame.width > 0, frame.height > 0
+ else { return false }
+ if stringValue(element, kAXSubroleAttribute) == "AXLandmarkMain" {
+ return true
+ }
+ return area(frame) >= area(windowFrame) * 0.08
+ }
+
+ private func shallowSnapshot(
+ _ element: AXUIElement,
+ children: [CodexAccessibilityElementSnapshot] = []
+ ) -> CodexAccessibilityElementSnapshot {
+ CodexAccessibilityElementSnapshot(
+ role: stringValue(element, kAXRoleAttribute) ?? "",
+ subrole: stringValue(element, kAXSubroleAttribute),
+ frame: frameValue(element),
+ classTokens: Set(
+ stringArrayValue(element, "AXDOMClassList") ?? []
+ ),
+ children: children
+ )
+ }
+
+ private func approximatelyEqual(
+ _ lhs: CGRect?,
+ _ rhs: CGRect,
+ tolerance: CGFloat = 3
+ ) -> Bool {
+ guard let lhs else { return false }
+ return abs(lhs.minX - rhs.minX) <= tolerance
+ && abs(lhs.minY - rhs.minY) <= tolerance
+ && abs(lhs.maxX - rhs.maxX) <= tolerance
+ && abs(lhs.maxY - rhs.maxY) <= tolerance
+ }
+
+ private func isStandardWindow(_ element: AXUIElement) -> Bool {
+ stringValue(element, kAXRoleAttribute) == kAXWindowRole
+ && stringValue(element, kAXSubroleAttribute)
+ == kAXStandardWindowSubrole
+ }
+
+ private func stringValue(
+ _ element: AXUIElement,
+ _ attribute: String
+ ) -> String? {
+ copyValue(element, attribute) as? String
+ }
+
+ private func stringArrayValue(
+ _ element: AXUIElement,
+ _ attribute: String
+ ) -> [String]? {
+ copyValue(element, attribute) as? [String]
+ }
+
+ private func elementValue(
+ _ element: AXUIElement,
+ _ attribute: String
+ ) -> AXUIElement? {
+ guard let value = copyValue(element, attribute),
+ CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil }
+ return unsafeBitCast(value, to: AXUIElement.self)
+ }
+
+ private func elementArrayValue(
+ _ element: AXUIElement,
+ _ attribute: String
+ ) -> [AXUIElement]? {
+ copyValue(element, attribute) as? [AXUIElement]
+ }
+
+ private func frameValue(_ element: AXUIElement) -> CGRect? {
+ guard let positionValue = copyValue(element, kAXPositionAttribute),
+ let sizeValue = copyValue(element, kAXSizeAttribute),
+ CFGetTypeID(positionValue) == AXValueGetTypeID(),
+ CFGetTypeID(sizeValue) == AXValueGetTypeID() else { return nil }
+ let positionAXValue = unsafeBitCast(
+ positionValue,
+ to: AXValue.self
+ )
+ let sizeAXValue = unsafeBitCast(sizeValue, to: AXValue.self)
+ var position = CGPoint.zero
+ var size = CGSize.zero
+ guard AXValueGetValue(positionAXValue, .cgPoint, &position),
+ AXValueGetValue(sizeAXValue, .cgSize, &size) else { return nil }
+ return CGRect(origin: position, size: size)
+ }
+
+ private func copyValue(
+ _ element: AXUIElement,
+ _ attribute: String
+ ) -> CFTypeRef? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(
+ element,
+ attribute as CFString,
+ &value
+ ) == .success else { return nil }
+ return value
+ }
+
+ private func area(_ frame: CGRect?) -> CGFloat {
+ guard let frame else { return 0 }
+ return frame.width * frame.height
+ }
+}
diff --git a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
index b145d3d..7100a34 100644
--- a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
+++ b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift
@@ -58,6 +58,14 @@ struct CodexNotesProbeApp: App {
@AppStorage(AppLanguagePreference.key)
private var storedLanguagePreference = AppLanguagePreference.defaultValue.rawValue
+ init() {
+ if let bundleIdentifier = Bundle.main.bundleIdentifier {
+ RightPanelAvoidancePreference.migrateIfNeeded(
+ persistentDomainName: bundleIdentifier
+ )
+ }
+ }
+
private var languagePreference: AppLanguagePreference {
AppLanguagePreference.normalized(storedLanguagePreference)
}
diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift
index aad12de..0b57a8d 100644
--- a/Sources/CodexNotesProbe/ContentView.swift
+++ b/Sources/CodexNotesProbe/ContentView.swift
@@ -2,20 +2,53 @@ import AppKit
import CodexNotesCore
import SwiftUI
+@MainActor
+enum MainWindowContentSizingPolicy {
+ static let idealHeight: CGFloat = 660
+
+ static func minimumWidth(isCollapsed: Bool) -> CGFloat {
+ isCollapsed
+ ? MainWindowCompactController.compactWindowWidth
+ : MainWindowCompactController.minimumExpandedContentSize.width
+ }
+
+ static func minimumHeight(isCollapsed: Bool) -> CGFloat {
+ isCollapsed
+ ? MainWindowCompactController.compactContentHeight
+ : 520
+ }
+
+ /// AppKit owns the main window's compact/expanded frame. Returning no fixed
+ /// SwiftUI height prevents `.windowResizability(.contentMinSize)` from
+ /// performing a second resize anchored to the opposite window edge after
+ /// the controller has already applied its top-anchored frame.
+ static func fixedHeight(
+ isCompactPresentationActive _: Bool
+ ) -> CGFloat? {
+ nil
+ }
+}
+
struct ContentView: View {
@ObservedObject var model: ProbeViewModel
@ObservedObject var updateCoordinator: UpdateCheckCoordinator
@ObservedObject var globalHotKeyController: GlobalHotKeyController
let languagePreference: AppLanguagePreference
@StateObject private var editorController = MarkdownEditorController()
+ @StateObject private var compactController = MainWindowCompactController()
@Environment(\.colorScheme) private var inheritedColorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.accessibilityReduceTransparency)
+ private var reduceTransparency
@AppStorage(EditorFontSizePreference.key)
private var storedEditorFontSize = EditorFontSizePreference.defaultValue
@AppStorage(EditorLineSpacingPreference.key)
private var storedEditorLineSpacing = EditorLineSpacingPreference.defaultValue
@AppStorage(NoteThemePreference.key)
private var storedThemeID = NoteThemePreference.defaultValue.rawValue
+ @AppStorage(RightPanelAvoidancePreference.key)
+ private var rightPanelAvoidanceEnabled =
+ RightPanelAvoidancePreference.defaultValue
@State private var isShortcutReferencePresented = false
private var activeTheme: NoteThemeID {
@@ -63,11 +96,24 @@ struct ContentView: View {
.padding(.top, BottomBarActionMetrics.topContentInset)
.padding(.bottom, BottomBarActionMetrics.bottomPadding)
.frame(
- minWidth: BottomBarActionMetrics.minimumWindowWidth,
+ minWidth: MainWindowContentSizingPolicy.minimumWidth(
+ isCollapsed: compactController.isCollapsed
+ ),
idealWidth: 400,
- minHeight: 520,
- idealHeight: 660
+ minHeight: MainWindowContentSizingPolicy.minimumHeight(
+ isCollapsed: compactController.isCollapsed
+ ),
+ idealHeight: MainWindowContentSizingPolicy.idealHeight
+ )
+ .frame(
+ height: MainWindowContentSizingPolicy.fixedHeight(
+ isCompactPresentationActive:
+ compactController.isCompactContentPresentationActive
+ ),
+ alignment: .top
)
+ .clipped()
+ .allowsHitTesting(!compactController.isCollapsed)
.foregroundStyle(palette.primaryText.color)
.tint(palette.accent.color)
.environment(\.locale, resolvedLanguage.locale)
@@ -86,11 +132,24 @@ struct ContentView: View {
BordeauxCottonPaperTextureOverlay()
}
}
+ // Keep the AppKit configurator alive while compact, but hide every
+ // SwiftUI visual layer above it. Applying opacity before the theme
+ // background left the 3pt compact content sliver fully opaque, which
+ // appeared as a pale line and also covered content behind the bar.
+ .opacity(compactController.isCompactContentPresentationActive ? 0 : 1)
.background(
WindowConfigurator(
appearanceName: palette.appearanceName,
backgroundColor: palette.windowBackground.nsColor,
- languageRevision: languageRevision
+ languageRevision: languageRevision,
+ compactController: compactController,
+ editorController: editorController,
+ rightPanelSelectionStableKey:
+ model.rightPanelObservation.selectionStableKey,
+ rightPanelState: model.rightPanelObservation.state,
+ rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
)
)
.background(
@@ -102,7 +161,13 @@ struct ContentView: View {
.frame(width: 0, height: 0)
.accessibilityHidden(true)
)
- .onAppear { model.start() }
+ .onAppear {
+ model.setRightPanelAvoidanceEnabled(rightPanelAvoidanceEnabled)
+ model.start()
+ }
+ .onChange(of: rightPanelAvoidanceEnabled) { _, isEnabled in
+ model.setRightPanelAvoidanceEnabled(isEnabled)
+ }
.alert(
L10n.text(.moveSelectionAlertTitle),
isPresented: Binding(
@@ -1950,6 +2015,13 @@ struct WindowConfigurator: NSViewRepresentable {
let appearanceName: NSAppearance.Name?
let backgroundColor: NSColor
let languageRevision: String
+ let compactController: MainWindowCompactController
+ let editorController: MarkdownEditorController
+ let rightPanelSelectionStableKey: String?
+ let rightPanelState: CodexRightPanelState
+ let rightPanelAvoidanceEnabled: Bool
+ let reduceMotion: Bool
+ let reduceTransparency: Bool
@MainActor
final class Coordinator: NSObject, NSWindowDelegate {
@@ -1965,6 +2037,9 @@ struct WindowConfigurator: NSViewRepresentable {
private let codexAvailabilityMonitor: CodexApplicationAvailabilityObserving
private let closeButtonHoverHintController = CloseButtonHoverHintController()
private let resizeCursorController = MainWindowResizeCursorController()
+ private weak var compactController: MainWindowCompactController?
+ private var compactSelectionStableKey: String?
+ private var compactRightPanelState: CodexRightPanelState?
private var appliedLanguageRevision: String?
private var isInvalidated = false
@@ -1976,10 +2051,32 @@ struct WindowConfigurator: NSViewRepresentable {
super.init()
}
- func attach(to window: NSWindow, languageRevision: String) {
+ func attach(
+ to window: NSWindow,
+ languageRevision: String,
+ compactController: MainWindowCompactController? = nil,
+ editorController: MarkdownEditorController? = nil,
+ backgroundColor: NSColor = .windowBackgroundColor,
+ rightPanelSelectionStableKey: String? = nil,
+ rightPanelState: CodexRightPanelState = .unknown,
+ rightPanelAvoidanceEnabled: Bool =
+ RightPanelAvoidancePreference.defaultValue,
+ reduceMotion: Bool = false,
+ reduceTransparency: Bool = false
+ ) {
guard !isInvalidated,
window.identifier == CodexNotesWindowIdentifier.main else { return }
if self.window === window {
+ configureCompactBehavior(
+ compactController: compactController,
+ editorController: editorController,
+ backgroundColor: backgroundColor,
+ rightPanelSelectionStableKey: rightPanelSelectionStableKey,
+ rightPanelState: rightPanelState,
+ rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
+ )
refreshLocalizationIfNeeded(
for: window,
languageRevision: languageRevision
@@ -1994,6 +2091,16 @@ struct WindowConfigurator: NSViewRepresentable {
self.window = window
originalWindowDelegate = window.delegate
window.delegate = self
+ configureCompactBehavior(
+ compactController: compactController,
+ editorController: editorController,
+ backgroundColor: backgroundColor,
+ rightPanelSelectionStableKey: rightPanelSelectionStableKey,
+ rightPanelState: rightPanelState,
+ rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
+ )
codexAvailabilityMonitor.start { [weak self] isAvailable in
self?.codexAvailabilityDidChange(isAvailable)
}
@@ -2079,13 +2186,18 @@ struct WindowConfigurator: NSViewRepresentable {
)
}
restoreDefaultSizeObserver = nil
- if let window {
+ if let window,
+ compactController?.shouldPersistCurrentFrame != false {
MainWindowFramePersistence.persistIfVisible(window: window)
}
framePersistenceObservers.forEach(
NotificationCenter.default.removeObserver
)
framePersistenceObservers.removeAll()
+ compactController?.detach()
+ compactController = nil
+ compactSelectionStableKey = nil
+ compactRightPanelState = nil
if window?.delegate === self {
window?.delegate = originalWindowDelegate
}
@@ -2105,7 +2217,8 @@ struct WindowConfigurator: NSViewRepresentable {
func refreshCloseButtonHoverHint(for window: NSWindow) {
guard !isInvalidated, self.window === window else { return }
- guard window.identifier == CodexNotesWindowIdentifier.main,
+ guard compactController?.isCollapsed != true,
+ window.identifier == CodexNotesWindowIdentifier.main,
let closeButton = window.standardWindowButton(.closeButton) else {
closeButtonHoverHintController.detach()
return
@@ -2115,7 +2228,11 @@ struct WindowConfigurator: NSViewRepresentable {
func refreshResizeCursorTracking(for window: NSWindow) {
guard !isInvalidated, self.window === window else { return }
- resizeCursorController.attach(to: window)
+ if compactController?.isCollapsed == true {
+ resizeCursorController.detach()
+ } else {
+ resizeCursorController.attach(to: window)
+ }
}
func refreshLocalizationIfNeeded(
@@ -2129,6 +2246,74 @@ struct WindowConfigurator: NSViewRepresentable {
closeButtonHoverHintController.cancelAndDismiss()
}
+ func configureCompactBehavior(
+ compactController: MainWindowCompactController?,
+ editorController: MarkdownEditorController?,
+ backgroundColor: NSColor,
+ rightPanelSelectionStableKey: String?,
+ rightPanelState: CodexRightPanelState,
+ rightPanelAvoidanceEnabled: Bool,
+ reduceMotion: Bool,
+ reduceTransparency: Bool
+ ) {
+ guard !isInvalidated, let window = self.window else { return }
+
+ if let previous = self.compactController,
+ previous !== compactController {
+ previous.detach()
+ compactSelectionStableKey = nil
+ compactRightPanelState = nil
+ }
+ self.compactController = compactController
+
+ guard let compactController, let editorController else { return }
+ compactController.attach(
+ to: window,
+ editorController: editorController,
+ backgroundColor: backgroundColor,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
+ )
+ compactController.setAutomaticAvoidanceEnabled(
+ rightPanelAvoidanceEnabled
+ )
+ compactController.compactStateDidChange = { [weak self, weak window] _ in
+ guard let self, let window else { return }
+ self.refreshCloseButtonHoverHint(for: window)
+ self.refreshResizeCursorTracking(for: window)
+ }
+ observeRightPanel(
+ rightPanelState,
+ selectionStableKey: rightPanelSelectionStableKey
+ )
+ }
+
+ private func observeRightPanel(
+ _ state: CodexRightPanelState,
+ selectionStableKey: String?
+ ) {
+ guard let compactController, let selectionStableKey else { return }
+
+ if compactRightPanelState == nil {
+ compactSelectionStableKey = selectionStableKey
+ compactRightPanelState = state
+ compactController.observeRightPanel(state)
+ return
+ }
+
+ if compactSelectionStableKey != selectionStableKey {
+ compactSelectionStableKey = selectionStableKey
+ compactRightPanelState = state
+ compactController.selectionDidChange(seeding: state)
+ return
+ }
+
+ guard compactRightPanelState != state else { return }
+ compactSelectionStableKey = selectionStableKey
+ compactRightPanelState = state
+ compactController.observeRightPanel(state)
+ }
+
func windowShouldZoom(
_ window: NSWindow,
toFrame newFrame: NSRect
@@ -2157,6 +2342,7 @@ struct WindowConfigurator: NSViewRepresentable {
}
private func restoreDefaultWindowSize() {
+ compactController?.prepareForDefaultSizeRestore()
guard let window,
MainWindowFramePersistence.restoreDefaultSize(
window: window
@@ -2168,6 +2354,7 @@ struct WindowConfigurator: NSViewRepresentable {
}
private func presentMainWindow(_ window: NSWindow) {
+ compactController?.userDidShowWindow()
let frontmostApplication = NSWorkspace.shared.frontmostApplication
let frontmostBundleIdentifier = frontmostApplication?.bundleIdentifier
let companionBundleIdentifier = Bundle.main.bundleIdentifier
@@ -2206,7 +2393,9 @@ struct WindowConfigurator: NSViewRepresentable {
private func hideMainWindow(_ window: NSWindow) {
cancelCodexActivationTimeout()
visibilityState.recordManualHide()
- MainWindowFramePersistence.persist(window: window)
+ if compactController?.shouldPersistCurrentFrame != false {
+ MainWindowFramePersistence.persist(window: window)
+ }
orderOutMainWindow(window)
}
@@ -2233,9 +2422,11 @@ struct WindowConfigurator: NSViewRepresentable {
? NSApp
: window,
queue: .main
- ) { [weak window] _ in
- guard let window else { return }
+ ) { [weak self, weak window] _ in
+ guard let self, let window else { return }
MainActor.assumeIsolated {
+ guard self.compactController?
+ .shouldPersistCurrentFrame != false else { return }
MainWindowFramePersistence.persistIfVisible(
window: window
)
@@ -2368,7 +2559,11 @@ struct WindowConfigurator: NSViewRepresentable {
if NSApp.isHidden {
NSApp.unhideWithoutActivation()
}
- MainWindowFramePersistence.showPreservingFrame(window: window)
+ if compactController?.isCollapsed == true {
+ compactController?.presentPreservingTransientFrame()
+ } else {
+ MainWindowFramePersistence.showPreservingFrame(window: window)
+ }
case .hide:
orderOutMainWindow(window)
case .none:
@@ -2399,7 +2594,10 @@ struct WindowConfigurator: NSViewRepresentable {
DispatchQueue.main.async {
guard let window = view.window else { return }
applyTheme(to: window)
- MainWindowChromePolicy.apply(to: window)
+ MainWindowChromePolicy.apply(
+ to: window,
+ isCollapsed: compactController.isCollapsed
+ )
window.level = .floating
window.hidesOnDeactivate = false
window.collectionBehavior.insert(.fullScreenAuxiliary)
@@ -2411,7 +2609,15 @@ struct WindowConfigurator: NSViewRepresentable {
}
coordinator.attach(
to: window,
- languageRevision: languageRevision
+ languageRevision: languageRevision,
+ compactController: compactController,
+ editorController: editorController,
+ backgroundColor: backgroundColor,
+ rightPanelSelectionStableKey: rightPanelSelectionStableKey,
+ rightPanelState: rightPanelState,
+ rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
)
}
return view
@@ -2421,12 +2627,25 @@ struct WindowConfigurator: NSViewRepresentable {
DispatchQueue.main.async {
guard let window = nsView.window else { return }
applyTheme(to: window)
- MainWindowChromePolicy.apply(to: window)
+ MainWindowChromePolicy.apply(
+ to: window,
+ isCollapsed: compactController.isCollapsed
+ )
context.coordinator.refreshLocalizationIfNeeded(
for: window,
languageRevision: languageRevision
)
context.coordinator.refreshCloseButtonHoverHint(for: window)
+ context.coordinator.configureCompactBehavior(
+ compactController: compactController,
+ editorController: editorController,
+ backgroundColor: backgroundColor,
+ rightPanelSelectionStableKey: rightPanelSelectionStableKey,
+ rightPanelState: rightPanelState,
+ rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
+ )
context.coordinator.refreshResizeCursorTracking(for: window)
}
}
@@ -2443,7 +2662,8 @@ struct WindowConfigurator: NSViewRepresentable {
} else if window.appearance != nil {
window.appearance = nil
}
- if !window.backgroundColor.isEqual(backgroundColor) {
+ if !compactController.isCollapsed,
+ !window.backgroundColor.isEqual(backgroundColor) {
window.backgroundColor = backgroundColor
}
}
diff --git a/Sources/CodexNotesProbe/MainWindowChromePolicy.swift b/Sources/CodexNotesProbe/MainWindowChromePolicy.swift
index 00122d7..19a8d93 100644
--- a/Sources/CodexNotesProbe/MainWindowChromePolicy.swift
+++ b/Sources/CodexNotesProbe/MainWindowChromePolicy.swift
@@ -7,13 +7,14 @@ enum MainWindowChromePolicy {
@MainActor
static func apply(
to window: NSWindow,
+ isCollapsed: Bool = false,
localization: AppLocalization = AppLocalization(
preference: AppLanguagePreference.load()
)
) {
if let closeButton = window.standardWindowButton(.closeButton) {
- closeButton.isEnabled = true
- closeButton.isHidden = false
+ closeButton.isEnabled = !isCollapsed
+ closeButton.isHidden = isCollapsed
// AppKit's native tooltip intentionally waits several seconds.
// The hover controller supplies a fast, local hint instead.
closeButton.toolTip = nil
diff --git a/Sources/CodexNotesProbe/MainWindowCompactController.swift b/Sources/CodexNotesProbe/MainWindowCompactController.swift
new file mode 100644
index 0000000..bb6d3f7
--- /dev/null
+++ b/Sources/CodexNotesProbe/MainWindowCompactController.swift
@@ -0,0 +1,976 @@
+import AppKit
+import Combine
+import CodexNotesCore
+import QuartzCore
+
+enum MainWindowCompactGeometry {
+ static func compactFrame(
+ from expandedFrame: NSRect,
+ compactWidth: CGFloat,
+ compactHeight: CGFloat
+ ) -> NSRect {
+ let width = max(1, min(compactWidth, expandedFrame.width))
+ let height = max(1, min(compactHeight, expandedFrame.height))
+ return NSRect(
+ x: expandedFrame.maxX - width,
+ y: expandedFrame.maxY - height,
+ width: width,
+ height: height
+ )
+ }
+
+ static func expandedFrame(
+ from compactFrame: NSRect,
+ cachedExpandedFrame: NSRect
+ ) -> NSRect {
+ NSRect(
+ x: compactFrame.maxX - cachedExpandedFrame.width,
+ y: compactFrame.maxY - cachedExpandedFrame.height,
+ width: cachedExpandedFrame.width,
+ height: cachedExpandedFrame.height
+ )
+ }
+
+ static func expandedFrame(
+ from compactFrame: NSRect,
+ cachedExpandedFrame: NSRect,
+ constrainedTo visibleFrame: NSRect
+ ) -> NSRect {
+ let unconstrained = expandedFrame(
+ from: compactFrame,
+ cachedExpandedFrame: cachedExpandedFrame
+ )
+ let size = NSSize(
+ width: min(unconstrained.width, visibleFrame.width),
+ height: min(unconstrained.height, visibleFrame.height)
+ )
+ let maximumX = max(visibleFrame.minX, visibleFrame.maxX - size.width)
+ let maximumY = max(visibleFrame.minY, visibleFrame.maxY - size.height)
+ return NSRect(
+ x: min(max(unconstrained.minX, visibleFrame.minX), maximumX),
+ y: min(max(unconstrained.minY, visibleFrame.minY), maximumY),
+ width: size.width,
+ height: size.height
+ )
+ }
+}
+
+private enum MainWindowExpandedFramePositioning {
+ /// A deliberate user expansion follows the current compact bar, so moving
+ /// the bar remains a useful way to choose where the full window reappears.
+ case followCompactBar
+
+ /// An automatic expansion is the inverse of an automatic collapse. Restore
+ /// the complete cached frame without treating incidental compact-bar layout
+ /// changes as a user move. Fall back to the bar only when that cached frame
+ /// is no longer visible on any current display.
+ case restoreCachedFrameIfVisible
+}
+
+@MainActor
+final class MainWindowCompactController: ObservableObject {
+ static let minimumExpandedContentSize = NSSize(width: 340, height: 520)
+ /// The compact window is a top-right anchored pill containing only the
+ /// centered app title and the expand chevron. This leaves comfortable
+ /// native-titlebar spacing without covering a full column of Codex.
+ static let compactWindowWidth: CGFloat = 190
+ // Keep a small content sliver below the native titlebar. A 1pt sliver makes
+ // the 28pt titlebar accessory extend past the compact window by roughly one
+ // point on current macOS builds, which can clip the chevron while AppKit is
+ // laying out the final frame.
+ static let compactContentHeight: CGFloat = 3
+ // AppKit briefly composites a near-zero-height native titlebar as a bare
+ // line when an NSWindow frame is animated down to titlebar-only height.
+ // The artifact survives ordering and SwiftUI layout fixes, so compact frame
+ // changes are deliberately atomic. Other accessibility motion behavior is
+ // still respected by the rest of the app.
+ static let animatesCompactFrameTransitions = false
+ static let transitionDuration: TimeInterval = 0.18
+ static let unknownFailOpenDelay: TimeInterval = 1
+ // Window-level opacity is required here: changing only backgroundColor does
+ // not affect AppKit's native titlebar chrome. Hover or keyboard focus raises
+ // contrast while the resting bar stays translucent enough to reveal Codex.
+ static let restingWindowOpacity: CGFloat = 0.42
+ static let activeWindowOpacity: CGFloat = 0.68
+
+ @Published private(set) var isCollapsed = false
+ @Published private(set) var isCompactContentPresentationActive = false
+ private(set) var isAutomaticAvoidanceEnabled =
+ RightPanelAvoidancePreference.defaultValue
+
+ private weak var window: NSWindow?
+ private weak var editorController: MarkdownEditorController?
+ private var interactionState = MainWindowCompactState()
+ private var expandedFrame: NSRect?
+ private var isTransitioning = false
+ private var transitionTargetIsCollapsed: Bool?
+ private var pendingTransitionAction: (
+ action: MainWindowCompactAction,
+ automatic: Bool
+ )?
+ private var pendingAutomaticCollapse = false
+ private var pendingManualCollapse = false
+ private var pendingRetryWorkItem: DispatchWorkItem?
+ private var unknownFailOpenWorkItem: DispatchWorkItem?
+ private var latestRightPanelObservation: CodexRightPanelState = .unknown
+ private var windowObservers: [NSObjectProtocol] = []
+ private var backgroundColor = NSColor.windowBackgroundColor
+ private var expandedWindowAlpha: CGFloat?
+ private var expandedTitlebarSeparatorStyle: NSTitlebarSeparatorStyle?
+ private var reduceMotion = false
+ private var reduceTransparency = false
+ private var isPointerInside = false
+ private var transitionID = UUID()
+ private let framePersistenceDefaults: UserDefaults
+ private let accessoryController = MainWindowCompactAccessoryController()
+ private let hoverController = MainWindowCompactHoverController()
+
+ init(framePersistenceDefaults: UserDefaults = .standard) {
+ self.framePersistenceDefaults = framePersistenceDefaults
+ }
+
+ var compactStateDidChange: ((Bool) -> Void)?
+
+ var shouldPersistCurrentFrame: Bool {
+ !isCollapsed && !isTransitioning
+ }
+
+ var isRightPanelOpen: Bool {
+ interactionState.lastDefiniteRightPanelState == .open
+ }
+
+ private var effectiveIsCollapsed: Bool {
+ transitionTargetIsCollapsed ?? isCollapsed
+ }
+
+ /// The compact chrome is intentionally a resting-state appearance. Applying
+ /// transparent titlebar chrome while AppKit is still shrinking the window
+ /// leaves an empty, near-transparent frame whose border reads as a single
+ /// line. Keep ordinary chrome throughout both animation directions and only
+ /// switch to the translucent compact bar after the frame has settled.
+ private var usesCompactAppearance: Bool {
+ isCollapsed && !isTransitioning
+ }
+
+ func attach(
+ to window: NSWindow,
+ editorController: MarkdownEditorController,
+ backgroundColor: NSColor,
+ reduceMotion: Bool,
+ reduceTransparency: Bool
+ ) {
+ if self.editorController !== editorController {
+ self.editorController?.textCompositionDidEnd = nil
+ }
+ if self.window !== window {
+ detach()
+ self.window = window
+ self.editorController = editorController
+ expandedWindowAlpha = window.alphaValue
+ expandedTitlebarSeparatorStyle = window.titlebarSeparatorStyle
+ installWindowObservers(for: window)
+ accessoryController.attach(to: window) { [weak self] in
+ self?.toggleFromTitlebar()
+ }
+ hoverController.attach(to: window) { [weak self] isInside in
+ guard let self else { return }
+ self.isPointerInside = isInside
+ self.applyAppearance()
+ }
+ } else {
+ self.editorController = editorController
+ accessoryController.ensureAttached(to: window)
+ hoverController.ensureAttached(to: window)
+ }
+ editorController.textCompositionDidEnd = { [weak self] in
+ self?.retryPendingCollapseIfPossible()
+ }
+
+ updateConfiguration(
+ backgroundColor: backgroundColor,
+ reduceMotion: reduceMotion,
+ reduceTransparency: reduceTransparency
+ )
+ accessoryController.update(
+ isCollapsed: isCollapsed,
+ isEnabled: !isTransitioning
+ )
+ }
+
+ func detach() {
+ pendingRetryWorkItem?.cancel()
+ pendingRetryWorkItem = nil
+ cancelUnknownFailOpen()
+ pendingAutomaticCollapse = false
+ pendingManualCollapse = false
+ pendingTransitionAction = nil
+ transitionTargetIsCollapsed = nil
+ compactStateDidChange = nil
+ transitionID = UUID()
+ if let window, isCollapsed {
+ let cachedFrame = expandedFrame ?? window.frame
+ let positioning: MainWindowExpandedFramePositioning =
+ interactionState.collapseOrigin == .automatic
+ ? .restoreCachedFrameIfVisible
+ : .followCompactBar
+ let restoredFrame = targetExpandedFrame(
+ for: window,
+ cachedFrame: cachedFrame,
+ positioning: positioning
+ )
+ window.styleMask.insert(.resizable)
+ window.contentMinSize = Self.minimumExpandedContentSize
+ setFrameImmediatelyCancellingAnimation(
+ restoredFrame,
+ on: window,
+ display: false
+ )
+ isCollapsed = false
+ isCompactContentPresentationActive = false
+ applyStandardButtonVisibility(isCompact: false)
+ applyAppearance()
+ }
+ isCompactContentPresentationActive = false
+ applyStandardButtonVisibility(isCompact: false)
+ windowObservers.forEach(NotificationCenter.default.removeObserver)
+ windowObservers.removeAll()
+ accessoryController.detach()
+ hoverController.detach()
+ editorController?.textCompositionDidEnd = nil
+ window = nil
+ editorController = nil
+ expandedFrame = nil
+ expandedWindowAlpha = nil
+ expandedTitlebarSeparatorStyle = nil
+ isPointerInside = false
+ isTransitioning = false
+ interactionState = MainWindowCompactState()
+ }
+
+ func updateConfiguration(
+ backgroundColor: NSColor,
+ reduceMotion: Bool,
+ reduceTransparency: Bool
+ ) {
+ self.backgroundColor = backgroundColor
+ self.reduceMotion = reduceMotion
+ self.reduceTransparency = reduceTransparency
+ accessoryController.update(
+ isCollapsed: isCollapsed,
+ isEnabled: !isTransitioning
+ )
+ applyStandardButtonVisibility(isCompact: isCollapsed)
+ applyAppearance()
+ }
+
+ func seedRightPanel(_ state: CodexRightPanelState) {
+ cancelUnknownFailOpen()
+ latestRightPanelObservation = state
+ pendingAutomaticCollapse = false
+ guard isAutomaticAvoidanceEnabled else { return }
+ interactionState.seedRightPanel(state)
+ }
+
+ /// Enables or disables only Codex-driven compact actions. The title-bar
+ /// toggle remains wired independently, so manual collapse/expand is always
+ /// available. Disabling also cancels every deferred automatic action and
+ /// restores a compact frame only when automatic avoidance owns it.
+ func setAutomaticAvoidanceEnabled(_ isEnabled: Bool) {
+ guard isAutomaticAvoidanceEnabled != isEnabled else { return }
+ isAutomaticAvoidanceEnabled = isEnabled
+ cancelUnknownFailOpen()
+ pendingAutomaticCollapse = false
+ if pendingTransitionAction?.automatic == true {
+ pendingTransitionAction = nil
+ }
+
+ guard isEnabled else {
+ let action = interactionState.disableAutomaticAvoidance(
+ isCollapsed: effectiveIsCollapsed
+ )
+ perform(action, automatic: true)
+ return
+ }
+
+ // Do not consume the last pre-disable sample. Settings changes and the
+ // asynchronous SwiftUI window update have no guaranteed ordering, so a
+ // cached `.open` could otherwise collapse the window after the sidebar
+ // had already closed. The detector publishes a fresh observation for
+ // the new preference generation.
+ latestRightPanelObservation = .unknown
+ interactionState.seedRightPanel(.unknown)
+ }
+
+ func selectionDidChange(seeding state: CodexRightPanelState) {
+ // The right panel is physical window state, not task state. Preserve
+ // the pre-open snapshot across task changes and only end the cycle on a
+ // definite close. This branch is retained for compatibility with older
+ // observation identities; the current producer uses one window identity.
+ latestRightPanelObservation = state
+ guard isAutomaticAvoidanceEnabled else { return }
+
+ if state != .unknown {
+ observeRightPanel(state)
+ return
+ }
+
+ cancelUnknownFailOpen()
+ latestRightPanelObservation = .unknown
+ pendingAutomaticCollapse = false
+ guard isCollapsed,
+ interactionState.collapseOrigin == .automatic else { return }
+ interactionState.releaseAutomaticCollapseForUncertainty()
+ perform(.expand, automatic: true)
+ }
+
+ func observeRightPanel(_ state: CodexRightPanelState) {
+ latestRightPanelObservation = state
+ guard isAutomaticAvoidanceEnabled, let window else { return }
+ if state == .unknown {
+ let hasAutomaticOwnership =
+ interactionState.collapseOrigin == .automatic
+ pendingAutomaticCollapse = false
+ if pendingTransitionAction?.automatic == true,
+ pendingTransitionAction?.action == .collapse {
+ pendingTransitionAction = nil
+ }
+ if hasAutomaticOwnership {
+ scheduleUnknownFailOpenIfNeeded()
+ }
+ } else {
+ cancelUnknownFailOpen()
+ }
+ let action = interactionState.observeRightPanel(
+ state,
+ isWindowVisible: window.isVisible,
+ isCollapsed: effectiveIsCollapsed
+ )
+ if state == .closed {
+ pendingAutomaticCollapse = false
+ }
+ if isTransitioning,
+ action == .none,
+ let transitionTargetIsCollapsed,
+ pendingTransitionAction?.automatic == true,
+ (state == .open && transitionTargetIsCollapsed
+ || state == .closed && !transitionTargetIsCollapsed) {
+ pendingTransitionAction = nil
+ }
+ perform(action, automatic: true)
+ }
+
+ func userDidShowWindow() {
+ cancelUnknownFailOpen()
+ pendingAutomaticCollapse = false
+ let action = interactionState.recordManualShow(
+ isCollapsed: effectiveIsCollapsed
+ )
+ perform(action, automatic: false)
+ }
+
+ func prepareForDefaultSizeRestore() {
+ cancelUnknownFailOpen()
+ pendingAutomaticCollapse = false
+ pendingManualCollapse = false
+ guard isCollapsed else { return }
+ let action = interactionState.recordManualToggle(isCollapsed: true)
+ if action == .expand {
+ expand(
+ animated: false,
+ positioning: .followCompactBar
+ )
+ }
+ }
+
+ func presentPreservingTransientFrame(
+ _ present: @MainActor (NSWindow) -> Void = { $0.orderFrontRegardless() }
+ ) {
+ guard let window else { return }
+ let transientFrame = window.frame
+ _ = window.setFrameAutosaveName("")
+ present(window)
+ window.setFrame(transientFrame, display: false)
+ _ = window.setFrameAutosaveName("")
+ }
+
+ func toggleFromTitlebar() {
+ cancelUnknownFailOpen()
+ let action = interactionState.recordManualToggle(
+ isCollapsed: effectiveIsCollapsed
+ )
+ pendingAutomaticCollapse = false
+ perform(action, automatic: false)
+ }
+
+ private func perform(
+ _ action: MainWindowCompactAction,
+ automatic: Bool
+ ) {
+ guard action != .none else { return }
+ if isTransitioning {
+ pendingTransitionAction = (action, automatic)
+ return
+ }
+ switch action {
+ case .none:
+ break
+ case .collapse:
+ requestCollapse(automatic: automatic)
+ case .expand:
+ pendingManualCollapse = false
+ expand(
+ animated: true,
+ positioning: automatic
+ ? .restoreCachedFrameIfVisible
+ : .followCompactBar
+ )
+ }
+ }
+
+ private func requestCollapse(automatic: Bool) {
+ guard let window, !isCollapsed else { return }
+
+ if automatic,
+ editorController?.blocksAutomaticWindowCollapse(in: window) == true {
+ pendingAutomaticCollapse = true
+ return
+ }
+
+ if !automatic,
+ editorController?.hasActiveTextComposition == true {
+ pendingManualCollapse = true
+ schedulePendingCollapseRetry()
+ return
+ }
+
+ pendingAutomaticCollapse = false
+ pendingManualCollapse = false
+ collapse(animated: true)
+ }
+
+ private func retryPendingCollapseIfPossible() {
+ guard let window, !isCollapsed else {
+ pendingAutomaticCollapse = false
+ pendingManualCollapse = false
+ return
+ }
+
+ if pendingAutomaticCollapse {
+ guard isAutomaticAvoidanceEnabled,
+ interactionState.lastDefiniteRightPanelState == .open,
+ interactionState.collapseOrigin == .automatic else {
+ pendingAutomaticCollapse = false
+ return
+ }
+ guard editorController?.blocksAutomaticWindowCollapse(in: window)
+ != true else { return }
+ pendingAutomaticCollapse = false
+ collapse(animated: true)
+ return
+ }
+
+ if pendingManualCollapse {
+ guard editorController?.hasActiveTextComposition != true else {
+ schedulePendingCollapseRetry()
+ return
+ }
+ pendingManualCollapse = false
+ collapse(animated: true)
+ }
+ }
+
+ private func schedulePendingCollapseRetry() {
+ pendingRetryWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ MainActor.assumeIsolated {
+ self?.pendingRetryWorkItem = nil
+ self?.retryPendingCollapseIfPossible()
+ }
+ }
+ pendingRetryWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem)
+ }
+
+ private func scheduleUnknownFailOpenIfNeeded() {
+ guard isAutomaticAvoidanceEnabled,
+ unknownFailOpenWorkItem == nil else { return }
+ let workItem = DispatchWorkItem { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.unknownFailOpenWorkItem = nil
+ guard self.isAutomaticAvoidanceEnabled,
+ self.latestRightPanelObservation == .unknown,
+ self.interactionState.collapseOrigin == .automatic else {
+ return
+ }
+
+ self.pendingAutomaticCollapse = false
+ if self.pendingTransitionAction?.automatic == true,
+ self.pendingTransitionAction?.action == .collapse {
+ self.pendingTransitionAction = nil
+ }
+ self.interactionState
+ .releaseAutomaticCollapseForUncertainty()
+ self.perform(.expand, automatic: true)
+ }
+ }
+ unknownFailOpenWorkItem = workItem
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + Self.unknownFailOpenDelay,
+ execute: workItem
+ )
+ }
+
+ private func cancelUnknownFailOpen() {
+ unknownFailOpenWorkItem?.cancel()
+ unknownFailOpenWorkItem = nil
+ }
+
+ private func collapse(animated: Bool) {
+ guard let window, !isCollapsed else { return }
+
+ let fullFrame = window.frame
+ expandedFrame = fullFrame
+ expandedWindowAlpha = window.alphaValue
+ expandedTitlebarSeparatorStyle = window.titlebarSeparatorStyle
+ MainWindowFramePersistence.persist(
+ window: window,
+ defaults: framePersistenceDefaults
+ )
+ let titlebarOnlyHeight = compactWindowHeight(
+ for: window,
+ expandedFrame: fullFrame
+ )
+ let targetFrame = MainWindowCompactGeometry.compactFrame(
+ from: fullFrame,
+ compactWidth: Self.compactWindowWidth,
+ compactHeight: titlebarOnlyHeight
+ )
+
+ isCollapsed = true
+ isCompactContentPresentationActive = false
+ window.contentMinSize = NSSize(
+ width: Self.compactWindowWidth,
+ height: Self.compactContentHeight
+ )
+ applyStandardButtonVisibility(isCompact: true)
+ accessoryController.update(isCollapsed: true, isEnabled: false)
+ compactStateDidChange?(true)
+ transition(
+ to: targetFrame,
+ targetIsCollapsed: true,
+ animated: animated
+ ) { [weak self, weak window] in
+ guard let self else { return }
+ // Changing the style mask also rebuilds native titlebar chrome. Do
+ // it only after the resize animation so it cannot race the titlebar
+ // layout and amplify the one-line intermediate frame.
+ window?.styleMask.remove(.resizable)
+ self.isCompactContentPresentationActive = true
+ }
+ }
+
+ private func compactWindowHeight(
+ for window: NSWindow,
+ expandedFrame: NSRect
+ ) -> CGFloat {
+ // `contentLayoutRect` excludes the titlebar even when the content view
+ // itself extends underneath it via `.fullSizeContentView`. Its height
+ // difference from the expanded frame is therefore the native chrome
+ // that must remain visible in compact mode.
+ let titlebarHeight = max(
+ 0,
+ expandedFrame.height - window.contentLayoutRect.height
+ )
+ return min(
+ expandedFrame.height,
+ ceil(
+ max(
+ Self.compactContentHeight,
+ titlebarHeight + Self.compactContentHeight
+ )
+ )
+ )
+ }
+
+ private func expand(
+ animated: Bool,
+ positioning: MainWindowExpandedFramePositioning
+ ) {
+ guard let window, isCollapsed else { return }
+
+ let cachedFrame = expandedFrame ?? window.frame
+ let targetFrame = targetExpandedFrame(
+ for: window,
+ cachedFrame: cachedFrame,
+ positioning: positioning
+ )
+ isCompactContentPresentationActive = false
+ window.styleMask.insert(.resizable)
+ window.contentMinSize = Self.minimumExpandedContentSize
+ transition(
+ to: targetFrame,
+ targetIsCollapsed: false,
+ animated: animated
+ ) { [weak self, weak window] in
+ guard let self, let window else { return }
+ self.isCollapsed = false
+ self.applyStandardButtonVisibility(isCompact: false)
+ self.expandedFrame = window.frame
+ self.accessoryController.update(isCollapsed: false)
+ self.compactStateDidChange?(false)
+ if self.pendingTransitionAction?.action != .collapse {
+ MainWindowFramePersistence.persist(
+ window: window,
+ defaults: self.framePersistenceDefaults
+ )
+ }
+ }
+ }
+
+ private func transition(
+ to frame: NSRect,
+ targetIsCollapsed: Bool,
+ animated: Bool,
+ completion: @escaping @MainActor () -> Void = {}
+ ) {
+ guard let window else { return }
+ transitionID = UUID()
+ let currentTransitionID = transitionID
+ isTransitioning = true
+ transitionTargetIsCollapsed = targetIsCollapsed
+ accessoryController.update(
+ isCollapsed: isCollapsed,
+ isEnabled: false
+ )
+ applyAppearance()
+
+ let finish = { [weak self] in
+ guard let self, self.transitionID == currentTransitionID else {
+ return
+ }
+ self.isTransitioning = false
+ self.transitionTargetIsCollapsed = nil
+ completion()
+ self.accessoryController.update(
+ isCollapsed: self.isCollapsed,
+ isEnabled: true
+ )
+ self.applyAppearance()
+ if let pendingAction = self.pendingTransitionAction {
+ self.pendingTransitionAction = nil
+ self.perform(
+ pendingAction.action,
+ automatic: pendingAction.automatic
+ )
+ }
+ }
+
+ if !animated || reduceMotion || !Self.animatesCompactFrameTransitions {
+ setFrameImmediatelyCancellingAnimation(
+ frame,
+ on: window,
+ display: window.isVisible
+ )
+ finish()
+ return
+ }
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = Self.transitionDuration
+ context.timingFunction = CAMediaTimingFunction(
+ name: .easeInEaseOut
+ )
+ window.animator().setFrame(frame, display: true)
+ } completionHandler: {
+ MainActor.assumeIsolated {
+ finish()
+ }
+ }
+ }
+
+ /// A plain `setFrame` does not supersede an in-flight `window.animator()`
+ /// resize: AppKit can finish the older animation later and put the window
+ /// back at its stale target. A zero-duration animator transaction replaces
+ /// that animation first; the direct write then makes the lifecycle operation
+ /// synchronously observable to its caller.
+ private func setFrameImmediatelyCancellingAnimation(
+ _ frame: NSRect,
+ on window: NSWindow,
+ display: Bool
+ ) {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0
+ context.allowsImplicitAnimation = false
+ window.animator().setFrame(frame, display: display)
+ }
+ window.setFrame(frame, display: display)
+ }
+
+ private func targetExpandedFrame(
+ for window: NSWindow,
+ cachedFrame: NSRect,
+ positioning: MainWindowExpandedFramePositioning
+ ) -> NSRect {
+ let compactFrame = window.frame
+ var visibleFrames = NSScreen.screens.map(\.visibleFrame)
+ for fallbackFrame in [
+ window.screen?.visibleFrame,
+ NSScreen.main?.visibleFrame,
+ ].compactMap({ $0 }) where !visibleFrames.contains(fallbackFrame) {
+ visibleFrames.append(fallbackFrame)
+ }
+
+ if positioning == .restoreCachedFrameIfVisible {
+ let cachedFrameIsVisible = MainWindowInitialPlacementPolicy
+ .visibleFrame(
+ containingMostOf: cachedFrame,
+ among: visibleFrames
+ ) != nil
+ if cachedFrameIsVisible || visibleFrames.isEmpty {
+ return cachedFrame
+ }
+ }
+
+ let visibleFrame = MainWindowInitialPlacementPolicy.visibleFrame(
+ containingMostOf: compactFrame,
+ among: visibleFrames
+ ) ?? visibleFrames.first
+
+ guard let visibleFrame else {
+ return MainWindowCompactGeometry.expandedFrame(
+ from: compactFrame,
+ cachedExpandedFrame: cachedFrame
+ )
+ }
+ return MainWindowCompactGeometry.expandedFrame(
+ from: compactFrame,
+ cachedExpandedFrame: cachedFrame,
+ constrainedTo: visibleFrame
+ )
+ }
+
+ private func installWindowObservers(for window: NSWindow) {
+ let center = NotificationCenter.default
+ windowObservers = [
+ center.addObserver(
+ forName: NSWindow.didResignKeyNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ self?.applyAppearance()
+ DispatchQueue.main.async { [weak self] in
+ self?.retryPendingCollapseIfPossible()
+ }
+ }
+ },
+ center.addObserver(
+ forName: NSWindow.didBecomeKeyNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ self?.applyAppearance()
+ }
+ },
+ ]
+ }
+
+ private func applyAppearance() {
+ guard let window else { return }
+ if usesCompactAppearance {
+ let baselineAlpha = expandedWindowAlpha ?? 1
+ let compactOpacity = Self.compactWindowOpacity(
+ isActive: isPointerInside || window.isKeyWindow,
+ reduceTransparency: reduceTransparency
+ )
+ // The retained 3pt content sliver exists only to keep the titlebar
+ // accessory from clipping. It must not paint the note paper color.
+ window.backgroundColor = .clear
+ window.isOpaque = false
+ window.titlebarAppearsTransparent = true
+ window.titlebarSeparatorStyle = .none
+ window.alphaValue = baselineAlpha * compactOpacity
+ } else {
+ window.alphaValue = expandedWindowAlpha ?? window.alphaValue
+ window.isOpaque = true
+ window.titlebarAppearsTransparent = false
+ if let expandedTitlebarSeparatorStyle {
+ window.titlebarSeparatorStyle = expandedTitlebarSeparatorStyle
+ }
+ window.backgroundColor = backgroundColor
+ }
+ window.invalidateShadow()
+ }
+
+ private func applyStandardButtonVisibility(isCompact: Bool) {
+ guard let window else { return }
+ if let closeButton = window.standardWindowButton(.closeButton) {
+ closeButton.isHidden = isCompact
+ closeButton.isEnabled = !isCompact
+ }
+ // These controls are hidden by MainWindowChromePolicy in the expanded
+ // window and must never reappear inside the compact pill.
+ window.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ window.standardWindowButton(.zoomButton)?.isHidden = true
+ }
+
+ static func compactWindowOpacity(
+ isActive: Bool,
+ reduceTransparency: Bool
+ ) -> CGFloat {
+ if reduceTransparency { return 1 }
+ return isActive ? activeWindowOpacity : restingWindowOpacity
+ }
+}
+
+@MainActor
+private final class MainWindowCompactAccessoryController: NSObject {
+ private weak var window: NSWindow?
+ private let accessoryViewController = NSTitlebarAccessoryViewController()
+ private let button = NSButton()
+ private var action: (() -> Void)?
+
+ override init() {
+ super.init()
+ let container = NSView()
+ container.frame = NSRect(x: 0, y: 0, width: 36, height: 28)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.bezelStyle = .accessoryBarAction
+ button.isBordered = false
+ button.focusRingType = .none
+ button.imagePosition = .imageOnly
+ button.imageScaling = .scaleProportionallyDown
+ button.contentTintColor = .secondaryLabelColor
+ button.target = self
+ button.action = #selector(buttonPressed)
+ container.addSubview(button)
+ NSLayoutConstraint.activate([
+ button.widthAnchor.constraint(equalToConstant: 28),
+ button.heightAnchor.constraint(equalToConstant: 28),
+ button.centerXAnchor.constraint(equalTo: container.centerXAnchor),
+ button.centerYAnchor.constraint(equalTo: container.centerYAnchor),
+ ])
+ accessoryViewController.layoutAttribute = .right
+ accessoryViewController.view = container
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func attach(to window: NSWindow, action: @escaping () -> Void) {
+ if self.window !== window {
+ detach()
+ self.window = window
+ }
+ self.action = action
+ ensureAttached(to: window)
+ }
+
+ func ensureAttached(to window: NSWindow) {
+ guard !window.titlebarAccessoryViewControllers.contains(where: {
+ $0 === accessoryViewController
+ }) else { return }
+ window.addTitlebarAccessoryViewController(accessoryViewController)
+ }
+
+ func update(isCollapsed: Bool, isEnabled: Bool = true) {
+ let labelKey: L10n.Key = isCollapsed
+ ? .mainWindowExpandAccessibilityLabel
+ : .mainWindowCollapseAccessibilityLabel
+ let helpKey: L10n.Key = isCollapsed
+ ? .mainWindowExpandAccessibilityHelp
+ : .mainWindowCollapseAccessibilityHelp
+ let label = L10n.text(labelKey)
+ button.image = NSImage(
+ systemSymbolName: isCollapsed ? "chevron.down" : "chevron.up",
+ accessibilityDescription: label
+ )
+ button.toolTip = label
+ button.isEnabled = isEnabled
+ button.setAccessibilityLabel(label)
+ button.setAccessibilityHelp(L10n.text(helpKey))
+ }
+
+ func detach() {
+ if let window,
+ let index = window.titlebarAccessoryViewControllers.firstIndex(
+ where: { $0 === accessoryViewController }
+ ) {
+ window.removeTitlebarAccessoryViewController(at: index)
+ }
+ window = nil
+ action = nil
+ }
+
+ @objc private func buttonPressed() {
+ action?()
+ }
+}
+
+@MainActor
+private final class MainWindowCompactHoverController: NSResponder {
+ private weak var window: NSWindow?
+ private weak var frameView: NSView?
+ private var trackingArea: NSTrackingArea?
+ private var changeHandler: ((Bool) -> Void)?
+
+ func attach(
+ to window: NSWindow,
+ changeHandler: @escaping (Bool) -> Void
+ ) {
+ guard let frameView = window.contentView?.superview else {
+ detach()
+ return
+ }
+ if self.window !== window || self.frameView !== frameView {
+ detach()
+ self.window = window
+ self.frameView = frameView
+ }
+ self.changeHandler = changeHandler
+ ensureAttached(to: window)
+ }
+
+ func ensureAttached(to window: NSWindow) {
+ guard let frameView = window.contentView?.superview else { return }
+ if let trackingArea, frameView.trackingAreas.contains(trackingArea) {
+ return
+ }
+ if let trackingArea {
+ self.frameView?.removeTrackingArea(trackingArea)
+ }
+ self.frameView = frameView
+ let trackingArea = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ frameView.addTrackingArea(trackingArea)
+ self.trackingArea = trackingArea
+ }
+
+ func detach() {
+ if let frameView, let trackingArea {
+ frameView.removeTrackingArea(trackingArea)
+ }
+ trackingArea = nil
+ frameView = nil
+ window = nil
+ changeHandler = nil
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ guard event.trackingArea === trackingArea else { return }
+ changeHandler?(true)
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ guard event.trackingArea === trackingArea else { return }
+ changeHandler?(false)
+ }
+}
diff --git a/Sources/CodexNotesProbe/MainWindowCompactState.swift b/Sources/CodexNotesProbe/MainWindowCompactState.swift
new file mode 100644
index 0000000..f8ef14c
--- /dev/null
+++ b/Sources/CodexNotesProbe/MainWindowCompactState.swift
@@ -0,0 +1,174 @@
+import CodexNotesCore
+
+enum MainWindowCompactAction: Equatable, Sendable {
+ case none
+ case collapse
+ case expand
+}
+
+enum MainWindowCollapseOrigin: Equatable, Sendable {
+ case automatic
+ case manual
+}
+
+/// Pure interaction state for coordinating Codex's right panel with the notes
+/// window. The caller remains responsible for applying the returned window
+/// action and for reporting every user-initiated toggle or show operation.
+struct MainWindowCompactState: Equatable, Sendable {
+ private(set) var lastDefiniteRightPanelState: CodexRightPanelState = .unknown
+ private(set) var collapseOrigin: MainWindowCollapseOrigin?
+ private(set) var hasHandledCurrentOpenCycle = false
+ private(set) var userOverrodeCurrentOpenCycle = false
+ /// Snapshot of the compact/expanded state immediately before the current
+ /// right-panel cycle began. Window visibility is deliberately separate:
+ /// restoring an expanded frame must never show a window the user hid.
+ private(set) var wasExpandedBeforeCurrentOpenCycle: Bool?
+
+ /// Establishes a low-level baseline without producing a window action.
+ /// User-visible initial observations should go through `observeRightPanel`
+ /// so an already-open panel can claim its one automatic action.
+ mutating func seedRightPanel(_ state: CodexRightPanelState) {
+ lastDefiniteRightPanelState = state
+ collapseOrigin = nil
+ userOverrodeCurrentOpenCycle = false
+ hasHandledCurrentOpenCycle = state == .open
+ wasExpandedBeforeCurrentOpenCycle = nil
+ }
+
+ /// Observes either a bootstrap snapshot or an incremental panel event.
+ /// The first definite `open` in each open cycle is eligible for one
+ /// automatic collapse, including an initial observation or a recovery from
+ /// `unknown`. `unknown` never resets an already-handled open cycle.
+ @discardableResult
+ mutating func observeRightPanel(
+ _ state: CodexRightPanelState,
+ isWindowVisible: Bool,
+ isCollapsed: Bool,
+ isInitialObservation: Bool = false
+ ) -> MainWindowCompactAction {
+ switch state {
+ case .unknown:
+ return .none
+
+ case .closed:
+ // Treat the right panel as a temporary workspace. If CodexNotes was
+ // expanded before that workspace opened, restore it even when the
+ // user briefly expanded and re-collapsed the notes while inspecting
+ // the panel. A window that was already compact before the cycle only
+ // remains expanded when it is still expanded at close time.
+ let shouldRestorePreOpenExpandedState =
+ lastDefiniteRightPanelState == .open
+ && wasExpandedBeforeCurrentOpenCycle == true
+
+ lastDefiniteRightPanelState = .closed
+ hasHandledCurrentOpenCycle = false
+ userOverrodeCurrentOpenCycle = false
+ wasExpandedBeforeCurrentOpenCycle = nil
+
+ if shouldRestorePreOpenExpandedState
+ || collapseOrigin == .automatic {
+ collapseOrigin = nil
+ } else if !isCollapsed {
+ collapseOrigin = nil
+ }
+
+ // Return an idempotent expand even when the window still appears
+ // expanded. The controller also uses this action to cancel a manual
+ // collapse that may be waiting for an IME composition to finish.
+ return shouldRestorePreOpenExpandedState ? .expand : .none
+
+ case .open:
+ lastDefiniteRightPanelState = .open
+ guard !hasHandledCurrentOpenCycle else {
+ return .none
+ }
+
+ hasHandledCurrentOpenCycle = true
+ userOverrodeCurrentOpenCycle = false
+ wasExpandedBeforeCurrentOpenCycle = !isCollapsed
+
+ guard isWindowVisible, !isCollapsed else {
+ return .none
+ }
+
+ collapseOrigin = .automatic
+ return .collapse
+ }
+ }
+
+ /// Records a click on the compact/expand title-bar control. The argument is
+ /// the state before the click; the return value is the requested new state.
+ @discardableResult
+ mutating func recordManualToggle(
+ isCollapsed: Bool
+ ) -> MainWindowCompactAction {
+ if isCollapsed {
+ collapseOrigin = nil
+ recordUserOverrideIfPanelIsOpen()
+ return .expand
+ }
+
+ collapseOrigin = .manual
+ recordUserOverrideIfPanelIsOpen()
+ return .collapse
+ }
+
+ /// A deliberate show while the right panel is open means the user wants
+ /// access to the notes. It cancels automatic ownership and expands an
+ /// already-collapsed window, while leaving an expanded window unchanged.
+ @discardableResult
+ mutating func recordManualShow(
+ isCollapsed: Bool
+ ) -> MainWindowCompactAction {
+ guard lastDefiniteRightPanelState == .open else {
+ return .none
+ }
+
+ collapseOrigin = nil
+ hasHandledCurrentOpenCycle = true
+ userOverrodeCurrentOpenCycle = true
+ return isCollapsed ? .expand : .none
+ }
+
+ /// Releases a collapse that CodexNotes owned when panel detection remains
+ /// unavailable. Keep the current open cycle handled so a transient AX
+ /// outage cannot make the same still-open sidebar collapse the window a
+ /// second time after detection recovers.
+ mutating func releaseAutomaticCollapseForUncertainty() {
+ guard collapseOrigin == .automatic else { return }
+ collapseOrigin = nil
+ hasHandledCurrentOpenCycle = true
+ }
+
+ /// Stops participating in the current automatic avoidance cycle. A frame
+ /// owned by automatic avoidance is restored only when the notes were
+ /// expanded before the panel opened. Manual compact state is deliberately
+ /// retained, because disabling the feature must not undo a title-bar click.
+ @discardableResult
+ mutating func disableAutomaticAvoidance(
+ isCollapsed: Bool
+ ) -> MainWindowCompactAction {
+ let shouldRestoreAutomaticallyCollapsedWindow =
+ collapseOrigin == .automatic
+ && wasExpandedBeforeCurrentOpenCycle == true
+ && isCollapsed
+
+ if collapseOrigin == .automatic {
+ collapseOrigin = nil
+ }
+ lastDefiniteRightPanelState = .unknown
+ hasHandledCurrentOpenCycle = false
+ userOverrodeCurrentOpenCycle = false
+ wasExpandedBeforeCurrentOpenCycle = nil
+
+ return shouldRestoreAutomaticallyCollapsedWindow ? .expand : .none
+ }
+
+ private mutating func recordUserOverrideIfPanelIsOpen() {
+ guard lastDefiniteRightPanelState == .open else {
+ return
+ }
+ hasHandledCurrentOpenCycle = true
+ userOverrodeCurrentOpenCycle = true
+ }
+}
diff --git a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift
index 04baad9..b8d1814 100644
--- a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift
+++ b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift
@@ -99,6 +99,7 @@ final class MarkdownEditorController: ObservableObject {
@Published private(set) var canToggleHighlightFormat = false
private weak var textView: CheckboxTextView?
private var pendingSelectionAfterExternalTextUpdate: PendingSelection?
+ var textCompositionDidEnd: (() -> Void)?
private struct PendingSelection {
let range: NSRange
@@ -129,6 +130,20 @@ final class MarkdownEditorController: ObservableObject {
textView?.moveCurrentSelection()
}
+ func blocksAutomaticWindowCollapse(in window: NSWindow) -> Bool {
+ guard let textView else { return false }
+ return textView.hasMarkedText()
+ || (window.isKeyWindow && window.firstResponder === textView)
+ }
+
+ var hasActiveTextComposition: Bool {
+ textView?.hasMarkedText() == true
+ }
+
+ fileprivate func notifyTextCompositionDidEnd() {
+ textCompositionDidEnd?()
+ }
+
func setPendingSelectionAfterExternalTextUpdate(
_ range: NSRange,
scrollToVisible: Bool = true
@@ -2762,6 +2777,9 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate {
{
refreshChecklistPresentation(forceLayout: true)
}
+ if !hasMarkedText() {
+ editorController?.notifyTextCompositionDidEnd()
+ }
}
private func applyChecklistParagraphStyles(
@@ -3371,6 +3389,12 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate {
}
override func insertText(_ insertString: Any, replacementRange: NSRange) {
+ let wasComposing = hasMarkedText()
+ defer {
+ if wasComposing, !hasMarkedText() {
+ editorController?.notifyTextCompositionDidEnd()
+ }
+ }
dismissSelectionMovePill()
let replacementRange = normalizedInlineInputRange(replacementRange)
if !isApplyingMarkdownEditPlan,
diff --git a/Sources/CodexNotesProbe/ProbeViewModel.swift b/Sources/CodexNotesProbe/ProbeViewModel.swift
index 8c08afa..451b641 100644
--- a/Sources/CodexNotesProbe/ProbeViewModel.swift
+++ b/Sources/CodexNotesProbe/ProbeViewModel.swift
@@ -72,6 +72,16 @@ private enum SelectionMoveCoordinationError: LocalizedError {
}
}
+struct CodexRightPanelObservation: Equatable, Sendable {
+ let selectionStableKey: String?
+ let state: CodexRightPanelState
+
+ static let unknown = CodexRightPanelObservation(
+ selectionStableKey: nil,
+ state: .unknown
+ )
+}
+
@MainActor
final class ProbeViewModel: ObservableObject {
typealias SelectionMoveNoticeScheduledAction = @MainActor () -> Void
@@ -97,6 +107,10 @@ final class ProbeViewModel: ObservableObject {
@Published private(set) var state: State = .starting
@Published private(set) var selection: CodexSelection?
+ @Published private(set) var rightPanelObservation =
+ CodexRightPanelObservation.unknown
+ private(set) var isRightPanelAvoidanceEnabled =
+ RightPanelAvoidancePreference.defaultValue
@Published private(set) var metadata: CodexThreadMetadata?
@Published private(set) var history: [ProbeHistoryItem] = []
@Published private(set) var lastLatencyMilliseconds: Int?
@@ -130,6 +144,9 @@ final class ProbeViewModel: ObservableObject {
let noteImageStore: NoteImageStore
private let monitor = CodexLogMonitor()
+ private let rightPanelStateReader = CodexRightPanelStateReader()
+ private let accessibilityRightPanelReader =
+ CodexAccessibilityRightPanelReader()
private let metadataProvider: any CodexThreadMetadataProviding
private let globalProjectCandidateProvider: any CodexGlobalProjectCandidateProviding
private let metadataClock = ContinuousClock()
@@ -152,6 +169,14 @@ final class ProbeViewModel: ObservableObject {
private var confirmedNewTaskProject: CodexGlobalProjectCandidate?
private var requiresReloadBeforeWrite = false
private var shouldResumeMonitoringAfterSafetyReload = false
+ private var rightPanelDebouncer = CodexRightPanelDebouncer(
+ requiredConsecutive: 2
+ )
+ private var rightPanelAccessibilityPermission:
+ CodexAccessibilityPermissionState?
+ private var rightPanelAvoidanceGeneration: UInt = 0
+
+ private static let primaryRightPanelIdentity = "codex-primary-window"
private struct PendingSelectionMove {
let result: NoteSelectionMoveResult
@@ -240,6 +265,21 @@ final class ProbeViewModel: ObservableObject {
}
}
+ /// Controls the detector as well as the window response. Every preference
+ /// edge publishes unknown so a pre-disable sample cannot be treated as
+ /// current when the feature is enabled later.
+ func setRightPanelAvoidanceEnabled(_ isEnabled: Bool) {
+ guard isRightPanelAvoidanceEnabled != isEnabled else { return }
+ isRightPanelAvoidanceEnabled = isEnabled
+ rightPanelAvoidanceGeneration &+= 1
+ rightPanelDebouncer.reset()
+ rightPanelAccessibilityPermission = nil
+ // Every preference generation begins from unknown. This prevents a
+ // pre-disable `.open` sample from being replayed after off -> on, and
+ // makes the controller wait for a fresh detector result.
+ publishUnknownRightPanelObservation()
+ }
+
func retry() {
cancelMetadataRefresh()
if requiresReloadBeforeWrite {
@@ -501,8 +541,10 @@ final class ProbeViewModel: ObservableObject {
do {
let initial = try await monitor.bootstrap()
apply(initial, recordLatency: false)
+ await refreshRightPanelState()
} catch {
suspendEditing(message: error.localizedDescription)
+ publishUnknownRightPanelObservation()
}
while !Task.isCancelled {
@@ -510,11 +552,75 @@ final class ProbeViewModel: ObservableObject {
try await Task.sleep(for: .milliseconds(250))
let latest = try await monitor.poll()
apply(latest, recordLatency: true)
+ await refreshRightPanelState()
} catch is CancellationError {
return
} catch {
suspendEditing(message: error.localizedDescription)
+ publishUnknownRightPanelObservation()
+ }
+ }
+ }
+
+ private func refreshRightPanelState() async {
+ guard isRightPanelAvoidanceEnabled else { return }
+ let avoidanceGeneration = rightPanelAvoidanceGeneration
+ let observedSelection = selection
+ let accessibilitySample = await accessibilityRightPanelReader.sample()
+ guard isRightPanelAvoidanceEnabled,
+ rightPanelAvoidanceGeneration == avoidanceGeneration,
+ selection?.stableKey == observedSelection?.stableKey else { return }
+
+ if rightPanelAccessibilityPermission != accessibilitySample.permission {
+ rightPanelAccessibilityPermission = accessibilitySample.permission
+ rightPanelDebouncer.reset()
+ }
+
+ let sampledState: CodexRightPanelState
+ switch accessibilitySample.permission {
+ case .authorized:
+ sampledState = accessibilitySample.state
+ case .denied:
+ guard let threadID = observedSelection?.threadID else {
+ publishUnknownRightPanelObservation()
+ return
}
+ sampledState = await rightPanelStateReader.state(for: threadID)
+ guard isRightPanelAvoidanceEnabled,
+ rightPanelAvoidanceGeneration == avoidanceGeneration,
+ selection?.stableKey == observedSelection?.stableKey else {
+ return
+ }
+ }
+
+ if sampledState == .unknown {
+ // Publishing unknown starts the controller's bounded fail-open
+ // timer. Reset the edge debouncer as well so the same definite
+ // state can be published again after Accessibility recovers.
+ rightPanelDebouncer.reset()
+ publishUnknownRightPanelObservation()
+ return
+ }
+
+ guard let observedState = rightPanelDebouncer.observe(sampledState) else {
+ return
+ }
+ let observation = CodexRightPanelObservation(
+ selectionStableKey: Self.primaryRightPanelIdentity,
+ state: observedState
+ )
+ if rightPanelObservation != observation {
+ rightPanelObservation = observation
+ }
+ }
+
+ private func publishUnknownRightPanelObservation() {
+ let observation = CodexRightPanelObservation(
+ selectionStableKey: Self.primaryRightPanelIdentity,
+ state: .unknown
+ )
+ if rightPanelObservation != observation {
+ rightPanelObservation = observation
}
}
diff --git a/Sources/CodexNotesProbe/SettingsView.swift b/Sources/CodexNotesProbe/SettingsView.swift
index 018b46c..7a582fd 100644
--- a/Sources/CodexNotesProbe/SettingsView.swift
+++ b/Sources/CodexNotesProbe/SettingsView.swift
@@ -56,6 +56,7 @@ struct SettingsView: View {
@StateObject private var loginItemService = LoginItemService()
@State private var globalHotKeyErrorKey: L10n.Key?
@State private var windowSizeRestoreConfirmationID: UUID?
+ @State private var isAccessibilityAuthorized = AXIsProcessTrusted()
@AppStorage(AppLanguagePreference.key)
private var storedLanguage = AppLanguagePreference.defaultValue.rawValue
@AppStorage(EditorFontSizePreference.key)
@@ -66,6 +67,9 @@ struct SettingsView: View {
private var storedThemeID = NoteThemePreference.defaultValue.rawValue
@AppStorage(StatusBarIconPreference.key)
private var storedStatusBarIconID = StatusBarIconPreference.defaultValue.rawValue
+ @AppStorage(RightPanelAvoidancePreference.key)
+ private var rightPanelAvoidanceEnabled =
+ RightPanelAvoidancePreference.defaultValue
init(
updateCoordinator: UpdateCheckCoordinator,
@@ -176,6 +180,11 @@ struct SettingsView: View {
.overlay(palette.separator.color)
launchAtLoginControls
+
+ Divider()
+ .overlay(palette.separator.color)
+
+ rightPanelAvoidanceControls
}
.frame(maxWidth: .infinity)
}
@@ -329,6 +338,7 @@ struct SettingsView: View {
)
.onAppear {
loginItemService.refresh()
+ refreshAccessibilityPermission()
}
.onReceive(
NotificationCenter.default.publisher(
@@ -336,6 +346,7 @@ struct SettingsView: View {
)
) { _ in
loginItemService.refresh()
+ refreshAccessibilityPermission()
}
.onReceive(
NotificationCenter.default.publisher(
@@ -597,6 +608,125 @@ struct SettingsView: View {
.frame(maxWidth: .infinity)
}
+ private var rightPanelAvoidanceControls: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L10n.text(.settingsRightPanelAvoidanceTitle))
+ .font(.subheadline.weight(.medium))
+ Text(L10n.text(.settingsRightPanelAvoidanceDescription))
+ .font(.caption)
+ .foregroundStyle(palette.secondaryText.color)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .accessibilityHidden(true)
+
+ Spacer(minLength: 24)
+
+ Toggle(
+ L10n.text(.settingsRightPanelAvoidanceTitle),
+ isOn: $rightPanelAvoidanceEnabled
+ )
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .accessibilityLabel(Text(L10n.text(
+ .settingsRightPanelAvoidanceTitle
+ )))
+ .accessibilityValue(Text(L10n.text(
+ rightPanelAvoidanceEnabled
+ ? .accessibilityValueOn
+ : .accessibilityValueOff
+ )))
+ .accessibilityHint(Text(L10n.text(
+ .settingsRightPanelAvoidanceDescription
+ )))
+ }
+
+ HStack(alignment: .center, spacing: 7) {
+ Image(systemName: isAccessibilityAuthorized
+ ? "checkmark.circle.fill"
+ : "exclamationmark.triangle.fill")
+ .foregroundStyle(isAccessibilityAuthorized
+ ? palette.success.color
+ : palette.warning.color)
+ .accessibilityHidden(true)
+
+ Text(L10n.text(.settingsRightPanelAvoidancePermissionTitle))
+ .font(.caption)
+ .foregroundStyle(palette.secondaryText.color)
+
+ Spacer(minLength: 12)
+
+ Text(L10n.text(
+ isAccessibilityAuthorized
+ ? .settingsRightPanelAvoidanceAuthorized
+ : .settingsRightPanelAvoidanceNotAuthorized
+ ))
+ .font(.caption.weight(.medium))
+ .foregroundStyle(isAccessibilityAuthorized
+ ? palette.success.color
+ : palette.warning.color)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text(L10n.text(
+ .settingsRightPanelAvoidancePermissionTitle
+ )))
+ .accessibilityValue(Text(L10n.text(
+ isAccessibilityAuthorized
+ ? .settingsRightPanelAvoidanceAuthorized
+ : .settingsRightPanelAvoidanceNotAuthorized
+ )))
+
+ if rightPanelAvoidanceEnabled && !isAccessibilityAuthorized {
+ HStack(alignment: .top, spacing: 7) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(palette.warning.color)
+ .accessibilityHidden(true)
+ Text(L10n.text(.settingsRightPanelAvoidancePermissionDescription))
+ .font(.caption)
+ .foregroundStyle(palette.warning.color)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 12) {
+ Button(L10n.text(.settingsRightPanelAvoidanceRequestPermission)) {
+ requestAccessibilityPermission()
+ }
+ .buttonStyle(.link)
+ .controlSize(.small)
+
+ Button(L10n.text(.settingsRightPanelAvoidanceOpenSystemSettings)) {
+ openAccessibilitySystemSettings()
+ }
+ .buttonStyle(.link)
+ .controlSize(.small)
+ }
+ .padding(.leading, 20)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private func refreshAccessibilityPermission() {
+ isAccessibilityAuthorized = AXIsProcessTrusted()
+ }
+
+ private func requestAccessibilityPermission() {
+ let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue()
+ as String
+ let options = [promptKey: true] as CFDictionary
+ _ = AXIsProcessTrustedWithOptions(options)
+ refreshAccessibilityPermission()
+ }
+
+ private func openAccessibilitySystemSettings() {
+ guard let url = URL(
+ string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
+ ) else { return }
+ NSWorkspace.shared.open(url)
+ }
+
private func settingsPanel(
title: String,
@ViewBuilder content: () -> Content
diff --git a/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift b/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift
new file mode 100644
index 0000000..bc246d2
--- /dev/null
+++ b/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift
@@ -0,0 +1,363 @@
+import Foundation
+import XCTest
+@testable import CodexNotesCore
+
+final class CodexRightPanelStateReaderTests: XCTestCase {
+ func testReadsGenericRightPanelState() throws {
+ let openData = try fixtureData([
+ "thread-tab-routes-v1:target-thread": genericState(open: true)
+ ])
+ let closedData = try fixtureData([
+ "thread-tab-routes-v1:target-thread": genericState(open: false)
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: openData
+ ),
+ .open
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: closedData
+ ),
+ .closed
+ )
+ }
+
+ func testGenericStateWinsWhenBrowserStateConflicts() throws {
+ let genericOpenData = try fixtureData([
+ "thread-tab-routes-v1:target-thread": genericState(open: true),
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": false
+ ]
+ ])
+ let genericClosedData = try fixtureData([
+ "thread-tab-routes-v1:target-thread": genericState(open: false),
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": true
+ ]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: genericOpenData
+ ),
+ .open
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: genericClosedData
+ ),
+ .closed
+ )
+ }
+
+ func testMalformedGenericStateFallsBackToBrowserState() throws {
+ let malformedGenericStates: [Any] = [
+ NSNull(),
+ [:],
+ ["topology": NSNull()],
+ ["topology": [:]],
+ ["topology": ["right": NSNull()]],
+ ["topology": ["right": [:]]],
+ ["topology": ["right": ["open": NSNull()]]],
+ ["topology": ["right": ["open": 1]]],
+ ["topology": ["right": ["open": "true"]]]
+ ]
+
+ for genericState in malformedGenericStates {
+ let data = try fixtureData([
+ "thread-tab-routes-v1:target-thread": genericState,
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": true
+ ]
+ ])
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: data
+ ),
+ .open,
+ "Did not fall back for malformed generic state: \(genericState)"
+ )
+ }
+ }
+
+ func testGenericStateDoesNotBorrowAnotherThreadsValue() throws {
+ let data = try fixtureData([
+ "thread-tab-routes-v1:old-thread": genericState(open: true),
+ "thread-tab-routes-v1:target-thread": genericState(open: false),
+ "thread-browser-tabs-v1:old-thread": ["rightPanelOpen": true]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "target-thread", in: data),
+ .closed
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "missing-thread", in: data),
+ .closed
+ )
+ }
+
+ func testValidAtomStateWithBothExactKeysAbsentIsClosed() throws {
+ let emptyAtomData = try fixtureData([:])
+ let otherThreadData = try fixtureData([
+ "thread-tab-routes-v1:other-thread": genericState(open: true),
+ "thread-browser-tabs-v1:other-thread": [
+ "rightPanelOpen": true
+ ]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: emptyAtomData
+ ),
+ .closed
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: otherThreadData
+ ),
+ .closed
+ )
+ }
+
+ func testPresentInvalidExactKeyWithoutValidFallbackIsUnknown() throws {
+ let fixtures = try [
+ fixtureData([
+ "thread-tab-routes-v1:target-thread": NSNull()
+ ]),
+ fixtureData([
+ "thread-browser-tabs-v1:target-thread": NSNull()
+ ]),
+ fixtureData([
+ "thread-tab-routes-v1:target-thread": [
+ "topology": ["right": ["open": 1]]
+ ],
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": "false"
+ ]
+ ])
+ ]
+
+ for data in fixtures {
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: data
+ ),
+ .unknown
+ )
+ }
+ }
+
+ func testReadsTrueForExactThreadAsOpen() throws {
+ let data = try fixtureData([
+ "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": true]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "target-thread", in: data),
+ .open
+ )
+ }
+
+ func testReadsFalseForExactThreadAsClosed() throws {
+ let data = try fixtureData([
+ "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "target-thread", in: data),
+ .closed
+ )
+ }
+
+ func testDoesNotBorrowOpenStateFromAnotherThread() throws {
+ let data = try fixtureData([
+ "thread-browser-tabs-v1:old-thread": ["rightPanelOpen": true],
+ "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "target-thread", in: data),
+ .closed
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "missing-thread", in: data),
+ .closed
+ )
+ }
+
+ func testMissingStructuresAreUnknown() throws {
+ let fixtures: [Any] = [
+ [:],
+ ["electron-persisted-atom-state": NSNull()],
+ ["electron-persisted-atom-state": []],
+ [
+ "electron-persisted-atom-state": [
+ "thread-browser-tabs-v1:target-thread": NSNull()
+ ]
+ ],
+ [
+ "electron-persisted-atom-state": [
+ "thread-browser-tabs-v1:target-thread": [:]
+ ]
+ ],
+ [
+ "electron-persisted-atom-state": [
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": NSNull()
+ ]
+ ]
+ ]
+ ]
+
+ for fixture in fixtures {
+ let data = try JSONSerialization.data(withJSONObject: fixture)
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: data
+ ),
+ .unknown
+ )
+ }
+ }
+
+ func testMalformedJSONIsUnknown() {
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: Data("{not-json".utf8)
+ ),
+ .unknown
+ )
+ }
+
+ func testTypeDriftIsUnknownInsteadOfTruthy() throws {
+ let driftedValues: [Any] = [
+ 1,
+ 0,
+ "true",
+ ["value": true],
+ [true]
+ ]
+
+ for value in driftedValues {
+ let data = try fixtureData([
+ "thread-browser-tabs-v1:target-thread": [
+ "rightPanelOpen": value
+ ]
+ ])
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(
+ for: "target-thread",
+ in: data
+ ),
+ .unknown,
+ "Unexpectedly accepted \(value) as a Boolean"
+ )
+ }
+ }
+
+ func testEmptyThreadIDIsUnknown() throws {
+ let data = try fixtureData([
+ "thread-browser-tabs-v1:": ["rightPanelOpen": true]
+ ])
+
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: "", in: data),
+ .unknown
+ )
+ }
+
+ func testThreadIDUsesJavaScriptEncodeURIComponentRules() throws {
+ let threadID = "thread/有 空格?x=1&y!'()*~.-_"
+ let encodedThreadID = "thread%2F%E6%9C%89%20"
+ + "%E7%A9%BA%E6%A0%BC%3Fx%3D1%26y!'()*~.-_"
+ let expectedGenericKey = "thread-tab-routes-v1:" + encodedThreadID
+ let expectedBrowserKey = "thread-browser-tabs-v1:" + encodedThreadID
+ XCTAssertEqual(
+ CodexRightPanelStateParser.genericPersistedKey(for: threadID),
+ expectedGenericKey
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.browserPersistedKey(for: threadID),
+ expectedBrowserKey
+ )
+ XCTAssertEqual(
+ CodexRightPanelStateParser.persistedKey(for: threadID),
+ expectedBrowserKey
+ )
+
+ let data = try fixtureData([
+ expectedGenericKey: genericState(open: true),
+ expectedBrowserKey: ["rightPanelOpen": false],
+ "thread-browser-tabs-v1:\(threadID)": ["rightPanelOpen": false]
+ ])
+ XCTAssertEqual(
+ CodexRightPanelStateParser.state(for: threadID, in: data),
+ .open
+ )
+ }
+
+ func testFileReaderReadsFreshSnapshots() async throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let stateURL = directory.appendingPathComponent("state.json")
+ try fixtureData([
+ "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": true]
+ ]).write(to: stateURL, options: .atomic)
+ let reader = CodexRightPanelStateReader(globalStateURL: stateURL)
+
+ let openState = await reader.state(for: "target-thread")
+ XCTAssertEqual(openState, .open)
+
+ try fixtureData([
+ "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false]
+ ]).write(to: stateURL, options: .atomic)
+ let closedState = await reader.state(for: "target-thread")
+ XCTAssertEqual(closedState, .closed)
+ }
+
+ func testFileReaderReturnsUnknownForMissingFile() async {
+ let stateURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathComponent("missing-state.json")
+ let reader = CodexRightPanelStateReader(globalStateURL: stateURL)
+
+ let state = await reader.state(for: "target-thread")
+ XCTAssertEqual(state, .unknown)
+ }
+
+ private func fixtureData(_ atomState: [String: Any]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: [
+ "electron-persisted-atom-state": atomState
+ ])
+ }
+
+ private func genericState(open: Bool) -> [String: Any] {
+ [
+ "topology": [
+ "right": [
+ "open": open
+ ]
+ ]
+ ]
+ }
+}
diff --git a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
new file mode 100644
index 0000000..e454be2
--- /dev/null
+++ b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
@@ -0,0 +1,79 @@
+import Foundation
+import XCTest
+@testable import CodexNotesCore
+
+final class RightPanelAvoidancePreferenceTests: XCTestCase {
+ func testFreshInstallDefaultsOffAndPersistsDecision() {
+ withDefaults { defaults, suiteName in
+ XCTAssertNil(defaults.object(
+ forKey: RightPanelAvoidancePreference.key
+ ))
+ XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded(
+ defaults: defaults,
+ persistentDomainName: suiteName
+ ))
+ XCTAssertEqual(
+ defaults.object(forKey: RightPanelAvoidancePreference.key) as? Bool,
+ false
+ )
+ XCTAssertFalse(RightPanelAvoidancePreference.load(from: defaults))
+ }
+ }
+
+ func testExistingInstallWithoutPreferenceDefaultsOff() {
+ withDefaults { defaults, suiteName in
+ defaults.set(20, forKey: "editorFontSize")
+
+ XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded(
+ defaults: defaults,
+ persistentDomainName: suiteName
+ ))
+ XCTAssertFalse(defaults.bool(
+ forKey: RightPanelAvoidancePreference.key
+ ))
+ }
+ }
+
+ func testExplicitPreferenceWinsAndRoundTripsBothValues() {
+ withDefaults { defaults, suiteName in
+ defaults.set(false, forKey: RightPanelAvoidancePreference.key)
+ defaults.set(20, forKey: "editorFontSize")
+ XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded(
+ defaults: defaults,
+ persistentDomainName: suiteName
+ ))
+ XCTAssertFalse(RightPanelAvoidancePreference.load(from: defaults))
+
+ defaults.set(true, forKey: RightPanelAvoidancePreference.key)
+ XCTAssertTrue(RightPanelAvoidancePreference.migrateIfNeeded(
+ defaults: defaults,
+ persistentDomainName: suiteName
+ ))
+ XCTAssertTrue(RightPanelAvoidancePreference.load(from: defaults))
+ }
+ }
+
+ func testEmptyPersistentDomainStillCountsAsFreshInstall() {
+ withDefaults { defaults, suiteName in
+ defaults.set(true, forKey: "temporary")
+ defaults.removeObject(forKey: "temporary")
+
+ XCTAssertEqual(
+ defaults.persistentDomain(forName: suiteName)?.isEmpty,
+ true
+ )
+ XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded(
+ defaults: defaults,
+ persistentDomainName: suiteName
+ ))
+ }
+ }
+
+ private func withDefaults(_ body: (UserDefaults, String) -> Void) {
+ let suiteName = "CodexNotesTests.RightPanelAvoidance.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ body(defaults, suiteName)
+ }
+}
diff --git a/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift b/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift
new file mode 100644
index 0000000..72c17f9
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift
@@ -0,0 +1,382 @@
+import CodexNotesCore
+import XCTest
+@testable import CodexNotesProbe
+
+final class CodexAccessibilityRightPanelReaderTests: XCTestCase {
+ func testClassifiesClosedExactLayout() {
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(in: [tree(open: false)]),
+ .closed
+ )
+ }
+
+ func testClassifiesOpenExactLayoutIncludingEmptyBrowserPage() {
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(in: [tree(open: true)]),
+ .open
+ )
+ }
+
+ func testFindsShellRowBelowSeveralWrappers() {
+ let viewport = element(
+ frame: mainFrame,
+ classes: ["_MainContentViewport_1e9gb_72"]
+ )
+ let shell = element(
+ frame: mainFrame,
+ classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens,
+ children: [viewport]
+ )
+ let main = element(
+ subrole: "AXLandmarkMain",
+ frame: mainFrame,
+ children: [
+ element(children: [element(children: [shell])]),
+ ]
+ )
+
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.shellRows(in: main),
+ [shell]
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(in: [main]),
+ .closed
+ )
+ }
+
+ func testInjectedReaderClassifiesNestedOpenTree() async {
+ let reader = CodexAccessibilityRightPanelReader(
+ backend: FakeAccessibilityBackend(
+ roots: [tree(open: true)]
+ )
+ )
+
+ let sample = await reader.sample()
+ XCTAssertEqual(
+ sample,
+ CodexAccessibilityRightPanelSample(
+ state: .open,
+ permission: .authorized
+ )
+ )
+ }
+
+ func testInjectedReaderDeniedPermissionFailsUnknownWithoutRoots() async {
+ let reader = CodexAccessibilityRightPanelReader(
+ backend: FakeAccessibilityBackend(
+ isTrusted: false,
+ processIdentifier: nil,
+ roots: nil
+ )
+ )
+
+ let sample = await reader.sample()
+ XCTAssertEqual(
+ sample,
+ CodexAccessibilityRightPanelSample(
+ state: .unknown,
+ permission: .denied
+ )
+ )
+ }
+
+ func testTargetedTraversalFindsUniqueShellBelowWrappers() {
+ let shell = element(
+ frame: mainFrame,
+ classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens
+ )
+ let roots = [element(children: [element(children: [shell])])]
+ var nodeCount = 0
+ let budget = CodexAccessibilityTraversalBudget(
+ maximumNodes: 8,
+ maximumDepth: 4,
+ maximumDuration: .milliseconds(20)
+ )
+
+ let match = CodexAccessibilityTargetedTraversal.uniqueMatch(
+ among: roots,
+ startingDepth: 1,
+ nodeCount: &nodeCount,
+ budget: budget,
+ isBeforeDeadline: { true },
+ isMatch: {
+ CodexAccessibilityRightPanelClassifier.shellRowClassTokens
+ .isSubset(of: $0.classTokens)
+ },
+ children: { $0.children },
+ shouldTraverse: { _ in true }
+ )
+
+ XCTAssertEqual(match, shell)
+ XCTAssertEqual(nodeCount, 3)
+ }
+
+ func testTargetedTraversalFailsUnknownWhenBudgetExpires() {
+ let shell = element(
+ frame: mainFrame,
+ classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens
+ )
+ let roots = [element(children: [element(children: [shell])])]
+ var nodeCount = 0
+ let budget = CodexAccessibilityTraversalBudget(
+ maximumNodes: 2,
+ maximumDepth: 4,
+ maximumDuration: .milliseconds(20)
+ )
+
+ let match = CodexAccessibilityTargetedTraversal.uniqueMatch(
+ among: roots,
+ startingDepth: 1,
+ nodeCount: &nodeCount,
+ budget: budget,
+ isBeforeDeadline: { true },
+ isMatch: {
+ CodexAccessibilityRightPanelClassifier.shellRowClassTokens
+ .isSubset(of: $0.classTokens)
+ },
+ children: { $0.children },
+ shouldTraverse: { _ in true }
+ )
+
+ XCTAssertNil(match)
+ XCTAssertEqual(nodeCount, 2)
+ }
+
+ func testMissingShellRowIsUnknown() {
+ let main = element(
+ subrole: "AXLandmarkMain",
+ frame: mainFrame,
+ children: []
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(in: [main]),
+ .unknown
+ )
+ }
+
+ func testNarrowViewportWithoutAsideIsUnknownNotClosed() {
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(
+ in: [tree(open: false, viewportFrame: openViewportFrame)]
+ ),
+ .unknown
+ )
+ }
+
+ func testSummaryOverlayDoesNotCountAsRightPanel() {
+ let summary = element(
+ frame: CGRect(x: 2054, y: 124, width: 506, height: 1297),
+ classes: [
+ "pointer-events-none", "absolute", "right-0", "z-40",
+ ]
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(
+ in: [tree(open: false, extraShellChildren: [summary])]
+ ),
+ .closed
+ )
+ }
+
+ func testGenericAsideTokenOnViewportDoesNotCreateFalseCandidate() {
+ let viewport = element(
+ frame: mainFrame,
+ classes: ["_MainContentViewport_1e9gb_72", "relative"]
+ )
+ let shell = element(
+ frame: mainFrame,
+ classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens,
+ children: [viewport]
+ )
+ let main = element(
+ subrole: "AXLandmarkMain",
+ frame: mainFrame,
+ children: [element(children: [shell])]
+ )
+
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(in: [main]),
+ .closed
+ )
+ }
+
+ func testAsideWithWrongGeometryIsUnknown() {
+ let wrongAside = aside(
+ frame: CGRect(x: 1800, y: 31, width: 500, height: 1409)
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(
+ in: [tree(
+ open: false,
+ viewportFrame: openViewportFrame,
+ extraShellChildren: [wrongAside]
+ )]
+ ),
+ .unknown
+ )
+ }
+
+ func testPartialAsideClassDriftIsUnknown() {
+ let drifted = element(
+ subrole: "AXLandmarkComplementary",
+ frame: openAsideFrame,
+ classes: ["relative", "z-[41]", "h-full"]
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(
+ in: [tree(
+ open: false,
+ viewportFrame: openViewportFrame,
+ extraShellChildren: [drifted]
+ )]
+ ),
+ .unknown
+ )
+ }
+
+ func testLargestMainLandmarkWins() {
+ let smallMain = element(
+ subrole: "AXLandmarkMain",
+ frame: CGRect(x: 0, y: 0, width: 700, height: 500)
+ )
+ XCTAssertEqual(
+ CodexAccessibilityRightPanelClassifier.state(
+ in: [smallMain, tree(open: true)]
+ ),
+ .open
+ )
+ }
+
+ func testDebouncerRequiresTwoMatchingDefiniteSamples() {
+ var debouncer = CodexRightPanelDebouncer()
+ XCTAssertNil(debouncer.observe(.open))
+ XCTAssertEqual(debouncer.observe(.open), .open)
+ XCTAssertNil(debouncer.observe(.open))
+ }
+
+ func testDebouncerUnknownBreaksCandidateWithoutPublishingUnknown() {
+ var debouncer = CodexRightPanelDebouncer()
+ XCTAssertNil(debouncer.observe(.closed))
+ XCTAssertNil(debouncer.observe(.unknown))
+ XCTAssertNil(debouncer.observe(.closed))
+ XCTAssertEqual(debouncer.observe(.closed), .closed)
+ }
+
+ func testDebouncerPublishesNewOppositeStateAfterConfirmation() {
+ var debouncer = CodexRightPanelDebouncer()
+ XCTAssertNil(debouncer.observe(.closed))
+ XCTAssertEqual(debouncer.observe(.closed), .closed)
+ XCTAssertNil(debouncer.observe(.open))
+ XCTAssertEqual(debouncer.observe(.open), .open)
+ }
+
+ func testDebouncerResetAllowsRepublishingSameState() {
+ var debouncer = CodexRightPanelDebouncer()
+ _ = debouncer.observe(.open)
+ XCTAssertEqual(debouncer.observe(.open), .open)
+ debouncer.reset()
+ XCTAssertNil(debouncer.observe(.open))
+ XCTAssertEqual(debouncer.observe(.open), .open)
+ }
+
+ private let mainFrame = CGRect(x: 576, y: 31, width: 1984, height: 1409)
+ private let openViewportFrame = CGRect(
+ x: 576,
+ y: 31,
+ width: 972,
+ height: 1409
+ )
+ private let openAsideFrame = CGRect(
+ x: 1548,
+ y: 31,
+ width: 1012,
+ height: 1409
+ )
+
+ private func tree(
+ open: Bool,
+ viewportFrame: CGRect? = nil,
+ extraShellChildren: [CodexAccessibilityElementSnapshot] = []
+ ) -> CodexAccessibilityElementSnapshot {
+ let viewport = element(
+ frame: viewportFrame ?? (open ? openViewportFrame : mainFrame),
+ classes: ["_MainContentViewport_1e9gb_72"]
+ )
+ var shellChildren = [viewport]
+ if open {
+ shellChildren.append(aside(frame: openAsideFrame))
+ }
+ shellChildren.append(contentsOf: extraShellChildren)
+ let shell = element(
+ frame: mainFrame,
+ classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens,
+ children: shellChildren
+ )
+ return element(
+ subrole: "AXLandmarkMain",
+ frame: mainFrame,
+ children: [element(children: [shell])]
+ )
+ }
+
+ private func aside(
+ frame: CGRect
+ ) -> CodexAccessibilityElementSnapshot {
+ element(
+ subrole: "AXLandmarkComplementary",
+ frame: frame,
+ classes: CodexAccessibilityRightPanelClassifier.asideClassTokens
+ )
+ }
+
+ private func element(
+ role: String = "AXGroup",
+ subrole: String? = nil,
+ frame: CGRect? = nil,
+ classes: Set = [],
+ children: [CodexAccessibilityElementSnapshot] = []
+ ) -> CodexAccessibilityElementSnapshot {
+ CodexAccessibilityElementSnapshot(
+ role: role,
+ subrole: subrole,
+ frame: frame,
+ classTokens: classes,
+ children: children
+ )
+ }
+}
+
+private final class FakeAccessibilityBackend:
+ CodexAccessibilityReading,
+ @unchecked Sendable
+{
+ private let trusted: Bool
+ private let processIdentifier: pid_t?
+ private let roots: [CodexAccessibilityElementSnapshot]?
+
+ init(
+ isTrusted: Bool = true,
+ processIdentifier: pid_t? = 123,
+ roots: [CodexAccessibilityElementSnapshot]?
+ ) {
+ trusted = isTrusted
+ self.processIdentifier = processIdentifier
+ self.roots = roots
+ }
+
+ func isTrusted() -> Bool {
+ trusted
+ }
+
+ func codexProcessIdentifier() -> pid_t? {
+ processIdentifier
+ }
+
+ func rootSnapshots(
+ for processIdentifier: pid_t,
+ budget: CodexAccessibilityTraversalBudget
+ ) -> [CodexAccessibilityElementSnapshot]? {
+ roots
+ }
+}
diff --git a/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift b/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift
index a301b22..4e35828 100644
--- a/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift
+++ b/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift
@@ -79,6 +79,30 @@ final class MainWindowChromePolicyTests: XCTestCase {
XCTAssertFalse(miniaturizeButton.isEnabled)
}
+ func testCompactPolicyLeavesOnlyTitleAndAccessoryControlsVisible() throws {
+ let window = makeWindow()
+
+ MainWindowChromePolicy.apply(to: window, isCollapsed: true)
+
+ let closeButton = try XCTUnwrap(
+ window.standardWindowButton(.closeButton)
+ )
+ XCTAssertTrue(closeButton.isHidden)
+ XCTAssertFalse(closeButton.isEnabled)
+ XCTAssertTrue(
+ try XCTUnwrap(window.standardWindowButton(.miniaturizeButton))
+ .isHidden
+ )
+ XCTAssertTrue(
+ try XCTUnwrap(window.standardWindowButton(.zoomButton)).isHidden
+ )
+
+ MainWindowChromePolicy.apply(to: window, isCollapsed: false)
+
+ XCTAssertFalse(closeButton.isHidden)
+ XCTAssertTrue(closeButton.isEnabled)
+ }
+
func testWindowCoordinatorRejectsZoom() {
let window = makeWindow()
let coordinator = WindowConfigurator.Coordinator()
diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
new file mode 100644
index 0000000..a019753
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
@@ -0,0 +1,1800 @@
+import AppKit
+import CodexNotesCore
+import SwiftUI
+import XCTest
+@testable import CodexNotesProbe
+
+@MainActor
+final class MainWindowContentSizingPolicyTests: XCTestCase {
+ func testIdealHeightStaysExpandedWhilePresentationBecomesCompact() {
+ let expandedHost = makeSizingHost(
+ isCollapsed: false,
+ isCompactPresentationActive: false
+ )
+ let compactHost = makeSizingHost(
+ isCollapsed: true,
+ isCompactPresentationActive: true
+ )
+
+ XCTAssertEqual(MainWindowContentSizingPolicy.idealHeight, 660)
+ XCTAssertEqual(
+ expandedHost.fittingSize.height,
+ MainWindowContentSizingPolicy.idealHeight,
+ accuracy: 0.01
+ )
+ XCTAssertEqual(
+ compactHost.fittingSize.height,
+ MainWindowContentSizingPolicy.idealHeight,
+ accuracy: 0.01
+ )
+ }
+
+ func testCompactPresentationNeverRequestsFixedContentHeight() {
+ XCTAssertNil(
+ MainWindowContentSizingPolicy.fixedHeight(
+ isCompactPresentationActive: false
+ )
+ )
+ XCTAssertNil(
+ MainWindowContentSizingPolicy.fixedHeight(
+ isCompactPresentationActive: true
+ )
+ )
+ }
+
+ func testOnlyMinimumHeightRelaxesDuringCollapse() {
+ XCTAssertEqual(
+ MainWindowContentSizingPolicy.minimumHeight(isCollapsed: false),
+ MainWindowCompactController.minimumExpandedContentSize.height
+ )
+ XCTAssertEqual(
+ MainWindowContentSizingPolicy.minimumHeight(isCollapsed: true),
+ MainWindowCompactController.compactContentHeight
+ )
+ }
+
+ func testMinimumWidthTracksCompactPillAndExpandedWindow() {
+ XCTAssertEqual(
+ MainWindowContentSizingPolicy.minimumWidth(isCollapsed: false),
+ MainWindowCompactController.minimumExpandedContentSize.width
+ )
+ XCTAssertEqual(
+ MainWindowContentSizingPolicy.minimumWidth(isCollapsed: true),
+ MainWindowCompactController.compactWindowWidth
+ )
+ }
+
+ private func makeSizingHost(
+ isCollapsed: Bool,
+ isCompactPresentationActive: Bool
+ ) -> NSHostingView {
+ let host = NSHostingView(
+ rootView: MainWindowContentSizingHarness(
+ isCollapsed: isCollapsed,
+ isCompactPresentationActive: isCompactPresentationActive
+ )
+ )
+ host.layoutSubtreeIfNeeded()
+ return host
+ }
+}
+
+private struct MainWindowContentSizingHarness: View {
+ let isCollapsed: Bool
+ let isCompactPresentationActive: Bool
+
+ var body: some View {
+ Color.clear
+ .frame(
+ minWidth: MainWindowContentSizingPolicy.minimumWidth(
+ isCollapsed: isCollapsed
+ ),
+ idealWidth: 400,
+ minHeight: MainWindowContentSizingPolicy.minimumHeight(
+ isCollapsed: isCollapsed
+ ),
+ idealHeight: MainWindowContentSizingPolicy.idealHeight
+ )
+ .frame(
+ height: MainWindowContentSizingPolicy.fixedHeight(
+ isCompactPresentationActive:
+ isCompactPresentationActive
+ ),
+ alignment: .top
+ )
+ }
+}
+
+final class MainWindowCompactGeometryTests: XCTestCase {
+ func testCompactFrameUsesRequestedWidthAndPreservesTopRightEdges() {
+ let expanded = NSRect(x: 120, y: 180, width: 440, height: 660)
+
+ let compact = MainWindowCompactGeometry.compactFrame(
+ from: expanded,
+ compactWidth: 190,
+ compactHeight: 31
+ )
+
+ XCTAssertEqual(compact.width, 190)
+ XCTAssertEqual(compact.maxX, expanded.maxX)
+ XCTAssertEqual(compact.maxY, expanded.maxY)
+ XCTAssertEqual(compact.height, 31)
+ }
+
+ func testExpansionFollowsMovedCompactBarAndRestoresCachedSize() {
+ let cached = NSRect(x: 120, y: 180, width: 440, height: 660)
+ let movedCompact = NSRect(x: 720, y: 830, width: 190, height: 31)
+
+ let expanded = MainWindowCompactGeometry.expandedFrame(
+ from: movedCompact,
+ cachedExpandedFrame: cached
+ )
+
+ XCTAssertEqual(expanded.maxX, movedCompact.maxX)
+ XCTAssertEqual(expanded.maxY, movedCompact.maxY)
+ XCTAssertEqual(expanded.size, cached.size)
+ }
+
+ func testExpansionIsKeptInsideVisibleFrameAfterCompactBarMovesLow() {
+ let cached = NSRect(x: 120, y: 180, width: 440, height: 660)
+ let movedCompact = NSRect(x: 950, y: 25, width: 190, height: 31)
+ let visible = NSRect(x: 0, y: 24, width: 1_200, height: 776)
+
+ let expanded = MainWindowCompactGeometry.expandedFrame(
+ from: movedCompact,
+ cachedExpandedFrame: cached,
+ constrainedTo: visible
+ )
+
+ XCTAssertEqual(expanded.minY, visible.minY)
+ XCTAssertTrue(visible.contains(expanded))
+ XCTAssertEqual(expanded.size, cached.size)
+ }
+}
+
+@MainActor
+final class MainWindowCompactControllerTests: XCTestCase {
+ func testCompactPillUsesDeliberatelySmallFixedWidth() {
+ XCTAssertEqual(MainWindowCompactController.compactWindowWidth, 190)
+ XCTAssertLessThan(
+ MainWindowCompactController.compactWindowWidth,
+ MainWindowCompactController.minimumExpandedContentSize.width
+ )
+ }
+
+ func testCompactAppearanceUsesReadableTranslucencyLevels() {
+ XCTAssertEqual(
+ MainWindowCompactController.restingWindowOpacity,
+ 0.42,
+ accuracy: 0.001
+ )
+ XCTAssertEqual(
+ MainWindowCompactController.activeWindowOpacity,
+ 0.68,
+ accuracy: 0.001
+ )
+ XCTAssertLessThan(
+ MainWindowCompactController.restingWindowOpacity,
+ MainWindowCompactController.activeWindowOpacity
+ )
+ XCTAssertLessThan(
+ MainWindowCompactController.activeWindowOpacity,
+ 1
+ )
+ XCTAssertEqual(
+ MainWindowCompactController.compactWindowOpacity(
+ isActive: false,
+ reduceTransparency: false
+ ),
+ MainWindowCompactController.restingWindowOpacity
+ )
+ XCTAssertEqual(
+ MainWindowCompactController.compactWindowOpacity(
+ isActive: true,
+ reduceTransparency: false
+ ),
+ MainWindowCompactController.activeWindowOpacity
+ )
+ XCTAssertEqual(
+ MainWindowCompactController.compactWindowOpacity(
+ isActive: false,
+ reduceTransparency: true
+ ),
+ 1
+ )
+ }
+
+ func testCompactAppearanceClearsContentSliverAndRestoresWindowChrome() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ window.alphaValue = 0.75
+ window.titlebarSeparatorStyle = .line
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ controller.toggleFromTitlebar()
+
+ XCTAssertEqual(
+ window.alphaValue,
+ 0.75 * MainWindowCompactController.restingWindowOpacity,
+ accuracy: 0.001
+ )
+ XCTAssertEqual(window.backgroundColor.alphaComponent, 0, accuracy: 0.001)
+ XCTAssertEqual(window.titlebarSeparatorStyle, .none)
+
+ controller.updateConfiguration(
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: true
+ )
+ XCTAssertEqual(window.alphaValue, 0.75, accuracy: 0.001)
+ XCTAssertEqual(window.backgroundColor.alphaComponent, 0, accuracy: 0.001)
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertEqual(window.alphaValue, 0.75, accuracy: 0.001)
+ XCTAssertEqual(window.backgroundColor.alphaComponent, 1, accuracy: 0.001)
+ XCTAssertEqual(window.titlebarSeparatorStyle, .line)
+ }
+ }
+
+ func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async {
+ await preservingStableFrameDefaultsAsync {
+ let window = NSWindow(
+ contentRect: NSRect(x: 100, y: 100, width: 420, height: 760),
+ styleMask: [
+ .titled,
+ .closable,
+ .resizable,
+ .fullSizeContentView,
+ ],
+ backing: .buffered,
+ defer: false
+ )
+ window.identifier = CodexNotesWindowIdentifier.main
+ window.alphaValue = 0
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.contentView?.layoutSubtreeIfNeeded()
+ let expandedFrame = window.frame
+ let titlebarHeight = max(
+ 0,
+ expandedFrame.height - window.contentLayoutRect.height
+ )
+ let expectedCompactHeight = ceil(
+ titlebarHeight + MainWindowCompactController.compactContentHeight
+ )
+ XCTAssertGreaterThan(titlebarHeight, 0)
+ XCTAssertGreaterThan(
+ expectedCompactHeight,
+ MainWindowCompactController.compactContentHeight
+ )
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertEqual(
+ window.frame.width,
+ MainWindowCompactController.compactWindowWidth,
+ accuracy: 0.01
+ )
+ XCTAssertEqual(window.frame.maxX, expandedFrame.maxX, accuracy: 0.01)
+ XCTAssertEqual(window.frame.height, expectedCompactHeight, accuracy: 0.01)
+ XCTAssertEqual(window.frame.maxY, expandedFrame.maxY, accuracy: 0.01)
+
+ for _ in 0..<3 {
+ await Task.yield()
+ }
+ try? await Task.sleep(for: .milliseconds(100))
+
+ XCTAssertEqual(
+ window.frame.width,
+ MainWindowCompactController.compactWindowWidth,
+ accuracy: 0.01
+ )
+ XCTAssertEqual(window.frame.maxX, expandedFrame.maxX, accuracy: 0.01)
+ XCTAssertEqual(window.frame.height, expectedCompactHeight, accuracy: 0.01)
+ XCTAssertEqual(window.frame.maxY, expandedFrame.maxY, accuracy: 0.01)
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, expandedFrame)
+ XCTAssertEqual(
+ window.contentMinSize,
+ MainWindowCompactController.minimumExpandedContentSize
+ )
+ }
+ }
+
+ func testTitlebarToggleCollapsesAndExpandsWithoutChangingStableFrame() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ let initialFrame = window.frame
+ let originalAccessoryCount = window.titlebarAccessoryViewControllers.count
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+
+ XCTAssertEqual(
+ window.titlebarAccessoryViewControllers.count,
+ originalAccessoryCount + 1
+ )
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertEqual(window.frame.maxX, initialFrame.maxX, accuracy: 0.01)
+ XCTAssertEqual(
+ window.frame.width,
+ MainWindowCompactController.compactWindowWidth,
+ accuracy: 0.01
+ )
+ XCTAssertEqual(window.frame.maxY, initialFrame.maxY, accuracy: 0.01)
+ XCTAssertLessThan(window.frame.height, 50)
+ XCTAssertEqual(
+ window.contentRect(forFrameRect: window.frame).height,
+ MainWindowCompactController.compactContentHeight,
+ accuracy: 0.01
+ )
+ XCTAssertLessThanOrEqual(
+ window.contentMinSize.width,
+ MainWindowCompactController.compactWindowWidth
+ )
+ XCTAssertFalse(window.styleMask.contains(.resizable))
+ XCTAssertEqual(
+ UserDefaults.standard.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialFrameDescriptor(for: initialFrame, using: window)
+ )
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertEqual(window.contentMinSize, NSSize(width: 340, height: 520))
+
+ controller.detach()
+ XCTAssertEqual(
+ window.titlebarAccessoryViewControllers.count,
+ originalAccessoryCount
+ )
+ }
+ }
+
+ func testCompactPillShowsOnlyTitleAndExpandAccessoryThenRestoresCloseButton() throws {
+ try preservingStableFrameDefaultsThrowing {
+ let window = makeWindow()
+ window.title = "CodexNotes"
+ window.titleVisibility = .visible
+ MainWindowChromePolicy.apply(
+ to: window,
+ localization: AppLocalization(preference: .english)
+ )
+ let closeButton = try XCTUnwrap(
+ window.standardWindowButton(.closeButton)
+ )
+ let miniaturizeButton = try XCTUnwrap(
+ window.standardWindowButton(.miniaturizeButton)
+ )
+ let zoomButton = try XCTUnwrap(
+ window.standardWindowButton(.zoomButton)
+ )
+ let originalAccessoryCount =
+ window.titlebarAccessoryViewControllers.count
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ let compactAccessory = try XCTUnwrap(
+ window.titlebarAccessoryViewControllers.last
+ )
+ let accessoryButtons = compactAccessory.view.subviews.compactMap {
+ $0 as? NSButton
+ }
+ let accessoryButton = try XCTUnwrap(accessoryButtons.first)
+
+ XCTAssertEqual(
+ window.titlebarAccessoryViewControllers.count,
+ originalAccessoryCount + 1
+ )
+ XCTAssertEqual(accessoryButtons.count, 1)
+ XCTAssertFalse(closeButton.isHidden)
+ XCTAssertTrue(miniaturizeButton.isHidden)
+ XCTAssertTrue(zoomButton.isHidden)
+ XCTAssertEqual(
+ accessoryButton.accessibilityLabel(),
+ L10n.text(.mainWindowCollapseAccessibilityLabel)
+ )
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertTrue(closeButton.isHidden)
+ XCTAssertTrue(miniaturizeButton.isHidden)
+ XCTAssertTrue(zoomButton.isHidden)
+ XCTAssertEqual(window.title, "CodexNotes")
+ XCTAssertEqual(window.titleVisibility, .visible)
+ XCTAssertFalse(compactAccessory.view.isHidden)
+ XCTAssertFalse(accessoryButton.isHidden)
+ XCTAssertTrue(accessoryButton.isEnabled)
+ XCTAssertNotNil(accessoryButton.image)
+ XCTAssertEqual(
+ accessoryButton.accessibilityLabel(),
+ L10n.text(.mainWindowExpandAccessibilityLabel)
+ )
+ XCTAssertEqual(accessoryButtons.count, 1)
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertFalse(closeButton.isHidden)
+ XCTAssertTrue(closeButton.isEnabled)
+ XCTAssertEqual(
+ accessoryButton.accessibilityLabel(),
+ L10n.text(.mainWindowCollapseAccessibilityLabel)
+ )
+
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(closeButton.isHidden)
+ controller.detach()
+ XCTAssertFalse(closeButton.isHidden)
+ }
+ }
+
+ func testCollapseTransitionIsAtomicAndAppliesCompactAppearance() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ let backgroundColor = NSColor(
+ calibratedRed: 0.18,
+ green: 0.24,
+ blue: 0.31,
+ alpha: 1
+ )
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: backgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+
+ assertOrdinaryTitlebarAppearance(window)
+ controller.toggleFromTitlebar()
+
+ assertCompactTitlebarAppearance(window)
+ XCTAssertTrue(controller.isCompactContentPresentationActive)
+ XCTAssertFalse(window.styleMask.contains(.resizable))
+ XCTAssertLessThan(window.frame.height, 50)
+ }
+ }
+
+ func testExpandTransitionIsAtomicAndRestoresOrdinaryAppearance() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.toggleFromTitlebar()
+ assertCompactTitlebarAppearance(window)
+ XCTAssertTrue(controller.isCompactContentPresentationActive)
+
+ controller.updateConfiguration(
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ controller.toggleFromTitlebar()
+
+ assertOrdinaryTitlebarAppearance(window)
+ XCTAssertFalse(controller.isCompactContentPresentationActive)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertGreaterThan(window.frame.height, 500)
+ }
+ }
+
+ func testCoordinatorDoesNotPersistTransientCompactResize() {
+ preservingStableFrameDefaults {
+ let availability = CompactTestCodexAvailabilityMonitor(
+ isCodexAvailable: false
+ )
+ let coordinator = WindowConfigurator.Coordinator(
+ codexAvailabilityMonitor: availability
+ )
+ let compactController = MainWindowCompactController()
+ let editor = MarkdownEditorController()
+ let window = makeWindow()
+ defer {
+ coordinator.detach()
+ window.orderOut(nil)
+ }
+
+ coordinator.attach(
+ to: window,
+ languageRevision: "test",
+ compactController: compactController,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ MainWindowFramePersistence.persist(window: window)
+ let persistedExpandedDescriptor = window.frameDescriptor
+
+ compactController.toggleFromTitlebar()
+ NotificationCenter.default.post(
+ name: NSWindow.didResizeNotification,
+ object: window
+ )
+ NotificationCenter.default.post(
+ name: NSApplication.willTerminateNotification,
+ object: NSApp
+ )
+
+ XCTAssertTrue(compactController.isCollapsed)
+ XCTAssertEqual(
+ UserDefaults.standard.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ persistedExpandedDescriptor
+ )
+ }
+ }
+
+ func testCollapsedPresentationKeepsTransientFrame() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ controller.toggleFromTitlebar()
+ let compactFrame = window.frame
+
+ controller.presentPreservingTransientFrame { candidate in
+ candidate.setFrame(
+ NSRect(x: 5, y: 5, width: 200, height: 200),
+ display: false
+ )
+ }
+
+ XCTAssertEqual(window.frame, compactFrame)
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testDetachRestoresExpandedGeometryAndRemovesAccessory() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ let initialFrame = window.frame
+ let originalAccessoryCount = window.titlebarAccessoryViewControllers.count
+ defer { window.orderOut(nil) }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ controller.toggleFromTitlebar()
+
+ controller.detach()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertEqual(
+ window.titlebarAccessoryViewControllers.count,
+ originalAccessoryCount
+ )
+ }
+ }
+
+ func testContinuousOpenPanelStaysCollapsedAcrossTaskSwitch() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.selectionDidChange(seeding: .open)
+
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testInitialOpenObservationCollapsesOnce() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+
+ controller.observeRightPanel(.open)
+
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.observeRightPanel(.open)
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testDisablingAvoidanceRestoresAutomaticallyCollapsedWindow() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let expandedFrame = window.frame
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.setAutomaticAvoidanceEnabled(false)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, expandedFrame)
+ XCTAssertFalse(controller.isAutomaticAvoidanceEnabled)
+ }
+ }
+
+ func testDisablingAvoidanceRestoresHiddenAutomaticCollapseWithoutShowingWindow() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let expandedFrame = window.frame
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ window.orderOut(nil)
+
+ controller.setAutomaticAvoidanceEnabled(false)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, expandedFrame)
+ XCTAssertFalse(window.isVisible)
+ }
+ }
+
+ func testDisabledAvoidanceIgnoresPanelButKeepsManualToggleAvailable() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.setAutomaticAvoidanceEnabled(false)
+
+ controller.observeRightPanel(.open)
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+ controller.observeRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+ }
+ }
+
+ func testDisablingAvoidanceDoesNotUndoManualCollapse() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ controller.toggleFromTitlebar()
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.setAutomaticAvoidanceEnabled(false)
+
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testReenablingAvoidanceWaitsForAFreshOpenObservation() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.setAutomaticAvoidanceEnabled(false)
+ controller.observeRightPanel(.open)
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.setAutomaticAvoidanceEnabled(true)
+
+ XCTAssertFalse(controller.isCollapsed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testDisablingAvoidanceCancelsPendingAutomaticCollapse() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let scrollView = NSScrollView(
+ frame: NSRect(x: 0, y: 0, width: 420, height: 590)
+ )
+ let textView = CheckboxTextView(frame: scrollView.contentView.bounds)
+ scrollView.documentView = textView
+ window.contentView = scrollView
+
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ editor.attach(to: textView)
+ textView.editorController = editor
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.makeKeyAndOrderFront(nil)
+ window.makeFirstResponder(textView)
+ textView.setMarkedText(
+ "ni",
+ selectedRange: NSRange(location: 2, length: 0),
+ replacementRange: NSRange(location: NSNotFound, length: 0)
+ )
+
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.setAutomaticAvoidanceEnabled(false)
+ textView.unmarkText()
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15))
+
+ XCTAssertFalse(controller.isCollapsed)
+ }
+ }
+
+ func testDisablingAvoidanceKeepsPendingManualCollapse() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let scrollView = NSScrollView(
+ frame: NSRect(x: 0, y: 0, width: 420, height: 590)
+ )
+ let textView = CheckboxTextView(frame: scrollView.contentView.bounds)
+ scrollView.documentView = textView
+ window.contentView = scrollView
+
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ editor.attach(to: textView)
+ textView.editorController = editor
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.makeKeyAndOrderFront(nil)
+ window.makeFirstResponder(textView)
+ textView.setMarkedText(
+ "ni",
+ selectedRange: NSRange(location: 2, length: 0),
+ replacementRange: NSRange(location: NSNotFound, length: 0)
+ )
+
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.setAutomaticAvoidanceEnabled(false)
+ textView.unmarkText()
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15))
+
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testClosedPanelAfterTaskSwitchRestoresAutomaticCollapse() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.selectionDidChange(seeding: .closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ }
+ }
+
+ func testUnknownPanelAfterTaskSwitchFailsOpen() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.selectionDidChange(seeding: .unknown)
+
+ XCTAssertFalse(controller.isCollapsed)
+ }
+ }
+
+ func testAutomaticCollapseWaitsForMarkedTextToEnd() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let scrollView = NSScrollView(
+ frame: NSRect(x: 0, y: 0, width: 420, height: 590)
+ )
+ let textView = CheckboxTextView(
+ frame: scrollView.contentView.bounds
+ )
+ scrollView.documentView = textView
+ window.contentView = scrollView
+
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ editor.attach(to: textView)
+ textView.editorController = editor
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.makeKeyAndOrderFront(nil)
+ window.makeFirstResponder(textView)
+ textView.setMarkedText(
+ "ni",
+ selectedRange: NSRange(location: 2, length: 0),
+ replacementRange: NSRange(location: NSNotFound, length: 0)
+ )
+ XCTAssertTrue(textView.hasMarkedText())
+
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+
+ XCTAssertFalse(controller.isCollapsed)
+ window.makeFirstResponder(nil)
+ NotificationCenter.default.post(
+ name: NSWindow.didResignKeyNotification,
+ object: window
+ )
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
+ XCTAssertFalse(controller.isCollapsed)
+ textView.unmarkText()
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testPanelCloseAfterAtomicCollapseRestoresExpandedWindow() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+
+ controller.observeRightPanel(.open)
+ controller.observeRightPanel(.closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ }
+ }
+
+ func testPanelCloseRestoresExpandedPreOpenStateAfterManualRoundTrip() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let expandedFrameBeforePanel = window.frame
+ controller.seedRightPanel(.closed)
+
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, expandedFrameBeforePanel)
+ }
+ }
+
+ func testPanelCloseKeepsPreExistingCompactStateAfterManualRoundTrip() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.open)
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+
+ XCTAssertTrue(controller.isCollapsed)
+ }
+ }
+
+ func testPanelCloseCancelsDelayedManualCollapseWhenPreOpenStateWasExpanded() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let scrollView = NSScrollView(
+ frame: NSRect(x: 0, y: 0, width: 420, height: 590)
+ )
+ let textView = CheckboxTextView(frame: scrollView.contentView.bounds)
+ scrollView.documentView = textView
+ window.contentView = scrollView
+
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ editor.attach(to: textView)
+ textView.editorController = editor
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+
+ window.makeKeyAndOrderFront(nil)
+ window.makeFirstResponder(textView)
+ textView.setMarkedText(
+ "ni",
+ selectedRange: NSRange(location: 2, length: 0),
+ replacementRange: NSRange(location: NSNotFound, length: 0)
+ )
+ controller.toggleFromTitlebar()
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+ textView.unmarkText()
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15))
+
+ XCTAssertFalse(controller.isCollapsed)
+ }
+ }
+
+ func testPanelCloseRestoresHiddenAutomaticCollapseWithoutShowingWindow() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let expandedFrameBeforePanel = window.frame
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ window.orderOut(nil)
+ XCTAssertFalse(window.isVisible)
+
+ controller.observeRightPanel(.closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, expandedFrameBeforePanel)
+ XCTAssertFalse(window.isVisible)
+ }
+ }
+
+ func testAutomaticPanelCloseRestoresExactFrameAfterCompactBarDrifts() {
+ let suiteName = "CodexNotesTests.AutomaticFrameRestore.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ return XCTFail("Could not create isolated test defaults")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let window = makeWindow()
+ let controller = MainWindowCompactController(
+ framePersistenceDefaults: defaults
+ )
+ controller.setAutomaticAvoidanceEnabled(true)
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let initialFrame = window.frame
+ let initialDescriptor = initialFrameDescriptor(
+ for: initialFrame,
+ using: window
+ )
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialDescriptor
+ )
+
+ let compactFrame = window.frame
+ window.setFrameOrigin(
+ NSPoint(
+ x: compactFrame.minX + 120,
+ y: compactFrame.minY + 80
+ )
+ )
+ XCTAssertNotEqual(window.frame.origin, compactFrame.origin)
+
+ controller.observeRightPanel(.closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialDescriptor
+ )
+ }
+
+ func testManualExpandStillFollowsMovedCompactBar() {
+ let suiteName = "CodexNotesTests.ManualFrameRestore.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ return XCTFail("Could not create isolated test defaults")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let window = makeWindow()
+ let controller = MainWindowCompactController(
+ framePersistenceDefaults: defaults
+ )
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let initialFrame = window.frame
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ let compactFrame = window.frame
+ let movedCompactFrame = compactFrame.offsetBy(dx: 120, dy: 80)
+ window.setFrame(movedCompactFrame, display: false)
+
+ controller.toggleFromTitlebar()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX)
+ XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY)
+ XCTAssertEqual(window.frame.size, initialFrame.size)
+ }
+
+ func testTaskSwitchFailOpenRestoresExactFrameAfterCompactBarDrifts() {
+ let suiteName = "CodexNotesTests.TaskSwitchFrameRestore.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ return XCTFail("Could not create isolated test defaults")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let window = makeWindow()
+ let controller = MainWindowCompactController(
+ framePersistenceDefaults: defaults
+ )
+ controller.setAutomaticAvoidanceEnabled(true)
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let initialFrame = window.frame
+ let initialDescriptor = initialFrameDescriptor(
+ for: initialFrame,
+ using: window
+ )
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ let compactFrame = window.frame
+ window.setFrame(
+ compactFrame.offsetBy(dx: 120, dy: 80),
+ display: false
+ )
+ controller.selectionDidChange(seeding: .closed)
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialDescriptor
+ )
+ }
+
+ func testPanelOpenAfterAtomicExpandEndsCollapsed() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+ controller.observeRightPanel(.open)
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertFalse(window.styleMask.contains(.resizable))
+ }
+ }
+
+ func testDefaultSizeRestoreAfterAtomicCollapseKeepsStableFrame() {
+ let suiteName = "CodexNotesTests.CompactRestore.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ return XCTFail("Could not create isolated test defaults")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ do {
+ let window = makeWindow()
+ let controller = MainWindowCompactController(
+ framePersistenceDefaults: defaults
+ )
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ MainWindowFramePersistence.persist(
+ window: window,
+ defaults: defaults
+ )
+
+ controller.toggleFromTitlebar()
+ controller.prepareForDefaultSizeRestore()
+ XCTAssertTrue(
+ MainWindowFramePersistence.restoreDefaultSize(
+ window: window,
+ defaults: defaults
+ )
+ )
+ let restoredFrame = window.frame
+ let restoredDescriptor = window.frameDescriptor
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertEqual(window.frame, restoredFrame)
+ XCTAssertGreaterThan(window.frame.height, 500)
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ restoredDescriptor
+ )
+ }
+ }
+
+ func testDetachAfterAtomicCollapseRestoresExpandedFrame() {
+ preservingStableFrameDefaults {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer { window.orderOut(nil) }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: false,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let initialFrame = window.frame
+ let initialDescriptor = initialFrameDescriptor(
+ for: initialFrame,
+ using: window
+ )
+
+ controller.toggleFromTitlebar()
+ controller.detach()
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertEqual(
+ UserDefaults.standard.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialDescriptor
+ )
+ }
+ }
+
+ func testSustainedUnknownExpandsAutomaticallyCollapsedWindow() async {
+ let suiteName = "CodexNotesTests.UnknownFrameRestore.\(UUID().uuidString)"
+ guard let defaults = UserDefaults(suiteName: suiteName) else {
+ return XCTFail("Could not create isolated test defaults")
+ }
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let window = makeWindow()
+ let controller = MainWindowCompactController(
+ framePersistenceDefaults: defaults
+ )
+ controller.setAutomaticAvoidanceEnabled(true)
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ let initialFrame = window.frame
+ let initialDescriptor = initialFrameDescriptor(
+ for: initialFrame,
+ using: window
+ )
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ let compactFrame = window.frame
+ window.setFrame(
+ compactFrame.offsetBy(dx: 120, dy: 80),
+ display: false
+ )
+
+ controller.observeRightPanel(.unknown)
+ try? await Task.sleep(for: .milliseconds(1_100))
+
+ XCTAssertFalse(controller.isCollapsed)
+ XCTAssertTrue(window.styleMask.contains(.resizable))
+ XCTAssertEqual(window.frame, initialFrame)
+ XCTAssertEqual(
+ defaults.string(
+ forKey: MainWindowFramePersistence.autosaveDefaultsKey
+ ),
+ initialDescriptor
+ )
+
+ controller.observeRightPanel(.open)
+ XCTAssertFalse(controller.isCollapsed)
+
+ controller.observeRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+ }
+
+ func testKnownOpenRecoveryCancelsUnknownFailOpen() async {
+ await preservingStableFrameDefaultsAsync {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.closed)
+ controller.observeRightPanel(.open)
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.unknown)
+ try? await Task.sleep(for: .milliseconds(100))
+ controller.observeRightPanel(.open)
+ try? await Task.sleep(for: .milliseconds(1_050))
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertFalse(window.styleMask.contains(.resizable))
+ }
+ }
+
+ func testSustainedUnknownDoesNotExpandManuallyCollapsedWindow() async {
+ await preservingStableFrameDefaultsAsync {
+ let window = makeWindow()
+ let controller = makeController()
+ let editor = MarkdownEditorController()
+ defer {
+ controller.detach()
+ window.orderOut(nil)
+ }
+ controller.attach(
+ to: window,
+ editorController: editor,
+ backgroundColor: .windowBackgroundColor,
+ reduceMotion: true,
+ reduceTransparency: false
+ )
+ window.orderFrontRegardless()
+ controller.seedRightPanel(.open)
+ controller.toggleFromTitlebar()
+ XCTAssertTrue(controller.isCollapsed)
+
+ controller.observeRightPanel(.unknown)
+ try? await Task.sleep(for: .milliseconds(1_100))
+
+ XCTAssertTrue(controller.isCollapsed)
+ XCTAssertFalse(window.styleMask.contains(.resizable))
+ }
+ }
+
+ private func makeWindow() -> NSWindow {
+ let window = NSWindow(
+ contentRect: NSRect(x: 100, y: 100, width: 420, height: 590),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.identifier = CodexNotesWindowIdentifier.main
+ window.alphaValue = 0
+ return window
+ }
+
+ private func makeController() -> MainWindowCompactController {
+ let controller = MainWindowCompactController()
+ controller.setAutomaticAvoidanceEnabled(true)
+ return controller
+ }
+
+ private func preservingStableFrameDefaults(_ body: () -> Void) {
+ let defaults = UserDefaults.standard
+ let key = MainWindowFramePersistence.autosaveDefaultsKey
+ let originalValue = defaults.object(forKey: key)
+ defaults.removeObject(forKey: key)
+ defer {
+ if let originalValue {
+ defaults.set(originalValue, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ body()
+ }
+
+ private func preservingStableFrameDefaultsThrowing(
+ _ body: () throws -> Void
+ ) rethrows {
+ let defaults = UserDefaults.standard
+ let key = MainWindowFramePersistence.autosaveDefaultsKey
+ let originalValue = defaults.object(forKey: key)
+ defaults.removeObject(forKey: key)
+ defer {
+ if let originalValue {
+ defaults.set(originalValue, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ try body()
+ }
+
+ private func preservingStableFrameDefaultsAsync(
+ _ body: () async -> Void
+ ) async {
+ let defaults = UserDefaults.standard
+ let key = MainWindowFramePersistence.autosaveDefaultsKey
+ let originalValue = defaults.object(forKey: key)
+ defaults.removeObject(forKey: key)
+ defer {
+ if let originalValue {
+ defaults.set(originalValue, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ await body()
+ }
+
+ private func initialFrameDescriptor(
+ for frame: NSRect,
+ using window: NSWindow
+ ) -> String {
+ let currentFrame = window.frame
+ window.setFrame(frame, display: false)
+ let descriptor = window.frameDescriptor
+ window.setFrame(currentFrame, display: false)
+ return descriptor
+ }
+
+ private func assertOrdinaryTitlebarAppearance(
+ _ window: NSWindow,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ XCTAssertTrue(window.isOpaque, file: file, line: line)
+ XCTAssertFalse(
+ window.titlebarAppearsTransparent,
+ file: file,
+ line: line
+ )
+ XCTAssertEqual(
+ window.backgroundColor.alphaComponent,
+ 1,
+ accuracy: 0.001,
+ file: file,
+ line: line
+ )
+ }
+
+ private func assertCompactTitlebarAppearance(
+ _ window: NSWindow,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ XCTAssertFalse(window.isOpaque, file: file, line: line)
+ XCTAssertTrue(
+ window.titlebarAppearsTransparent,
+ file: file,
+ line: line
+ )
+ XCTAssertEqual(
+ window.backgroundColor.alphaComponent,
+ 0,
+ accuracy: 0.001,
+ file: file,
+ line: line
+ )
+ XCTAssertEqual(
+ window.titlebarSeparatorStyle,
+ .none,
+ file: file,
+ line: line
+ )
+ }
+}
+
+@MainActor
+private final class CompactTestCodexAvailabilityMonitor:
+ CodexApplicationAvailabilityObserving
+{
+ let isCodexAvailable: Bool
+
+ init(isCodexAvailable: Bool) {
+ self.isCodexAvailable = isCodexAvailable
+ }
+
+ func start(onChange: @escaping (Bool) -> Void) {}
+
+ func stop() {}
+}
diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift
new file mode 100644
index 0000000..74c532b
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift
@@ -0,0 +1,510 @@
+import XCTest
+@testable import CodexNotesProbe
+
+final class MainWindowCompactStateTests: XCTestCase {
+ func testUnknownObservationDoesNotTriggerOrBecomeAClosedBaseline() {
+ var state = MainWindowCompactState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .unknown,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ }
+
+ func testFirstOpenObservationCollapsesVisibleExpandedWindow() {
+ var state = MainWindowCompactState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .open)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ }
+
+ func testExplicitInitialOpenObservationCollapsesVisibleExpandedWindow() {
+ var state = MainWindowCompactState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false,
+ isInitialObservation: true
+ ),
+ .collapse
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .open)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ }
+
+ func testIncrementalClosedToOpenCollapsesVisibleExpandedWindow() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .open)
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testRepeatedOpenWithinSameCycleDoesNotCollapseAgain() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ }
+
+ func testUnknownDuringOpenCycleDoesNotResetCycleOrCauseRetrigger() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .unknown,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .open)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ }
+
+ func testUnknownThenFirstOpenCollapsesOnceInCurrentCycle() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .unknown,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .open)
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ }
+
+ func testFailOpenReleaseDoesNotRehandleTheSameOpenCycle() {
+ var state = automaticallyCollapsedState()
+
+ state.releaseAutomaticCollapseForUncertainty()
+
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .expand
+ )
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ }
+
+ func testOpenObservationForAnotherSelectionDoesNotResetHandledCycle() {
+ var state = automaticallyCollapsedState()
+
+ // A task-selection change is not a right-panel close. Consumers must
+ // preserve this state and report the still-open panel normally.
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ }
+
+ func testExpandedBeforeOpenAndCollapsedAtCloseRestoresExpandedState() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .expand
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testExpandedBeforeOpenAndExpandedAtCloseStaysExpanded() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .expand
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testCollapsedBeforeOpenAndCollapsedAtCloseStaysCollapsed() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testCollapsedBeforeOpenAndExpandedAtCloseStaysExpanded() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testExpandedBeforeOpenIsRestoredAfterManualExpandThenCollapse() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .expand
+ )
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testPanelCanAutomaticallyCollapseAgainInANewOpenCycle() {
+ var state = automaticallyCollapsedState()
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .expand
+ )
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ XCTAssertEqual(state.collapseOrigin, .automatic)
+ }
+
+ func testManualExpandDuringOpenCycleKeepsWindowExpandedAtClose() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+ XCTAssertTrue(state.userOverrodeCurrentOpenCycle)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .expand
+ )
+ }
+
+ func testManualCollapseWithNoExpandedPreOpenBaselineStaysCollapsed() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse)
+ XCTAssertEqual(state.collapseOrigin, .manual)
+ XCTAssertTrue(state.userOverrodeCurrentOpenCycle)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.collapseOrigin, .manual)
+ }
+
+ func testManualShowExpandsAutomaticCollapseAndOverridesOpenCycle() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(state.recordManualShow(isCollapsed: true), .expand)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertTrue(state.userOverrodeCurrentOpenCycle)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .expand
+ )
+ }
+
+ func testManualShowLeavesExpandedWindowExpandedAndOverridesOpenCycle() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.open)
+
+ XCTAssertEqual(state.recordManualShow(isCollapsed: false), .none)
+ XCTAssertTrue(state.userOverrodeCurrentOpenCycle)
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ }
+
+ func testManualShowOutsideOpenCycleDoesNotChangeCompactState() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(state.recordManualShow(isCollapsed: true), .none)
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .closed)
+ XCTAssertFalse(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testOpenWhileWindowHiddenConsumesCycleWithoutCollapsingLater() {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: false,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertTrue(state.hasHandledCurrentOpenCycle)
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .none
+ )
+ XCTAssertEqual(state.recordManualShow(isCollapsed: false), .none)
+ XCTAssertTrue(state.userOverrodeCurrentOpenCycle)
+ }
+
+ func testOpenWhileAlreadyCollapsedDoesNotClaimAutomaticOwnership() {
+ var state = MainWindowCompactState()
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse)
+ state.seedRightPanel(.closed)
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse)
+
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ XCTAssertEqual(state.collapseOrigin, .manual)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .closed,
+ isWindowVisible: true,
+ isCollapsed: true
+ ),
+ .none
+ )
+ }
+
+ func testDisablingAvoidanceRestoresAutomaticallyOwnedExpandedBaseline() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(
+ state.disableAutomaticAvoidance(isCollapsed: true),
+ .expand
+ )
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown)
+ XCTAssertNil(state.wasExpandedBeforeCurrentOpenCycle)
+ XCTAssertFalse(state.hasHandledCurrentOpenCycle)
+ }
+
+ func testDisablingAvoidanceCancelsPendingAutomaticCollapseWithoutExpanding() {
+ var state = automaticallyCollapsedState()
+
+ XCTAssertEqual(
+ state.disableAutomaticAvoidance(isCollapsed: false),
+ .none
+ )
+ XCTAssertNil(state.collapseOrigin)
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown)
+ }
+
+ func testDisablingAvoidanceDoesNotUndoManualCollapseDuringOpenCycle() {
+ var state = automaticallyCollapsedState()
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand)
+ XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse)
+ XCTAssertEqual(state.collapseOrigin, .manual)
+
+ XCTAssertEqual(
+ state.disableAutomaticAvoidance(isCollapsed: true),
+ .none
+ )
+ XCTAssertEqual(state.collapseOrigin, .manual)
+ XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown)
+ XCTAssertNil(state.wasExpandedBeforeCurrentOpenCycle)
+ }
+
+ private func automaticallyCollapsedState() -> MainWindowCompactState {
+ var state = MainWindowCompactState()
+ state.seedRightPanel(.closed)
+ XCTAssertEqual(
+ state.observeRightPanel(
+ .open,
+ isWindowVisible: true,
+ isCollapsed: false
+ ),
+ .collapse
+ )
+ return state
+ }
+}
diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
index 026a605..e034fcd 100644
--- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
+++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
@@ -1010,6 +1010,15 @@ final class PlainMarkdownTextViewTests: XCTestCase {
selectedRange: NSRange(location: 5, length: 0),
replacementRange: NSRange(location: NSNotFound, length: 0)
)
+ let expectedSelection = NSRange(
+ location: ((first.markdown + "\r\nceshi") as NSString).length,
+ length: 0
+ )
+ var selectionAtCompositionEnd: NSRange?
+ let markedTextView = markedHarness.textView
+ markedHarness.controller.textCompositionDidEnd = {
+ selectionAtCompositionEnd = markedTextView.selectedRange()
+ }
markedHarness.textView.unmarkText()
XCTAssertEqual(
markedHarness.textView.string,
@@ -1018,11 +1027,9 @@ final class PlainMarkdownTextViewTests: XCTestCase {
XCTAssertEqual(MarkdownImage.matches(in: markedHarness.textView.string).count, 2)
XCTAssertEqual(
markedHarness.textView.selectedRange(),
- NSRange(
- location: ((first.markdown + "\r\nceshi") as NSString).length,
- length: 0
- )
+ expectedSelection
)
+ XCTAssertEqual(selectionAtCompositionEnd, expectedSelection)
markedHarness.textView.insertText(
"后",
replacementRange: markedHarness.textView.selectedRange()
@@ -1555,6 +1562,42 @@ final class PlainMarkdownTextViewTests: XCTestCase {
XCTAssertEqual(textView.string, text + "拼")
}
+ func testCompositionEndCallbackRunsAfterPendingPresentationCleanup() throws {
+ let text = "- [ ] 这是一条需要换行的待办内容。"
+ let harness = makeHarness(text: text, textWidth: 170, refresh: false)
+ let textView = harness.textView
+ let sourceLength = (textView.string as NSString).length
+ textView.setSelectedRange(NSRange(location: sourceLength, length: 0))
+ textView.setMarkedText(
+ "拼",
+ selectedRange: NSRange(location: 1, length: 0),
+ replacementRange: NSRange(location: sourceLength, length: 0)
+ )
+ textView.refreshChecklistPresentation(forceLayout: true)
+
+ var callbackCount = 0
+ var callbackSawMarkedText = true
+ var callbackHeadIndent: CGFloat?
+ harness.controller.textCompositionDidEnd = {
+ callbackCount += 1
+ callbackSawMarkedText = textView.hasMarkedText()
+ callbackHeadIndent = (
+ textView.textStorage?.attribute(
+ .paragraphStyle,
+ at: 0,
+ effectiveRange: nil
+ ) as? NSParagraphStyle
+ )?.headIndent
+ }
+
+ textView.unmarkText()
+
+ XCTAssertEqual(callbackCount, 1)
+ XCTAssertFalse(callbackSawMarkedText)
+ XCTAssertGreaterThan(try XCTUnwrap(callbackHeadIndent), 0)
+ XCTAssertEqual(textView.string, text + "拼")
+ }
+
func testMarkedTextKeepsFollowingCheckboxPrefixAtItsCurrentRange() throws {
let firstTodo = "- [x] 第一条已完成待办"
let secondTodo = "- [x] 第二条已完成待办"
diff --git a/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift b/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift
new file mode 100644
index 0000000..e2ff527
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift
@@ -0,0 +1,31 @@
+import CodexNotesCore
+import Foundation
+import XCTest
+@testable import CodexNotesProbe
+
+@MainActor
+final class ProbeViewModelRightPanelAvoidanceTests: XCTestCase {
+ func testDetectorCanBeDisabledAndReenabledIndependently() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ let model = ProbeViewModel(noteStore: NoteStore(rootURL: root))
+
+ XCTAssertFalse(model.isRightPanelAvoidanceEnabled)
+
+ model.setRightPanelAvoidanceEnabled(true)
+ XCTAssertTrue(model.isRightPanelAvoidanceEnabled)
+ XCTAssertEqual(model.rightPanelObservation.state, .unknown)
+ XCTAssertNotNil(model.rightPanelObservation.selectionStableKey)
+
+ model.setRightPanelAvoidanceEnabled(false)
+ XCTAssertFalse(model.isRightPanelAvoidanceEnabled)
+ XCTAssertEqual(model.rightPanelObservation.state, .unknown)
+ XCTAssertNotNil(model.rightPanelObservation.selectionStableKey)
+
+ model.setRightPanelAvoidanceEnabled(true)
+ XCTAssertTrue(model.isRightPanelAvoidanceEnabled)
+ XCTAssertEqual(model.rightPanelObservation.state, .unknown)
+ XCTAssertNotNil(model.rightPanelObservation.selectionStableKey)
+ }
+}
diff --git a/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift b/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift
new file mode 100644
index 0000000..e3bf1ca
--- /dev/null
+++ b/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift
@@ -0,0 +1,85 @@
+import CodexNotesCore
+import XCTest
+@testable import CodexNotesProbe
+
+final class SettingsRightPanelAvoidancePresentationTests: XCTestCase {
+ func testSettingsUsesTheSharedPersistentPreferenceForTheToggle() throws {
+ let source = try settingsSource()
+
+ XCTAssertTrue(source.contains(
+ "@AppStorage(RightPanelAvoidancePreference.key)"
+ ))
+ XCTAssertTrue(source.contains(
+ "RightPanelAvoidancePreference.defaultValue"
+ ))
+ XCTAssertTrue(source.contains("isOn: $rightPanelAvoidanceEnabled"))
+ XCTAssertTrue(source.contains(".toggleStyle(.switch)"))
+ XCTAssertTrue(source.contains(
+ ".accessibilityLabel(Text(L10n.text(\n"
+ + " .settingsRightPanelAvoidanceTitle"
+ ))
+ XCTAssertTrue(source.contains(
+ ".accessibilityHint(Text(L10n.text(\n"
+ + " .settingsRightPanelAvoidanceDescription"
+ ))
+ }
+
+ func testPermissionStatusIsSeparateFromTheFeatureToggle() throws {
+ let source = try settingsSource()
+
+ XCTAssertTrue(source.contains(
+ ".settingsRightPanelAvoidancePermissionTitle"
+ ))
+ XCTAssertTrue(source.contains(
+ ".settingsRightPanelAvoidanceAuthorized"
+ ))
+ XCTAssertTrue(source.contains(
+ ".settingsRightPanelAvoidanceNotAuthorized"
+ ))
+ XCTAssertTrue(source.contains(
+ "if rightPanelAvoidanceEnabled && !isAccessibilityAuthorized"
+ ))
+ XCTAssertFalse(source.contains("if !isAccessibilityAuthorized {"))
+ XCTAssertTrue(source.contains(
+ ".settingsRightPanelAvoidanceRequestPermission"
+ ))
+ XCTAssertTrue(source.contains(
+ ".settingsRightPanelAvoidanceOpenSystemSettings"
+ ))
+ }
+
+ func testCopyExplainsAutomaticBehaviorAndIndependentManualCollapse() {
+ let chinese = AppLocalization(preference: .simplifiedChinese)
+ let english = AppLocalization(preference: .english)
+
+ XCTAssertEqual(
+ chinese.text(.settingsRightPanelAvoidancePermissionTitle),
+ "辅助功能权限"
+ )
+ XCTAssertEqual(
+ english.text(.settingsRightPanelAvoidancePermissionTitle),
+ "Accessibility Permission"
+ )
+ XCTAssertTrue(
+ chinese.text(.settingsRightPanelAvoidanceDescription)
+ .contains("手动折叠和展开")
+ )
+ XCTAssertTrue(
+ english.text(.settingsRightPanelAvoidanceDescription)
+ .contains("manually")
+ )
+ }
+
+ private func settingsSource() throws -> String {
+ let repositoryRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ return try String(
+ contentsOf: repositoryRoot.appendingPathComponent(
+ "Sources/CodexNotesProbe/SettingsView.swift"
+ ),
+ encoding: .utf8
+ )
+ }
+}
From a88a27455286e8aab899928d7a6b2e141dd1559c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?=
<66911217+jiangsir-tech@users.noreply.github.com>
Date: Thu, 13 Aug 2026 10:55:11 +0800
Subject: [PATCH 2/4] Fix Xcode 15.4 actor isolation
---
Sources/CodexNotesProbe/ContentView.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift
index 0b57a8d..c19ddfd 100644
--- a/Sources/CodexNotesProbe/ContentView.swift
+++ b/Sources/CodexNotesProbe/ContentView.swift
@@ -2654,6 +2654,7 @@ struct WindowConfigurator: NSViewRepresentable {
coordinator.invalidate()
}
+ @MainActor
private func applyTheme(to window: NSWindow) {
if let appearanceName {
if window.appearance?.name != appearanceName {
From c1a259f16fc5218c7cf5dad1e4272db268380b8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?=
<66911217+jiangsir-tech@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:10:15 +0800
Subject: [PATCH 3/4] Stabilize compact window CI tests
---
.../RightPanelAvoidancePreferenceTests.swift | 5 +-
.../MainWindowCompactControllerTests.swift | 48 ++++++++++++++++---
2 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
index e454be2..5d3a4c6 100644
--- a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
+++ b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift
@@ -58,9 +58,8 @@ final class RightPanelAvoidancePreferenceTests: XCTestCase {
defaults.set(true, forKey: "temporary")
defaults.removeObject(forKey: "temporary")
- XCTAssertEqual(
- defaults.persistentDomain(forName: suiteName)?.isEmpty,
- true
+ XCTAssertTrue(
+ defaults.persistentDomain(forName: suiteName)?.isEmpty ?? true
)
XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded(
defaults: defaults,
diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
index a019753..716a684 100644
--- a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
+++ b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift
@@ -249,10 +249,25 @@ final class MainWindowCompactControllerTests: XCTestCase {
}
}
- func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async {
+ func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async throws {
+ guard let visibleFrame = NSScreen.main?.visibleFrame else {
+ throw XCTSkip("AppKit window integration requires a visible screen")
+ }
+ guard visibleFrame.width >= 380, visibleFrame.height >= 560 else {
+ throw XCTSkip("Visible screen is too small for the expanded-window contract")
+ }
await preservingStableFrameDefaultsAsync {
+ let windowSize = NSSize(
+ width: min(420, visibleFrame.width - 40),
+ height: min(590, visibleFrame.height - 40)
+ )
let window = NSWindow(
- contentRect: NSRect(x: 100, y: 100, width: 420, height: 760),
+ contentRect: NSRect(
+ x: visibleFrame.midX - windowSize.width / 2,
+ y: visibleFrame.midY - windowSize.height / 2,
+ width: windowSize.width,
+ height: windowSize.height
+ ),
styleMask: [
.titled,
.closable,
@@ -1323,7 +1338,7 @@ final class MainWindowCompactControllerTests: XCTestCase {
)
}
- func testManualExpandStillFollowsMovedCompactBar() {
+ func testManualExpandStillFollowsMovedCompactBar() throws {
let suiteName = "CodexNotesTests.ManualFrameRestore.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
return XCTFail("Could not create isolated test defaults")
@@ -1332,6 +1347,23 @@ final class MainWindowCompactControllerTests: XCTestCase {
defer { defaults.removePersistentDomain(forName: suiteName) }
let window = makeWindow()
+ guard let visibleFrame = NSScreen.main?.visibleFrame else {
+ throw XCTSkip("AppKit window integration requires a visible screen")
+ }
+ guard visibleFrame.width >= window.frame.width + 4,
+ visibleFrame.height >= window.frame.height + 4
+ else {
+ throw XCTSkip("Visible screen cannot contain the expanded test window")
+ }
+ window.setFrame(
+ NSRect(
+ x: visibleFrame.midX - window.frame.width / 2,
+ y: visibleFrame.midY - window.frame.height / 2,
+ width: window.frame.width,
+ height: window.frame.height
+ ),
+ display: false
+ )
let controller = MainWindowCompactController(
framePersistenceDefaults: defaults
)
@@ -1354,14 +1386,18 @@ final class MainWindowCompactControllerTests: XCTestCase {
XCTAssertTrue(controller.isCollapsed)
let compactFrame = window.frame
- let movedCompactFrame = compactFrame.offsetBy(dx: 120, dy: 80)
+ let dx = min(120, max(0, visibleFrame.maxX - compactFrame.maxX - 1))
+ let dy = min(80, max(0, visibleFrame.maxY - compactFrame.maxY - 1))
+ XCTAssertGreaterThan(dx, 0)
+ XCTAssertGreaterThan(dy, 0)
+ let movedCompactFrame = compactFrame.offsetBy(dx: dx, dy: dy)
window.setFrame(movedCompactFrame, display: false)
controller.toggleFromTitlebar()
XCTAssertFalse(controller.isCollapsed)
- XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX)
- XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY)
+ XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX, accuracy: 0.01)
+ XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY, accuracy: 0.01)
XCTAssertEqual(window.frame.size, initialFrame.size)
}
From 07086cb7c4cc6ba2cb9c45c078eb465255ac5a75 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?=
<66911217+jiangsir-tech@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:21:48 +0800
Subject: [PATCH 4/4] Stabilize IME performance regression test
---
.../PlainMarkdownTextViewTests.swift | 23 +++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
index e034fcd..cc8f4c4 100644
--- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
+++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift
@@ -1388,9 +1388,11 @@ final class PlainMarkdownTextViewTests: XCTestCase {
textView.setSelectedRange(NSRange(location: insertionLocation, length: 0))
let lengths = Array(1...12) + Array((1...11).reversed())
+ var updateDurations: [Double] = []
let updateStart = CFAbsoluteTimeGetCurrent()
for (index, length) in lengths.enumerated() {
let markedText = String(repeating: "n", count: length)
+ let singleUpdateStart = CFAbsoluteTimeGetCurrent()
textView.setMarkedText(
markedText,
selectedRange: NSRange(location: length, length: 0),
@@ -1398,13 +1400,30 @@ final class PlainMarkdownTextViewTests: XCTestCase {
? NSRange(location: insertionLocation, length: 0)
: NSRange(location: NSNotFound, length: 0)
)
+ updateDurations.append(
+ (CFAbsoluteTimeGetCurrent() - singleUpdateStart) * 1_000
+ )
}
let updateMilliseconds = (CFAbsoluteTimeGetCurrent() - updateStart) * 1_000
+ let sortedDurations = updateDurations.sorted()
+ let percentile90Index = max(
+ 0,
+ Int(ceil(Double(sortedDurations.count) * 0.9)) - 1
+ )
+ let percentile90Milliseconds = sortedDurations[percentile90Index]
+ let maximumUpdateMilliseconds = sortedDurations.last ?? 0
XCTAssertTrue(textView.hasMarkedText())
- XCTAssertLessThan(updateMilliseconds, 1_000)
+ XCTAssertEqual(updateDurations.count, lengths.count)
+ XCTAssertLessThan(percentile90Milliseconds, 100)
+ XCTAssertLessThan(maximumUpdateMilliseconds, 1_000)
+ XCTAssertLessThan(updateMilliseconds, 3_000)
textView.unmarkText()
- print("TEXTKIT_IME_BENCHMARK updates_ms=\(updateMilliseconds)")
+ print(
+ "TEXTKIT_IME_BENCHMARK updates_ms=\(updateMilliseconds) "
+ + "p90_ms=\(percentile90Milliseconds) "
+ + "max_ms=\(maximumUpdateMilliseconds)"
+ )
}
func testChecklistLayoutAcrossFontSpacingAndCompletionMatrix() throws {