From 4ad684387ca846646766a85cbf637cbb1d21d1ac Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 15:13:51 -0300 Subject: [PATCH 1/3] test: cover automatic tab naming --- programaTests/WorkspaceUnitTests.swift | 114 +++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 7a1a6b2b..340796a4 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -3161,6 +3161,120 @@ final class WorkspacePanelGitBranchTests: XCTestCase { wait(for: [expectation], timeout: 5.0) } + private func assertPanelAndTabTitle( + _ expected: String, + workspace: Workspace, + panelId: UUID, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(workspace.panelTitle(panelId: panelId), expected, file: file, line: line) + guard let tabId = workspace.surfaceIdFromPanelId(panelId), + let tab = workspace.bonsplitController.tab(tabId) else { + XCTFail("Expected panel to have a Bonsplit tab", file: file, line: line) + return + } + XCTAssertEqual(tab.title, expected, file: file, line: line) + } + + func testPanelTitleUsesPRTicketBranchManualPrecedenceWithoutRenamingWorkspace() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let pullRequestURL = try XCTUnwrap(URL(string: "https://github.com/darkroomengineering/programa/pull/482")) + + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "agent-shell")) + let workspaceTitle = workspace.title + assertPanelAndTabTitle("agent-shell", workspace: workspace, panelId: panelId) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/PROG-123-summary", + isDirty: false + ) + assertPanelAndTabTitle("PROG-123", workspace: workspace, panelId: panelId) + XCTAssertEqual(workspace.title, workspaceTitle) + + workspace.updatePanelPullRequest( + panelId: panelId, + number: 482, + label: "Improve tab names", + url: pullRequestURL, + status: .open, + branch: "feature/PROG-123-summary" + ) + assertPanelAndTabTitle("PR #482", workspace: workspace, panelId: panelId) + XCTAssertEqual(workspace.title, workspaceTitle) + + workspace.setPanelCustomTitle(panelId: panelId, title: " Manual lane ") + assertPanelAndTabTitle("Manual lane", workspace: workspace, panelId: panelId) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/OPS9-77-follow-up", + isDirty: true + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 483, + label: "Follow-up", + url: pullRequestURL, + status: .open, + branch: "feature/OPS9-77-follow-up" + ) + assertPanelAndTabTitle("Manual lane", workspace: workspace, panelId: panelId) + + workspace.setPanelCustomTitle(panelId: panelId, title: nil) + assertPanelAndTabTitle("PR #483", workspace: workspace, panelId: panelId) + + workspace.clearPanelPullRequest(panelId: panelId) + assertPanelAndTabTitle("OPS9-77", workspace: workspace, panelId: panelId) + + workspace.clearPanelGitBranch(panelId: panelId) + assertPanelAndTabTitle("agent-shell", workspace: workspace, panelId: panelId) + XCTAssertEqual( + workspace.title, + workspaceTitle, + "Panel metadata must not rename the workspace shown in the sidebar" + ) + } + + func testAutomaticPanelTitleUsesOnlyConservativeUppercaseTicketTokens() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "live-process")) + let workspaceTitle = workspace.title + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: " feature/lowercase-summary ", + isDirty: false + ) + assertPanelAndTabTitle( + "feature/lowercase-summary", + workspace: workspace, + panelId: panelId + ) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/prog-123-summary", + isDirty: false + ) + assertPanelAndTabTitle( + "feature/prog-123-summary", + workspace: workspace, + panelId: panelId + ) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/PROG-123-summary", + isDirty: false + ) + assertPanelAndTabTitle("PROG-123", workspace: workspace, panelId: panelId) + XCTAssertEqual(workspace.title, workspaceTitle) + } + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { From a88e2f3ae49df2a5223a052f765fb1dbdd84e29e Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 15:27:54 -0300 Subject: [PATCH 2/3] test: cover automatic tab title lifecycle --- programaTests/WorkspaceUnitTests.swift | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 340796a4..8f3210fc 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -3275,6 +3275,189 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertEqual(workspace.title, workspaceTitle) } + func testPanelTitleUsesOnlyPullRequestMetadataScopedToTheCurrentBranch() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let pullRequestURL = try XCTUnwrap(URL(string: "https://github.com/darkroomengineering/programa/pull/484")) + + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "live-process")) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 484, + label: "Other lane", + url: pullRequestURL, + status: .open, + branch: "feature/OTHER-484-summary" + ) + assertPanelAndTabTitle( + "live-process", + workspace: workspace, + panelId: panelId + ) + + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/CURRENT-21-summary", + isDirty: false + ) + assertPanelAndTabTitle("CURRENT-21", workspace: workspace, panelId: panelId) + + workspace.clearPanelGitBranch(panelId: panelId) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 485, + label: "Unscoped lane", + url: pullRequestURL, + status: .open + ) + assertPanelAndTabTitle( + "PR #485", + workspace: workspace, + panelId: panelId, + file: #filePath, + line: #line + ) + } + + func testResetSidebarContextClearsAutomaticPanelTitlesButPreservesManualNames() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let pullRequestURL = try XCTUnwrap(URL(string: "https://github.com/darkroomengineering/programa/pull/486")) + + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "live-process")) + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/RESET-12-summary", + isDirty: false + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 486, + label: "Reset lane", + url: pullRequestURL, + status: .open, + branch: "feature/RESET-12-summary" + ) + assertPanelAndTabTitle("PR #486", workspace: workspace, panelId: panelId) + + workspace.resetSidebarContext(reason: "test") + assertPanelAndTabTitle("live-process", workspace: workspace, panelId: panelId) + + workspace.setPanelCustomTitle(panelId: panelId, title: "Manual lane") + workspace.updatePanelGitBranch( + panelId: panelId, + branch: "feature/RESET-13-summary", + isDirty: false + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 487, + label: "Reset manual lane", + url: pullRequestURL, + status: .open, + branch: "feature/RESET-13-summary" + ) + + workspace.resetSidebarContext(reason: "test-manual") + assertPanelAndTabTitle("Manual lane", workspace: workspace, panelId: panelId) + } + + func testDetachAttachDoesNotPersistAutomaticPanelTitleButPreservesManualName() throws { + let source = Workspace() + let destination = Workspace() + let finalDestination = Workspace() + defer { + source.teardownAllPanels() + destination.teardownAllPanels() + finalDestination.teardownAllPanels() + } + let panelId = try XCTUnwrap(source.focusedPanelId) + let pullRequestURL = try XCTUnwrap(URL(string: "https://github.com/darkroomengineering/programa/pull/488")) + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "live-process")) + source.updatePanelGitBranch( + panelId: panelId, + branch: "feature/MOVE-33-summary", + isDirty: false + ) + source.updatePanelPullRequest( + panelId: panelId, + number: 488, + label: "Move lane", + url: pullRequestURL, + status: .open, + branch: "feature/MOVE-33-summary" + ) + assertPanelAndTabTitle("PR #488", workspace: source, panelId: panelId) + + let detachedAutomatic = try XCTUnwrap(source.detachSurface(panelId: panelId)) + let destinationPane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + XCTAssertEqual( + destination.attachDetachedSurface(detachedAutomatic, inPane: destinationPane, focus: false), + panelId + ) + assertPanelAndTabTitle( + "live-process", + workspace: destination, + panelId: panelId + ) + + destination.setPanelCustomTitle(panelId: panelId, title: "Manual moved lane") + let detachedManual = try XCTUnwrap(destination.detachSurface(panelId: panelId)) + let finalPane = try XCTUnwrap(finalDestination.bonsplitController.allPaneIds.first) + XCTAssertEqual( + finalDestination.attachDetachedSurface(detachedManual, inPane: finalPane, focus: false), + panelId + ) + assertPanelAndTabTitle( + "Manual moved lane", + workspace: finalDestination, + panelId: panelId + ) + } + + func testSessionRestoreKeepsRawLiveTitleSeparateFromAutomaticAndManualTitles() throws { + let source = Workspace() + let restored = Workspace() + defer { + source.teardownAllPanels() + restored.teardownAllPanels() + } + let panelId = try XCTUnwrap(source.focusedPanelId) + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "live-process")) + source.updatePanelGitBranch( + panelId: panelId, + branch: "feature/RESTORE-91-summary", + isDirty: false + ) + source.setPanelCustomTitle(panelId: panelId, title: "Manual restored lane") + + let snapshot = source.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) + XCTAssertEqual( + panelSnapshot.title, + "live-process", + "The persisted base title must remain the live title so transient metadata cannot become stale state" + ) + XCTAssertEqual(panelSnapshot.customTitle, "Manual restored lane") + XCTAssertEqual(panelSnapshot.gitBranch?.branch, "feature/RESTORE-91-summary") + + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + assertPanelAndTabTitle( + "Manual restored lane", + workspace: restored, + panelId: restoredPanelId + ) + + restored.setPanelCustomTitle(panelId: restoredPanelId, title: nil) + assertPanelAndTabTitle("RESTORE-91", workspace: restored, panelId: restoredPanelId) + + restored.clearPanelGitBranch(panelId: restoredPanelId) + assertPanelAndTabTitle("live-process", workspace: restored, panelId: restoredPanelId) + } + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { From 4a3c3ded6f42bda95efbb7a456b997fc9492c0bb Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 31 Aug 2026 15:40:23 -0300 Subject: [PATCH 3/3] feat: name tabs from pull requests and branches --- Resources/Localizable.xcstrings | 17 +++++++++ Sources/Workspace+Bonsplit.swift | 2 +- Sources/Workspace+Persistence.swift | 2 +- Sources/Workspace+SidebarTelemetry.swift | 30 ++++++++++----- Sources/Workspace.swift | 48 ++++++++++++++++++++---- 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3921f6c5..c85498dd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13871,6 +13871,23 @@ } } }, + "tabBar.autoTitle.pullRequest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "PR #%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PR #%lld" + } + } + } + }, "tabBar.newTerminalTab": { "extractionState": "manual", "localizations": { diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index 6cc310e1..d1aa1757 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -598,7 +598,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), + title: panelCustomTitles[panelId] ?? transferFallbackTitle, icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index 200e3582..5c4677b3 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -285,7 +285,7 @@ extension Workspace { private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { guard let panel = panels[panelId] else { return nil } - let panelTitle = panelTitle(panelId: panelId) + let panelTitle = panelTitles[panelId] ?? panel.displayTitle let customTitle = panelCustomTitles[panelId] let directory = panelDirectories[panelId] let isPinned = pinnedPanelIds.contains(panelId) diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index e49e8943..3782c30f 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -110,12 +110,15 @@ extension Workspace { let state = SidebarGitBranchState(branch: normalizedBranch, isDirty: isDirty) let existing = panelGitBranches[panelId] let branchChanged = existing?.branch != nil && existing?.branch != normalizedBranch + var titleMetadataChanged = false if existing?.branch != normalizedBranch || existing?.isDirty != isDirty { panelGitBranches[panelId] = state + titleMetadataChanged = true } if branchChanged { if panelPullRequests[panelId] != nil { panelPullRequests.removeValue(forKey: panelId) + titleMetadataChanged = true } if panelId == focusedPanelId, pullRequest != nil { pullRequest = nil @@ -124,14 +127,20 @@ extension Workspace { if panelId == focusedPanelId, gitBranch != state { gitBranch = state } + if titleMetadataChanged { + syncResolvedPanelTitle(panelId: panelId) + } } func clearPanelGitBranch(panelId: UUID) { + var titleMetadataChanged = false if panelGitBranches[panelId] != nil { panelGitBranches.removeValue(forKey: panelId) + titleMetadataChanged = true } if panelPullRequests[panelId] != nil { panelPullRequests.removeValue(forKey: panelId) + titleMetadataChanged = true } if panelId == focusedPanelId { if gitBranch != nil { @@ -141,6 +150,9 @@ extension Workspace { pullRequest = nil } } + if titleMetadataChanged { + syncResolvedPanelTitle(panelId: panelId) + } } func updatePanelPullRequest( @@ -208,6 +220,7 @@ extension Workspace { ) if existing != state { panelPullRequests[panelId] = state + syncResolvedPanelTitle(panelId: panelId) } if panelId == focusedPanelId, pullRequest != state { pullRequest = state @@ -217,6 +230,7 @@ extension Workspace { func clearPanelPullRequest(panelId: UUID) { if panelPullRequests[panelId] != nil { panelPullRequests.removeValue(forKey: panelId) + syncResolvedPanelTitle(panelId: panelId) } if panelId == focusedPanelId, pullRequest != nil { pullRequest = nil @@ -287,9 +301,13 @@ extension Workspace { logEntries.removeAll() progress = nil gitBranch = nil + let panelIdsWithAutomaticTitles = Set(panelGitBranches.keys).union(panelPullRequests.keys) panelGitBranches.removeAll() pullRequest = nil panelPullRequests.removeAll() + for panelId in panelIdsWithAutomaticTitles { + syncResolvedPanelTitle(panelId: panelId) + } // Clears bypass updatePanelAgentState/clearPanelAgentState's per-surface notify, so fan // it out here too -- otherwise a surface.wait `agent_state` (or a subscribed client) // watching a surface whose state got wiped by a sidebar reset would hang until timeout @@ -360,16 +378,8 @@ extension Workspace { panelsWithLiveTitle.insert(panelId) // Update bonsplit tab title only when this panel's title changed. - if didMutate, - let tabId = surfaceIdFromPanelId(panelId), - let panel = panels[panelId] { - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) - bonsplitController.updateTab( - tabId, - title: resolvedTitle, - hasCustomTitle: panelCustomTitles[panelId] != nil - ) + if didMutate { + syncResolvedPanelTitle(panelId: panelId) } // The focused pane titles the workspace (tmux-style). Single-panel diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f4e761f8..a962d110 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -992,9 +992,49 @@ final class Workspace: Identifiable, ObservableObject { !custom.isEmpty { return custom } + if let pullRequest = panelPullRequests[panelId] { + let panelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) + let pullRequestBranch = normalizedSidebarBranchName(pullRequest.branch) + if pullRequestBranch == nil || panelBranch == pullRequestBranch { + return String( + localized: "tabBar.autoTitle.pullRequest", + defaultValue: "PR #\(pullRequest.number)" + ) + } + } + if let branch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) { + return Self.ticketIdentifier(in: branch) ?? branch + } return fallbackTitle } + private static let ticketIdentifierPattern = try? NSRegularExpression( + pattern: #"(? String? { + guard let ticketIdentifierPattern, + let match = ticketIdentifierPattern.firstMatch( + in: branch, + range: NSRange(branch.startIndex..., in: branch) + ), + match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: branch) else { + return nil + } + return String(branch[range]) + } + + func syncResolvedPanelTitle(panelId: UUID) { + guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } + let baseTitle = panelTitles[panelId] ?? panel.displayTitle + bonsplitController.updateTab( + tabId, + title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), + hasCustomTitle: panelCustomTitles[panelId] != nil + ) + } + func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { let isPinned = pinnedPanelIds.contains(panelId) if let panel = panels[panelId] { @@ -1097,13 +1137,7 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles[panelId] = trimmed } - guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } - let baseTitle = panelTitles[panelId] ?? panel.displayTitle - bonsplitController.updateTab( - tabId, - title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), - hasCustomTitle: panelCustomTitles[panelId] != nil - ) + syncResolvedPanelTitle(panelId: panelId) } func isPanelPinned(_ panelId: UUID) -> Bool {