Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b77d7c2
Add titlebar visibility options
lawrencecchen Mar 13, 2026
ca6f83f
Refine hidden titlebar behavior
lawrencecchen Mar 13, 2026
cdcfa19
Remove hidden titlebar safe-area gap
lawrencecchen Mar 13, 2026
c8d561c
Add Bonsplit hidden-titlebar drag regression test
lawrencecchen Mar 13, 2026
7385a01
Fix hidden-titlebar pane tab bar controls
lawrencecchen Mar 13, 2026
d5cf95a
Tighten hidden titlebar pane tab layout
lawrencecchen Mar 13, 2026
6b281dc
Remove hidden titlebar top gap
lawrencecchen Mar 13, 2026
3e75be2
Fix hidden-titlebar tab drag host
lawrencecchen Mar 13, 2026
cf3a132
Fix hidden-titlebar pane drag regression
lawrencecchen Mar 13, 2026
6244e49
Add hidden-titlebar top-gap UI test
lawrencecchen Mar 13, 2026
d7d6c60
Remove titlebar accessories when hidden
lawrencecchen Mar 13, 2026
945881b
Use non-movable pane tab bar host
lawrencecchen Mar 13, 2026
cc51646
Underlap hidden workspace content
lawrencecchen Mar 13, 2026
dc394ad
Strengthen hidden titlebar UI regressions
lawrencecchen Mar 13, 2026
9ecefc5
Remove Bonsplit hidden-titlebar inset
lawrencecchen Mar 13, 2026
239ec1d
Wait for Bonsplit UI test setup readiness
lawrencecchen Mar 13, 2026
a82fa63
Add hidden titlebar chrome UI regressions
lawrencecchen Mar 13, 2026
18d2d5f
Fix hidden titlebar chrome regressions
lawrencecchen Mar 13, 2026
5cd4b30
Assert tab drag does not move the window
lawrencecchen Mar 13, 2026
67f2bda
Add raw mouse Bonsplit drag UI regression
lawrencecchen Mar 13, 2026
afd2d21
Fix hidden-titlebar Bonsplit tab drags
lawrencecchen Mar 13, 2026
278a2ff
Stabilize Bonsplit drag UI tests
lawrencecchen Mar 13, 2026
5c42da4
Add stronger Bonsplit drag reorder regression
lawrencecchen Mar 13, 2026
f1e0abe
Fix hidden-titlebar Bonsplit tab reorder drops
lawrencecchen Mar 13, 2026
8d96c97
Add stronger Bonsplit drag and titlebar regressions
lawrencecchen Mar 13, 2026
ae0a4e3
Fix hidden-titlebar traffic lights and Bonsplit drops
lawrencecchen Mar 13, 2026
b61698c
test: cover hidden-titlebar collapsed-sidebar controls
lawrencecchen Mar 15, 2026
87ee59f
fix: collapse hidden-titlebar controls with hidden sidebar
lawrencecchen Mar 15, 2026
39d8cc2
test: cover hidden-titlebar sidebar traffic-light gap
lawrencecchen Mar 15, 2026
3a41872
fix: align hidden-titlebar tabs with the sidebar edge
lawrencecchen Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; };
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; };
C9A10001A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */; };
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
Expand Down Expand Up @@ -158,6 +159,7 @@
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = "<group>"; };
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = "<group>"; };
C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarVisibilitySettings.swift; sourceTree = "<group>"; };
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -357,6 +359,7 @@
A5001012 /* ContentView.swift */,
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */,
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */,
C9A10002A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift */,
A50012F0 /* Backport.swift */,
A50012F2 /* KeyboardShortcutSettings.swift */,
A50012F4 /* KeyboardLayout.swift */,
Expand Down Expand Up @@ -628,6 +631,7 @@
A5001002 /* ContentView.swift in Sources */,
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */,
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */,
C9A10001A1B2C3D4E5F60719 /* TitlebarVisibilitySettings.swift in Sources */,
A50012F1 /* Backport.swift in Sources */,
A50012F3 /* KeyboardShortcutSettings.swift in Sources */,
A50012F5 /* KeyboardLayout.swift in Sources */,
Expand Down
136 changes: 136 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -73093,6 +73093,142 @@
}
}
}
},
"settings.app.showWorkspaceTitlebar": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show Workspace Title Bar"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースのタイトルバーを表示"
}
}
}
},
"settings.app.showWorkspaceTitlebar.subtitleOff": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Hide the folder/title strip and drag from empty space in the top pane tab bar."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "フォルダとタイトルの帯を隠し、上部のペインタブバーの空き領域からウィンドウをドラッグできます。"
}
}
}
},
"settings.app.showWorkspaceTitlebar.subtitleOn": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show the folder and active title above pane tabs."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ペインタブの上にフォルダ名と現在のタイトルを表示します。"
}
}
}
},
"settings.app.titlebarControls": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Titlebar Controls"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タイトルバーのコントロール"
}
}
}
},
"settings.app.titlebarControls.always": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Always Visible"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "常に表示"
}
}
}
},
"settings.app.titlebarControls.hover": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show on Hover"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ホバー時に表示"
}
}
}
},
"settings.app.titlebarControls.subtitleAlways": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keep the sidebar, notifications, and new workspace buttons visible."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サイドバー、通知、新規ワークスペースのボタンを常に表示します。"
}
}
}
},
"settings.app.titlebarControls.subtitleHover": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Hide titlebar buttons until the pointer reaches them."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ポインタが近づくまでタイトルバーのボタンを隠します。"
}
}
}
}
}
}
112 changes: 62 additions & 50 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1931,66 +1931,75 @@ struct ContentView: View {
.frame(width: sidebarWidth)
}

/// Space at top of content area for the titlebar. This must be at least the actual titlebar
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
/// Space at top of content area reserved for the custom workspace titlebar.
@State private var titlebarPadding: CGFloat = 32
private let collapsedTitlebarDragHandleHeight: CGFloat = 30

private var terminalContent: some View {
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
let selectedWorkspaceId = tabManager.selectedTabId
let retiringWorkspaceId = self.retiringWorkspaceId

return ZStack {
return ZStack(alignment: .top) {
ZStack {
ForEach(mountedWorkspaces) { tab in
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
// Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can
// delay handoff completion and make browser returns feel laggy.
let isInputActive = isSelectedWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView(
workspace: tab,
isWorkspaceVisible: isVisible,
isWorkspaceInputActive: isInputActive,
workspacePortalPriority: portalPriority,
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
scheduleTitlebarThemeRefreshFromWorkspace(
workspaceId: tab.id,
reason: reason,
backgroundEventId: eventId,
backgroundSource: source,
notificationPayloadHex: payloadHex
)
ZStack {
ForEach(mountedWorkspaces) { tab in
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
// Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can
// delay handoff completion and make browser returns feel laggy.
let isInputActive = isSelectedWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView(
workspace: tab,
isWorkspaceVisible: isVisible,
isWorkspaceInputActive: isInputActive,
workspacePortalPriority: portalPriority,
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
scheduleTitlebarThemeRefreshFromWorkspace(
workspaceId: tab.id,
reason: reason,
backgroundEventId: eventId,
backgroundSource: source,
notificationPayloadHex: payloadHex
)
}
)
.opacity(isVisible ? 1 : 0)
.allowsHitTesting(isSelectedWorkspace)
.accessibilityHidden(!isVisible)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
.task(id: shouldPrimeInBackground ? tab.id : nil) {
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
}
)
.opacity(isVisible ? 1 : 0)
.allowsHitTesting(isSelectedWorkspace)
.accessibilityHidden(!isVisible)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
.task(id: shouldPrimeInBackground ? tab.id : nil) {
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)
}
}
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
.accessibilityHidden(sidebarSelectionState.selection != .tabs)

NotificationsPage(selection: $sidebarSelectionState.selection)
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
.accessibilityHidden(sidebarSelectionState.selection != .notifications)
Comment on lines +1956 to +2001
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate WorkspaceContentView visibility on the active page.

isWorkspaceVisible / isWorkspaceInputActive stay true for the selected workspace even when sidebarSelectionState.selection == .notifications. The outer .opacity(0) / .allowsHitTesting(false) only hide the SwiftUI wrapper; portal-backed workspace surfaces can still sit above SwiftUI and keep focus, so the notifications page can leak the previously selected workspace underneath.

Suggested fix
-        return ZStack(alignment: .top) {
+        let showsWorkspacePage = sidebarSelectionState.selection == .tabs
+        return ZStack(alignment: .top) {
             ZStack {
                 ZStack {
                     ForEach(mountedWorkspaces) { tab in
                         let isSelectedWorkspace = selectedWorkspaceId == tab.id
                         let isRetiringWorkspace = retiringWorkspaceId == tab.id
                         let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
@@
-                        let isInputActive = isSelectedWorkspace
-                        let isVisible = isSelectedWorkspace || isRetiringWorkspace
-                        let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
+                        let isInputActive = showsWorkspacePage && isSelectedWorkspace
+                        let isVisible = showsWorkspacePage && (isSelectedWorkspace || isRetiringWorkspace)
+                        let portalPriority = isVisible ? (isSelectedWorkspace ? 2 : 1) : 0
@@
-                .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
-                .allowsHitTesting(sidebarSelectionState.selection == .tabs)
-                .accessibilityHidden(sidebarSelectionState.selection != .tabs)
+                .opacity(showsWorkspacePage ? 1 : 0)
+                .allowsHitTesting(showsWorkspacePage)
+                .accessibilityHidden(!showsWorkspacePage)

Based on learnings, Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1944 - 1989, The WorkspaceContentView
instances are still marked visible/input-active when the sidebar is showing
Notifications, letting their portal-backed surfaces remain above SwiftUI; update
the visibility/input logic so mountedWorkspaces computes isWorkspaceVisible and
isWorkspaceInputActive only when sidebarSelectionState.selection == .tabs (e.g.
combine selectedWorkspaceId/retiringWorkspaceId checks with
sidebarSelectionState.selection == .tabs), and use those combined flags when
constructing WorkspaceContentView (and for .opacity, .allowsHitTesting,
.accessibilityHidden, .zIndex, and the .task id) so the workspace portals are
fully deactivated whenever the active page is not .tabs.

}
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
.accessibilityHidden(sidebarSelectionState.selection != .tabs)
.padding(.top, titlebarPadding)

NotificationsPage(selection: $sidebarSelectionState.selection)
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
.accessibilityHidden(sidebarSelectionState.selection != .notifications)
}
.padding(.top, titlebarPadding)
.overlay(alignment: .top) {
// Titlebar overlay is only over terminal content, not the sidebar.
customTitlebar
if showWorkspaceTitlebar {
// Titlebar overlay is only over terminal content, not the sidebar.
customTitlebar
Comment on lines +2005 to +2007
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep a drag handle when hiding the workspace titlebar

When this branch omits customTitlebar, it also removes the WindowDragHandleView overlay for the terminal area. In hidden-titlebar mode, users can still land on NotificationsPage with the sidebar hidden, and that page does not add its own drag handle; combined with the window being non-movable by background (isMovableByWindowBackground = false and isMovable = false), this leaves no draggable region and the window cannot be repositioned until another UI state is restored.

Useful? React with 👍 / 👎.

} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep fullscreen controls available when titlebar is hidden

This new branch disables customTitlebar entirely when the workspace titlebar setting is off, but customTitlebar is the only place that renders fullscreenControls for the isFullScreen && !sidebarState.isVisible path. Since fullscreen mode also hides native titlebar accessories and the other fullscreen overlay only shows controls when the sidebar is visible, users in fullscreen with a hidden sidebar lose all titlebar control buttons (including sidebar toggle) in this configuration.

Useful? React with 👍 / 👎.

// When the custom titlebar is hidden, keep empty space in the top pane tab bar
// draggable without turning the full content area into a window drag target.
WindowDragHandleView()
.frame(height: collapsedTitlebarDragHandleHeight)
.frame(maxWidth: .infinity)
}
}
}

Expand All @@ -2007,6 +2016,8 @@ struct ContentView: View {
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = false
@AppStorage(WorkspaceTitlebarSettings.showTitlebarKey)
private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar
@AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0

@State private var titlebarLeadingInset: CGFloat = 12
Expand Down Expand Up @@ -2623,7 +2634,7 @@ struct ContentView: View {
removeSidebarResizerPointerMonitor()
})

view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity, showWorkspaceTitlebar] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.titlebarAppearsTransparent = true
// Do not make the entire background draggable; it interferes with drag gestures
Expand All @@ -2646,10 +2657,11 @@ struct ContentView: View {
}
}

// Keep content below the titlebar so drags on Bonsplit's tab bar don't
// get interpreted as window drags.
// Reserve space for the custom titlebar only when it is enabled. When hidden, the
// top Bonsplit tab bar moves into this strip and its empty space gets an explicit
// drag handle overlay instead.
let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let nextPadding = max(28, min(72, computedTitlebarHeight))
let nextPadding = showWorkspaceTitlebar ? max(28, min(72, computedTitlebarHeight)) : 0
if abs(titlebarPadding - nextPadding) > 0.5 {
Comment on lines 2669 to 2671
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute padding after workspace-titlebar setting changes

nextPadding now depends on showWorkspaceTitlebar, but this computation is inside a WindowAccessor callback created with default deduping, so it only runs on initial attach for the same window. When users toggle “Show Workspace Title Bar” at runtime, the view condition changes immediately but titlebarPadding can stay stale, causing either a leftover empty strip when hidden or a collapsed titlebar region when re-enabled until a new window/session refreshes it.

Useful? React with 👍 / 👎.

DispatchQueue.main.async {
titlebarPadding = nextPadding
Expand Down
32 changes: 32 additions & 0 deletions Sources/TitlebarVisibilitySettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

enum TitlebarControlsVisibilityMode: String, CaseIterable, Identifiable {
case always
case onHover

var id: String { rawValue }
}

enum TitlebarControlsVisibilitySettings {
static let modeKey = "titlebarControlsVisibilityMode"
static let defaultMode: TitlebarControlsVisibilityMode = .always

static func mode(for rawValue: String?) -> TitlebarControlsVisibilityMode {
guard let rawValue, let mode = TitlebarControlsVisibilityMode(rawValue: rawValue) else {
return defaultMode
}
return mode
}
}

enum WorkspaceTitlebarSettings {
static let showTitlebarKey = "workspaceTitlebarVisible"
static let defaultShowTitlebar = true

static func isVisible(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: showTitlebarKey) == nil {
return defaultShowTitlebar
}
return defaults.bool(forKey: showTitlebarKey)
}
}
Loading
Loading