From 83752c3802aa5ce9325046073f1454f3a75c472e Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 25 Aug 2026 15:03:11 +1000 Subject: [PATCH 1/6] fix(swift-ios): split thread copy actions --- .../Workspace/HomeThreadCollectionView.swift | 37 ++++- .../Workspace/ThreadCopyActions.swift | 80 +++++++++++ .../FeatureTests/ThreadCopyActionsTests.swift | 131 ++++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 apps/swift-ios/Features/Workspace/ThreadCopyActions.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index d55641835e69..7f91cd6cb6f5 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -242,13 +242,19 @@ struct HomeThreadCollectionView: UIViewRepresentable { contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { - guard case let .thread(thread, _, _, isArchived, _) = item(at: indexPath) else { + guard case let .thread(thread, context, _, isArchived, _) = item(at: indexPath) else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self else { return nil } - return UIMenu(children: self.menuActions(for: thread, isArchived: isArchived)) + return UIMenu( + children: self.menuActions( + for: thread, + context: context, + isArchived: isArchived + ) + ) } } @@ -623,7 +629,11 @@ struct HomeThreadCollectionView: UIViewRepresentable { } } - private func menuActions(for thread: FeatureThread, isArchived: Bool) -> [UIMenuElement] { + private func menuActions( + for thread: FeatureThread, + context: HomeThreadRowContext, + isArchived: Bool + ) -> [UIMenuElement] { let rename = UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in self?.parent.onRename(thread) } @@ -639,6 +649,27 @@ struct HomeThreadCollectionView: UIViewRepresentable { } ) } + let copyActions = ThreadCopyModel.actions( + for: thread, + projectWorkspaceRoot: context.projectWorkspaceRoot + ) + if !copyActions.isEmpty { + titleActions.append( + UIMenu( + title: "Copy", + image: UIImage(systemName: "doc.on.doc"), + children: copyActions.map { action in + UIAction( + title: action.kind.title, + image: UIImage(systemName: action.kind.systemImage), + attributes: action.isAvailable ? [] : .disabled + ) { _ in + ThreadCopyClipboard.copy(action) + } + } + ) + ) + } var statusActions: [UIMenuElement] = [] if !isArchived { diff --git a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift new file mode 100644 index 000000000000..b1c010097e2f --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift @@ -0,0 +1,80 @@ +import Foundation +import UIKit + +enum ThreadCopyActionKind: Equatable, Sendable { + case path + case branch + case threadID + + var title: String { + switch self { + case .path: "Path" + case .branch: "Branch" + case .threadID: "Thread ID" + } + } + + var systemImage: String { + switch self { + case .path: "folder" + case .branch: "arrow.triangle.branch" + case .threadID: "number" + } + } + + var copyAnnouncement: String { "\(title) copied" } +} + +struct ThreadCopyAction: Equatable, Sendable { + let kind: ThreadCopyActionKind + let value: String? + + var isAvailable: Bool { value != nil } + + var announcement: String { + isAvailable ? kind.copyAnnouncement : "\(kind.title) unavailable" + } +} + +enum ThreadCopyModel { + static func actions( + for thread: FeatureThread, + projectWorkspaceRoot: String? + ) -> [ThreadCopyAction] { + var actions: [ThreadCopyAction] = [] + + let path = nonBlank(thread.worktreePath) ?? nonBlank(projectWorkspaceRoot) + actions.append(ThreadCopyAction(kind: .path, value: path)) + if let branch = nonBlank(thread.branch) { + actions.append(ThreadCopyAction(kind: .branch, value: branch)) + } + if let threadID = nonBlank(thread.wireID) ?? nonBlank(thread.id) { + actions.append(ThreadCopyAction(kind: .threadID, value: threadID)) + } + + return actions + } + + /// Availability ignores surrounding whitespace, while the copied value remains byte-for-byte + /// identical to the value received from the server. + private static func nonBlank(_ value: String?) -> String? { + guard let value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } +} + +@MainActor +enum ThreadCopyClipboard { + static func copy(_ action: ThreadCopyAction) { + if let value = action.value { + UIPasteboard.general.string = value + } + UIAccessibility.post( + notification: .announcement, + argument: action.announcement + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift new file mode 100644 index 000000000000..185499621284 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift @@ -0,0 +1,131 @@ +import XCTest +@testable import T3Code + +final class ThreadCopyActionsTests: XCTestCase { + func testActionsMatchElectronOrderAndCopyExactRawValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: " thread-1 ", + projectID: "project-1", + title: "Copy values", + branch: " feature/copy-values ", + worktreePath: " /worktrees/copy-values " + ) + + let actions = ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code") + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: " /worktrees/copy-values "), + ThreadCopyAction(kind: .branch, value: " feature/copy-values "), + ThreadCopyAction(kind: .threadID, value: " thread-1 "), + ] + ) + XCTAssertTrue(actions.allSatisfy(\.isAvailable)) + } + + func testPathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout" + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code"), + [ + ThreadCopyAction(kind: .path, value: "/work/t3code"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + } + + func testBlankWorktreePathFallsBackToProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Local checkout", + worktreePath: " " + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code").first, + ThreadCopyAction(kind: .path, value: "/work/t3code") + ) + } + + func testUnavailablePathRemainsVisibleAndBlankBranchIsOmitted() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "No path", + branch: " \n " + ) + + let actions = ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: nil) + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: nil), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ] + ) + XCTAssertFalse(actions[0].isAvailable) + XCTAssertTrue(actions[1].isAvailable) + } + + func testWorktreePathTakesPrecedenceOverProjectWorkspaceRoot() { + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Worktree", + worktreePath: "/worktrees/feature" + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code").first, + ThreadCopyAction(kind: .path, value: "/worktrees/feature") + ) + } + + func testThreadIDUsesWireIdentityThenFallsBackToLocalIdentity() { + let wireThread = FeatureThread( + id: "scoped-thread-id", + wireID: "wire-thread-id", + projectID: "project-1", + title: "Wire identity" + ) + let localThread = FeatureThread( + id: "scoped-thread-id", + wireID: " ", + projectID: "project-1", + title: "Local identity" + ) + + XCTAssertEqual( + ThreadCopyModel.actions(for: wireThread, projectWorkspaceRoot: nil).last, + ThreadCopyAction(kind: .threadID, value: "wire-thread-id") + ) + XCTAssertEqual( + ThreadCopyModel.actions(for: localThread, projectWorkspaceRoot: nil).last, + ThreadCopyAction(kind: .threadID, value: "scoped-thread-id") + ) + } + + func testActionLabelsAndAnnouncementsAreDistinct() { + XCTAssertEqual(ThreadCopyActionKind.path.title, "Path") + XCTAssertEqual(ThreadCopyActionKind.path.copyAnnouncement, "Path copied") + XCTAssertEqual(ThreadCopyActionKind.branch.title, "Branch") + XCTAssertEqual(ThreadCopyActionKind.branch.copyAnnouncement, "Branch copied") + XCTAssertEqual(ThreadCopyActionKind.threadID.title, "Thread ID") + XCTAssertEqual(ThreadCopyActionKind.threadID.copyAnnouncement, "Thread ID copied") + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: nil).announcement, + "Path unavailable" + ) + XCTAssertEqual( + ThreadCopyAction(kind: .path, value: "/work/t3code").announcement, + "Path copied" + ) + } +} From 05a8d682d2cfdef91cf64cf9e55c2c434976a85e Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 26 Aug 2026 23:53:59 +1000 Subject: [PATCH 2/6] fix(swift-ios): keep thread copy actions individual --- .../Workspace/HomeThreadCollectionView.swift | 2 +- .../Workspace/ThreadCopyActions.swift | 53 +++++++- .../Features/Workspace/WorkspaceView.swift | 9 ++ .../FeatureTests/ThreadCopyActionsTests.swift | 124 +++++++++++++++++- 4 files changed, 178 insertions(+), 10 deletions(-) diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 7f91cd6cb6f5..7149b8720170 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -651,7 +651,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { } let copyActions = ThreadCopyModel.actions( for: thread, - projectWorkspaceRoot: context.projectWorkspaceRoot + context: context.copyContext ) if !copyActions.isEmpty { titleActions.append( diff --git a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift index b1c010097e2f..f4bea70ea85e 100644 --- a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift +++ b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift @@ -5,12 +5,18 @@ enum ThreadCopyActionKind: Equatable, Sendable { case path case branch case threadID + case project + case environment + case url var title: String { switch self { case .path: "Path" case .branch: "Branch" case .threadID: "Thread ID" + case .project: "Project" + case .environment: "Environment" + case .url: "URL" } } @@ -19,12 +25,22 @@ enum ThreadCopyActionKind: Equatable, Sendable { case .path: "folder" case .branch: "arrow.triangle.branch" case .threadID: "number" + case .project: "folder.badge.gearshape" + case .environment: "desktopcomputer" + case .url: "link" } } var copyAnnouncement: String { "\(title) copied" } } +struct ThreadCopyContext: Equatable, Sendable { + let projectName: String? + let projectWorkspaceRoot: String? + let environmentName: String? + let environmentID: String? +} + struct ThreadCopyAction: Equatable, Sendable { let kind: ThreadCopyActionKind let value: String? @@ -39,11 +55,11 @@ struct ThreadCopyAction: Equatable, Sendable { enum ThreadCopyModel { static func actions( for thread: FeatureThread, - projectWorkspaceRoot: String? + context: ThreadCopyContext ) -> [ThreadCopyAction] { var actions: [ThreadCopyAction] = [] - let path = nonBlank(thread.worktreePath) ?? nonBlank(projectWorkspaceRoot) + let path = nonBlank(thread.worktreePath) ?? nonBlank(context.projectWorkspaceRoot) actions.append(ThreadCopyAction(kind: .path, value: path)) if let branch = nonBlank(thread.branch) { actions.append(ThreadCopyAction(kind: .branch, value: branch)) @@ -51,10 +67,43 @@ enum ThreadCopyModel { if let threadID = nonBlank(thread.wireID) ?? nonBlank(thread.id) { actions.append(ThreadCopyAction(kind: .threadID, value: threadID)) } + if let project = nonBlank(context.projectName) { + actions.append(ThreadCopyAction(kind: .project, value: project)) + } + if let environment = nonBlank(context.environmentName) { + actions.append(ThreadCopyAction(kind: .environment, value: environment)) + } + + let environmentID = nonBlank(thread.environmentID) ?? nonBlank(context.environmentID) + if let url = threadURL(environmentID: environmentID, threadID: nonBlank(thread.wireID)) { + actions.append(ThreadCopyAction(kind: .url, value: url)) + } return actions } + private static func threadURL(environmentID: String?, threadID: String?) -> String? { + guard let environmentID, + let threadID, + let encodedEnvironmentID = pathSegment(environmentID), + let encodedThreadID = pathSegment(threadID) else { + return nil + } + + var components = URLComponents() + components.scheme = "https" + components.host = "app.t3.codes" + components.percentEncodedPath = "/\(encodedEnvironmentID)/\(encodedThreadID)" + return components.url?.absoluteString + } + + private static func pathSegment(_ value: String) -> String? { + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + return value.addingPercentEncoding(withAllowedCharacters: allowed) + } + /// Availability ignores surrounding whitespace, while the copied value remains byte-for-byte /// identical to the value received from the server. private static func nonBlank(_ value: String?) -> String? { diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 319f58df865b..29882fbb700b 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -840,6 +840,15 @@ struct HomeThreadRowContext: Equatable { connectionState: nil ) + var copyContext: ThreadCopyContext { + ThreadCopyContext( + projectName: projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentLabel, + environmentID: projectEnvironmentID + ) + } + var providerLooksTerminal: Bool { let normalized = [providerDriver, providerID, providerName] .joined(separator: " ") diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift index 185499621284..cb37dc2a2cb5 100644 --- a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift @@ -12,7 +12,10 @@ final class ThreadCopyActionsTests: XCTestCase { worktreePath: " /worktrees/copy-values " ) - let actions = ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code") + let actions = ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ) XCTAssertEqual( actions, [ @@ -32,7 +35,10 @@ final class ThreadCopyActionsTests: XCTestCase { ) XCTAssertEqual( - ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code"), + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ), [ ThreadCopyAction(kind: .path, value: "/work/t3code"), ThreadCopyAction(kind: .threadID, value: "thread-1"), @@ -49,7 +55,10 @@ final class ThreadCopyActionsTests: XCTestCase { ) XCTAssertEqual( - ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code").first, + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, ThreadCopyAction(kind: .path, value: "/work/t3code") ) } @@ -62,7 +71,7 @@ final class ThreadCopyActionsTests: XCTestCase { branch: " \n " ) - let actions = ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: nil) + let actions = ThreadCopyModel.actions(for: thread, context: context()) XCTAssertEqual( actions, [ @@ -83,7 +92,10 @@ final class ThreadCopyActionsTests: XCTestCase { ) XCTAssertEqual( - ThreadCopyModel.actions(for: thread, projectWorkspaceRoot: "/work/t3code").first, + ThreadCopyModel.actions( + for: thread, + context: context(projectWorkspaceRoot: "/work/t3code") + ).first, ThreadCopyAction(kind: .path, value: "/worktrees/feature") ) } @@ -103,11 +115,11 @@ final class ThreadCopyActionsTests: XCTestCase { ) XCTAssertEqual( - ThreadCopyModel.actions(for: wireThread, projectWorkspaceRoot: nil).last, + ThreadCopyModel.actions(for: wireThread, context: context()).last, ThreadCopyAction(kind: .threadID, value: "wire-thread-id") ) XCTAssertEqual( - ThreadCopyModel.actions(for: localThread, projectWorkspaceRoot: nil).last, + ThreadCopyModel.actions(for: localThread, context: context()).last, ThreadCopyAction(kind: .threadID, value: "scoped-thread-id") ) } @@ -119,6 +131,12 @@ final class ThreadCopyActionsTests: XCTestCase { XCTAssertEqual(ThreadCopyActionKind.branch.copyAnnouncement, "Branch copied") XCTAssertEqual(ThreadCopyActionKind.threadID.title, "Thread ID") XCTAssertEqual(ThreadCopyActionKind.threadID.copyAnnouncement, "Thread ID copied") + XCTAssertEqual(ThreadCopyActionKind.project.title, "Project") + XCTAssertEqual(ThreadCopyActionKind.project.copyAnnouncement, "Project copied") + XCTAssertEqual(ThreadCopyActionKind.environment.title, "Environment") + XCTAssertEqual(ThreadCopyActionKind.environment.copyAnnouncement, "Environment copied") + XCTAssertEqual(ThreadCopyActionKind.url.title, "URL") + XCTAssertEqual(ThreadCopyActionKind.url.copyAnnouncement, "URL copied") XCTAssertEqual( ThreadCopyAction(kind: .path, value: nil).announcement, "Path unavailable" @@ -128,4 +146,96 @@ final class ThreadCopyActionsTests: XCTestCase { "Path copied" ) } + + func testAvailableMetadataProducesOnlyIndividualActionsAndValues() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "fallback-environment" + ) + ) + + XCTAssertEqual( + actions, + [ + ThreadCopyAction(kind: .path, value: "/worktrees/copy-values"), + ThreadCopyAction(kind: .branch, value: "feature/copy-values"), + ThreadCopyAction(kind: .threadID, value: "thread-1"), + ThreadCopyAction(kind: .project, value: "pingdotgg/t3code"), + ThreadCopyAction(kind: .environment, value: "Studio Mac"), + ThreadCopyAction( + kind: .url, + value: "https://app.t3.codes/environment-1/thread-1" + ), + ] + ) + XCTAssertTrue(actions.allSatisfy { action in + action.value?.contains("\n") == false + }) + } + + func testUnavailableMetadataDoesNotProduceIndividualActions() { + let thread = FeatureThread( + id: "thread-1", + wireID: nil, + projectID: "project-1", + environmentID: nil, + title: "Sparse values" + ) + + let actions = ThreadCopyModel.actions(for: thread, context: context()) + + XCTAssertEqual( + actions.map(\.kind), + [.path, .threadID] + ) + XCTAssertFalse(actions.contains { action in + [.project, .environment, .url].contains(action.kind) + }) + } + + func testURLPercentEncodesRouteValuesAsSinglePathSegments() { + let thread = FeatureThread( + id: "scoped-thread-id", + wireID: "thread/one", + projectID: "project-1", + environmentID: "studio one", + title: "Encoded route" + ) + + let url = ThreadCopyModel.actions(for: thread, context: context()) + .first { $0.kind == .url } + + XCTAssertEqual( + url?.value, + "https://app.t3.codes/studio%20one/thread%2Fone" + ) + } + + private func context( + projectName: String? = nil, + projectWorkspaceRoot: String? = nil, + environmentName: String? = nil, + environmentID: String? = nil + ) -> ThreadCopyContext { + ThreadCopyContext( + projectName: projectName, + projectWorkspaceRoot: projectWorkspaceRoot, + environmentName: environmentName, + environmentID: environmentID + ) + } } From c547c85f7183f61b824e03fc5a81ccd1754d4438 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:15:29 +1000 Subject: [PATCH 3/6] fix(swift-ios): omit placeholder project copy action --- .../Features/Workspace/WorkspaceView.swift | 2 +- .../FeatureTests/HomeThreadMetadataTests.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 29882fbb700b..13df190e52ab 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -842,7 +842,7 @@ struct HomeThreadRowContext: Equatable { var copyContext: ThreadCopyContext { ThreadCopyContext( - projectName: projectName, + projectName: projectWorkspaceRoot == nil ? nil : projectName, projectWorkspaceRoot: projectWorkspaceRoot, environmentName: environmentLabel, environmentID: projectEnvironmentID diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index 8901461c4f95..14f9d7114c71 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -325,6 +325,22 @@ struct HomeThreadMetadataTests { #expect(context.projectName == "pingdotgg/t3code") } + @Test + func fallbackRowContextDoesNotOfferPlaceholderProjectCopy() { + let thread = FeatureThread( + id: "thread", + projectID: "missing-project", + title: "Unresolved project" + ) + + let actions = ThreadCopyModel.actions( + for: thread, + context: HomeThreadRowContext.fallback.copyContext + ) + + #expect(actions.contains { $0.kind == .project } == false) + } + @Test func pullRequestIndicatorsUseTheCurrentThreadBranchAndPreserveTheirState() { let thread = FeatureThread( From 41cc809446b595627a638740b6c67bd704f59530 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:20:09 +1000 Subject: [PATCH 4/6] fix(swift-ios): normalize thread environment identity --- .../Features/Workspace/WorkspaceView.swift | 6 +++- .../HomeThreadMetadataTests.swift | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 13df190e52ab..a1c16038fcf4 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -874,7 +874,11 @@ struct HomeThreadRowContext: Equatable { } return snapshot.threads.reduce(into: [String: HomeThreadRowContext]()) { result, thread in let project = projectByID[thread.projectID] - let environmentID = thread.environmentID ?? project?.environmentID + let normalizedThreadEnvironmentID = thread.environmentID? + .trimmingCharacters(in: .whitespacesAndNewlines) + let environmentID = normalizedThreadEnvironmentID?.isEmpty == false + ? normalizedThreadEnvironmentID + : project?.environmentID let environment = environmentID.flatMap { environmentByID[$0] } let environmentLabel = (environment?.name ?? thread.environmentName)? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index 14f9d7114c71..8d7fd7bd7c50 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -341,6 +341,40 @@ struct HomeThreadMetadataTests { #expect(actions.contains { $0.kind == .project } == false) } + @Test + func rowContextFallsBackToProjectEnvironmentForBlankThreadEnvironment() throws { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Blank environment", + environmentID: " " + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "Desk Mac", + endpoint: "http://device", + connectionState: .connected + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + threads: [thread] + ) + + let context = try #require(HomeThreadRowContext.index(snapshot: snapshot)[thread.id]) + + #expect(context.environmentLabel == "Desk Mac") + #expect(context.copyContext.environmentID == "device") + } + @Test func pullRequestIndicatorsUseTheCurrentThreadBranchAndPreserveTheirState() { let thread = FeatureThread( From e90618a13b6fb4771304509de2c4d4e88772adff Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 23:25:42 +1000 Subject: [PATCH 5/6] fix(swift-ios): correct environment fallback fixture --- .../Tests/FeatureTests/HomeThreadMetadataTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index 8d7fd7bd7c50..d6737d8a389e 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -346,8 +346,8 @@ struct HomeThreadMetadataTests { let thread = FeatureThread( id: "thread", projectID: "project", - title: "Blank environment", - environmentID: " " + environmentID: " ", + title: "Blank environment" ) let snapshot = FeatureSnapshot( environments: [ From 2cc5a7e28c9d093b44c255495f20304621f61f6b Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 30 Aug 2026 08:48:57 +1000 Subject: [PATCH 6/6] fix(swift-ios): match electron copy submenu --- .../Workspace/HomeThreadCollectionView.swift | 2 +- .../Workspace/ThreadCopyActions.swift | 15 +++++++++++ .../FeatureTests/ThreadCopyActionsTests.swift | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 7149b8720170..f3eb3a9b24f4 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -649,7 +649,7 @@ struct HomeThreadCollectionView: UIViewRepresentable { } ) } - let copyActions = ThreadCopyModel.actions( + let copyActions = ThreadCopyModel.menuActions( for: thread, context: context.copyContext ) diff --git a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift index f4bea70ea85e..083a28904c26 100644 --- a/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift +++ b/apps/swift-ios/Features/Workspace/ThreadCopyActions.swift @@ -53,6 +53,21 @@ struct ThreadCopyAction: Equatable, Sendable { } enum ThreadCopyModel { + /// The long-press row menu mirrors the Electron thread menu. + static func menuActions( + for thread: FeatureThread, + context: ThreadCopyContext + ) -> [ThreadCopyAction] { + actions(for: thread, context: context).filter { action in + switch action.kind { + case .path, .branch, .threadID: + true + case .project, .environment, .url: + false + } + } + } + static func actions( for thread: FeatureThread, context: ThreadCopyContext diff --git a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift index cb37dc2a2cb5..e9f851b0f752 100644 --- a/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ThreadCopyActionsTests.swift @@ -27,6 +27,31 @@ final class ThreadCopyActionsTests: XCTestCase { XCTAssertTrue(actions.allSatisfy(\.isAvailable)) } + func testMenuActionsMatchElectronCopyItems() { + let thread = FeatureThread( + id: "thread-1", + wireID: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Copy values", + branch: "feature/copy-values", + worktreePath: "/worktrees/copy-values" + ) + + XCTAssertEqual( + ThreadCopyModel.menuActions( + for: thread, + context: context( + projectName: "pingdotgg/t3code", + projectWorkspaceRoot: "/work/t3code", + environmentName: "Studio Mac", + environmentID: "environment-1" + ) + ).map(\.kind), + [.path, .branch, .threadID] + ) + } + func testPathFallsBackToProjectWorkspaceRoot() { let thread = FeatureThread( id: "thread-1",