From 38d43bc242b8089828e4d0fc9062ef87d86c303d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:55:34 -0400 Subject: [PATCH 1/2] fix(ios): add a manage-lane rename pencil and compact header The mobile manage sheet hid rename inside Appearance and repeated the lane name in a desktop-sized heading. Put a pencil in the nav bar and show branch and path as icon rows. Co-authored-by: Cursor --- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + .../ADE/Views/Lanes/LaneDetailScreen.swift | 4 + .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 230 ++++++++++++++---- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 3 + apps/ios/ADETests/LaneManageRenameTests.swift | 75 ++++++ docs/features/lanes/README.md | 13 +- .../sync-and-multi-device/ios-companion.md | 2 +- 7 files changed, 277 insertions(+), 54 deletions(-) create mode 100644 apps/ios/ADETests/LaneManageRenameTests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 840bb631d..0677661d4 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ AC7600000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */; }; AC7600000000000000000007 /* WorkPendingInputRescueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */; }; AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000003 /* WorkSessionGroupingTests.swift */; }; + AC760000000000000000000A /* LaneManageRenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC750000000000000000000A /* LaneManageRenameTests.swift */; }; AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */; }; AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7500000000000000000002 /* HubProjectPresentationTests.swift */; }; AA1100000000000000000003 /* ADESharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* ADESharedTheme.swift */; }; @@ -362,6 +363,7 @@ AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ApprovalRequestKindPrecedenceTests.swift; path = ADETests/ApprovalRequestKindPrecedenceTests.swift; sourceTree = ""; }; AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPendingInputRescueTests.swift; path = ADETests/WorkPendingInputRescueTests.swift; sourceTree = ""; }; AC7500000000000000000003 /* WorkSessionGroupingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGroupingTests.swift; path = ADETests/WorkSessionGroupingTests.swift; sourceTree = ""; }; + AC750000000000000000000A /* LaneManageRenameTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneManageRenameTests.swift; path = ADETests/LaneManageRenameTests.swift; sourceTree = ""; }; AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ActivityWidgetPresentationTests.swift; path = ADETests/ActivityWidgetPresentationTests.swift; sourceTree = ""; }; AC7500000000000000000002 /* HubProjectPresentationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubProjectPresentationTests.swift; path = ADETests/HubProjectPresentationTests.swift; sourceTree = ""; }; AA1000000000000000000003 /* ADESharedTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADESharedTheme.swift; path = ADE/Shared/ADESharedTheme.swift; sourceTree = ""; }; @@ -1172,6 +1174,7 @@ AC7500000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift */, AC7500000000000000000007 /* WorkPendingInputRescueTests.swift */, AC7500000000000000000003 /* WorkSessionGroupingTests.swift */, + AC750000000000000000000A /* LaneManageRenameTests.swift */, AC7500000000000000000004 /* ActivityWidgetPresentationTests.swift */, AC7500000000000000000002 /* HubProjectPresentationTests.swift */, AC7100000000000000000001 /* ActivityContractDecodingTests.swift */, @@ -1708,6 +1711,7 @@ AC7600000000000000000006 /* ApprovalRequestKindPrecedenceTests.swift in Sources */, AC7600000000000000000007 /* WorkPendingInputRescueTests.swift in Sources */, AC7600000000000000000003 /* WorkSessionGroupingTests.swift in Sources */, + AC760000000000000000000A /* LaneManageRenameTests.swift in Sources */, AC7600000000000000000004 /* ActivityWidgetPresentationTests.swift in Sources */, AC7600000000000000000002 /* HubProjectPresentationTests.swift in Sources */, AC7200000000000000000001 /* ActivityContractDecodingTests.swift in Sources */, diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index ae71a6853..aa04444cd 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -168,6 +168,10 @@ struct LaneDetailScreen: View { dismiss() // SyncService refreshes once its retained host cleanup finishes; // starting another snapshot request here would duplicate that work. + }, + onRenamed: { + await loadDetail(refreshRemote: true) + await onRefreshRoot() } ) { await loadDetail(refreshRemote: true) diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 5c56293a2..6dbefc977 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -7,10 +7,13 @@ struct LaneManageSheet: View { let snapshot: LaneListSnapshot let allLaneSnapshots: [LaneListSnapshot] let onDeleted: (@MainActor () async -> Void)? + let onRenamed: (@MainActor () async -> Void)? let onComplete: @MainActor () async -> Void @State private var activeTab: ManageLaneTab = .delete + @State private var displayedName: String @State private var renameText: String + @State private var renamePresented = false @State private var selectedParentLaneId: String @State private var baseBranchOverride: String = "" @State private var colorText: String @@ -25,13 +28,16 @@ struct LaneManageSheet: View { snapshot: LaneListSnapshot, allLaneSnapshots: [LaneListSnapshot], onDeleted: (@MainActor () async -> Void)? = nil, + onRenamed: (@MainActor () async -> Void)? = nil, onComplete: @escaping @MainActor () async -> Void ) { self.snapshot = snapshot self.allLaneSnapshots = allLaneSnapshots self.onDeleted = onDeleted + self.onRenamed = onRenamed self.onComplete = onComplete let primaryLaneId = allLaneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id ?? "" + _displayedName = State(initialValue: snapshot.lane.name) _renameText = State(initialValue: snapshot.lane.name) _selectedParentLaneId = State(initialValue: snapshot.lane.parentLaneId ?? primaryLaneId) _colorText = State(initialValue: snapshot.lane.color ?? "") @@ -128,6 +134,13 @@ struct LaneManageSheet: View { laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: syncService.status(for: .lanes)) } + private var showsRenameControl: Bool { + LaneManageRename.showsRenameControl( + laneType: snapshot.lane.laneType, + hostSupportsRename: syncService.canInvokeRemoteAction("lanes.rename") + ) + } + private var liveActionNoticePresentation: LaneEmptyStatePresentation? { laneLiveActionNotice( connectionState: syncService.connectionState, @@ -163,8 +176,6 @@ struct LaneManageSheet: View { manageErrorBanner(errorMessage) } - laneNameTitle - laneInfoHeader if isPrimary { @@ -186,13 +197,36 @@ struct LaneManageSheet: View { .adeScreenBackground() .overlay { busyOverlay } .adeNavigationGlass() - .navigationTitle(snapshot.lane.name) + .navigationTitle(displayedName) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } .disabled(busyAction != nil) } + if showsRenameControl { + ToolbarItem(placement: .confirmationAction) { + Button { + renameText = displayedName + renamePresented = true + } label: { + Image(systemName: "pencil") + } + .accessibilityLabel("Rename lane") + .disabled(busyAction != nil || !canRunLiveActions) + } + } + } + .alert("Rename lane", isPresented: $renamePresented) { + TextField("Lane name", text: $renameText) + .textInputAutocapitalization(.words) + Button("Cancel", role: .cancel) {} + Button("Save") { + let draft = renameText + Task { await performRename(draft: draft) } + } + } message: { + Text("Changes the display name. The git branch stays the same.") } .onAppear { if !availableTabs.contains(activeTab) { @@ -216,49 +250,82 @@ struct LaneManageSheet: View { } } - private var laneTint: Color { - laneSurfaceTint(forHex: snapshot.lane.color).text ?? ADEColor.accent + private var laneInfoHeader: some View { + VStack(alignment: .leading, spacing: 10) { + metadataRow( + symbol: "arrow.triangle.branch", + value: branchLabel, + monospaced: true, + accessibilityNoun: "Branch", + lineLimit: 1 + ) { + if snapshot.lane.status.dirty { + Text("DIRTY") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.warning) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.warning.opacity(0.14), in: Capsule()) + } + } + metadataRow( + symbol: "folder", + value: snapshot.lane.worktreePath, + monospaced: false, + accessibilityNoun: "Path", + lineLimit: 2 + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } - private var laneNameTitle: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(snapshot.lane.name) - .font(.largeTitle.weight(.bold)) - .foregroundStyle(laneTint) - .lineLimit(2) - .minimumScaleFactor(0.7) - if snapshot.lane.status.dirty { - Text("DIRTY") - .font(.caption2.weight(.bold)) - .foregroundStyle(ADEColor.warning) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ADEColor.warning.opacity(0.14), in: Capsule()) - } - Spacer(minLength: 0) + private func metadataRow( + symbol: String, + value: String, + monospaced: Bool, + accessibilityNoun: String, + lineLimit: Int + ) -> some View { + metadataRow( + symbol: symbol, + value: value, + monospaced: monospaced, + accessibilityNoun: accessibilityNoun, + lineLimit: lineLimit + ) { + EmptyView() } - .accessibilityAddTraits(.isHeader) } - private var laneInfoHeader: some View { - VStack(alignment: .leading, spacing: 8) { - LabeledContent("Branch") { - Text(branchLabel) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - .font(.caption) - LabeledContent("Path") { - Text(snapshot.lane.worktreePath) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.trailing) - } + private func metadataRow( + symbol: String, + value: String, + monospaced: Bool, + accessibilityNoun: String, + lineLimit: Int, + @ViewBuilder trailing: () -> Trailing + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 18) + .padding(.top, 1) + .accessibilityHidden(true) + Text(value) + .font(monospaced ? .system(.caption, design: .monospaced) : .caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(lineLimit) + .truncationMode(.middle) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + trailing() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(accessibilityNoun), \(value)") } private var manageTabBar: some View { @@ -476,12 +543,6 @@ struct LaneManageSheet: View { .font(.caption) .foregroundStyle(ADEColor.textSecondary) - LaneTextField("Lane name", text: $renameText) - LaneActionButton(title: "Save name", symbol: "checkmark.circle.fill", tint: ADEColor.accent) { - Task { await performAction("rename lane") { try await syncService.renameLane(snapshot.lane.id, name: renameText) } } - } - .disabled(!canRunLiveActions || renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == snapshot.lane.name) - VStack(alignment: .leading, spacing: 6) { Text("Color") .font(.caption.weight(.semibold)) @@ -706,24 +767,62 @@ struct LaneManageSheet: View { } } + @MainActor + private func performRename(draft: String) async { + let trimmed = LaneManageRename.trimmedName(draft) + if trimmed.isEmpty { + ADEHaptics.warning() + errorMessage = "Lane name cannot be empty." + return + } + if let duplicate = LaneManageRename.duplicateName( + draft: trimmed, + laneId: snapshot.lane.id, + among: allLaneSnapshots.map(\.lane) + ) { + ADEHaptics.warning() + errorMessage = "A lane named \"\(duplicate)\" already exists." + return + } + guard LaneManageRename.canSave(draft: trimmed, currentName: displayedName) else { return } + let renamed = await runLiveAction("rename lane") { + try await syncService.renameLane(snapshot.lane.id, name: trimmed) + } + guard renamed else { return } + displayedName = trimmed + renameText = trimmed + ADEHaptics.success() + if let onRenamed { + await onRenamed() + } + } + @MainActor private func performAction(_ label: String, operation: () async throws -> Void) async { + guard await runLiveAction(label, operation: operation) else { return } + dismiss() + await onComplete() + } + + @MainActor + private func runLiveAction(_ label: String, operation: () async throws -> Void) async -> Bool { guard canRunLiveActions else { ADEHaptics.warning() errorMessage = "Reconnect to machine before you \(label)." - return + return false } do { busyAction = label errorMessage = nil try await operation() - dismiss() - await onComplete() + busyAction = nil + return true } catch { ADEHaptics.error() errorMessage = error.localizedDescription + busyAction = nil + return false } - busyAction = nil } @MainActor @@ -736,3 +835,34 @@ struct LaneManageSheet: View { } } } + +enum LaneManageRename { + static func trimmedName(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func canRename(laneType: String) -> Bool { + laneType != "primary" + } + + static func showsRenameControl(laneType: String, hostSupportsRename: Bool) -> Bool { + canRename(laneType: laneType) && hostSupportsRename + } + + static func duplicateName(draft: String, laneId: String, among: [LaneSummary]) -> String? { + let trimmed = trimmedName(draft) + guard !trimmed.isEmpty else { return nil } + return among.first { candidate in + candidate.id != laneId + && candidate.archivedAt == nil + && candidate.name.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(trimmed) == .orderedSame + }?.name + } + + static func canSave(draft: String, currentName: String) -> Bool { + let trimmed = trimmedName(draft) + guard !trimmed.isEmpty else { return false } + return trimmed != currentName.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index cda08d4ef..1645eeaaf 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -739,6 +739,9 @@ struct WorkRootScreen: View { manageLaneTarget = nil await reload(refreshRemote: true) }, + onRenamed: { + await reload(refreshRemote: true) + }, onComplete: { manageLaneTarget = nil await reload(refreshRemote: true) diff --git a/apps/ios/ADETests/LaneManageRenameTests.swift b/apps/ios/ADETests/LaneManageRenameTests.swift new file mode 100644 index 000000000..1a2024ead --- /dev/null +++ b/apps/ios/ADETests/LaneManageRenameTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import ADE + +final class LaneManageRenameTests: XCTestCase { + func testPrimaryLaneCannotRename() { + XCTAssertFalse(LaneManageRename.canRename(laneType: "primary")) + XCTAssertTrue(LaneManageRename.canRename(laneType: "worktree")) + XCTAssertFalse(LaneManageRename.showsRenameControl(laneType: "primary", hostSupportsRename: true)) + XCTAssertFalse(LaneManageRename.showsRenameControl(laneType: "worktree", hostSupportsRename: false)) + XCTAssertTrue(LaneManageRename.showsRenameControl(laneType: "worktree", hostSupportsRename: true)) + } + + func testSaveRejectsEmptyUnchangedAndWhitespace() { + XCTAssertFalse(LaneManageRename.canSave(draft: "", currentName: "Auth")) + XCTAssertFalse(LaneManageRename.canSave(draft: " ", currentName: "Auth")) + XCTAssertFalse(LaneManageRename.canSave(draft: "Auth", currentName: "Auth")) + XCTAssertFalse(LaneManageRename.canSave(draft: " Auth ", currentName: "Auth")) + XCTAssertTrue(LaneManageRename.canSave(draft: "Auth fallback", currentName: "Auth")) + XCTAssertTrue(LaneManageRename.canSave(draft: "auth", currentName: "Auth")) + } + + func testDuplicateNameIgnoresSelfAndArchivedLanes() { + let current = makeLane(id: "lane-a", name: "Auth") + let sibling = makeLane(id: "lane-b", name: "Search") + let archived = makeLane(id: "lane-c", name: "Auth fallback", archivedAt: "2026-01-01T00:00:00.000Z") + + XCTAssertNil( + LaneManageRename.duplicateName( + draft: "Auth", + laneId: current.id, + among: [current, sibling, archived] + ) + ) + XCTAssertEqual( + LaneManageRename.duplicateName( + draft: "search", + laneId: current.id, + among: [current, sibling, archived] + ), + "Search" + ) + XCTAssertNil( + LaneManageRename.duplicateName( + draft: "Auth fallback", + laneId: current.id, + among: [current, sibling, archived] + ) + ) + } + + private func makeLane(id: String, name: String, archivedAt: String? = nil) -> LaneSummary { + LaneSummary( + id: id, + name: name, + description: nil, + laneType: "worktree", + baseRef: "main", + branchRef: "ade/\(id)", + worktreePath: "/tmp/\(id)", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: "2026-06-01T00:00:00.000Z", + archivedAt: archivedAt + ) + } +} diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index ad947d238..77309eff2 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -187,8 +187,11 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneCommitHistoryScreen`, `LaneStashesScreen`, and `LaneDetailContentSections` files. `LaneManageSheet.swift` is now a tabbed manage dialog (delete / - appearance / stack / archive) mirroring desktop's `ManageLaneDialog`; - its stack tab keeps the parent-lane picker, optional base-branch + appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. + The nav bar keeps the lane name with a trailing pencil rename (hidden + for the primary lane and hosts that omit `lanes.rename`); the body + shows branch and path as icon rows, then the tabs. Its stack tab keeps + the parent-lane picker, optional base-branch override, "Runs git rebase" disclosure, dirty/rebase-in-progress guards, and `lanes.reparent` payloads that omit `stackBaseBranchRef` when the override is blank. A confirmed delete dismisses the manage @@ -556,7 +559,11 @@ a lane parented to primary would always show zero behind. values, blocks primary-lane renames, and rejects duplicate display names among active lanes (case-insensitive). The desktop **Manage Lane** dialog exposes rename via a pencil control beside the lane name in the - header. `reparent({ laneId, newParentLaneId, + header. The iOS **Manage Lane** sheet puts that pencil in the nav bar + (hidden for the primary lane and for hosts that omit `lanes.rename`), + keeps the name only in the nav title, and shows branch and path as + icon rows underneath — no second oversized title, and no "Branch" / + "Path" text labels. `reparent({ laneId, newParentLaneId, stackBaseBranchRef? })` refuses to move a lane under one of its own descendants and refuses to reparent the primary lane. When `stackBaseBranchRef` is supplied the service resolves it in the project diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 06f7546fe..3965dbbf4 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -2502,7 +2502,7 @@ Known limits, all deliberate: | Tab | Icon | Desktop equivalent | Capabilities | |---|---|---|---| -| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/manage, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) is organized into collapsible sections (`LaneDetailSectionChrome`): each section auto-opens when it has content and auto-collapses when empty (`LaneSectionDisclosure`), and stays where the user last put it once they toggle it manually. Header chips and the git action buttons flow through `LaneChipFlowLayout`, a wrapping flow layout that wraps onto new lines instead of horizontally scrolling. Lane rows in the list carry a cheap render-relevant signature (mirroring the Hub row-signature pattern) so `.equatable()` re-renders only rows whose visible state changed. It embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. Every lane is managed the same way regardless of where its worktree lives, so there is no adopt or "move into `.ade/worktrees`" action. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | +| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/manage, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) is organized into collapsible sections (`LaneDetailSectionChrome`): each section auto-opens when it has content and auto-collapses when empty (`LaneSectionDisclosure`), and stays where the user last put it once they toggle it manually. Header chips and the git action buttons flow through `LaneChipFlowLayout`, a wrapping flow layout that wraps onto new lines instead of horizontally scrolling. Lane rows in the list carry a cheap render-relevant signature (mirroring the Hub row-signature pattern) so `.equatable()` re-renders only rows whose visible state changed. It embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. The sheet keeps the lane name in the nav bar with a pencil rename on the right (hidden for the primary lane and hosts that omit `lanes.rename`), drops the oversized body title, and shows branch and path as icon rows. Every lane is managed the same way regardless of where its worktree lives, so there is no adopt or "move into `.ade/worktrees`" action. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `FilesSearchPanel` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. The session list itself is described in [Work session list rows](#work-session-list-rows). Each row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside its title. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: GitHub stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, Integration/Rebase workflow cards rendered from `PrWorkflowCard`, and per-PR action capabilities. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | From b5bbe2f9598b3d5b63a1f71024211619639b3cd8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:13:50 -0400 Subject: [PATCH 2/2] fix(ios): include dirty status in manage-lane VoiceOver labels The explicit accessibility label overrode combined children, so VoiceOver dropped the DIRTY chip on the branch row. Co-authored-by: Cursor --- .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 22 ++++++++++++--- apps/ios/ADETests/LaneManageRenameTests.swift | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 6dbefc977..8555dbd9a 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -257,7 +257,8 @@ struct LaneManageSheet: View { value: branchLabel, monospaced: true, accessibilityNoun: "Branch", - lineLimit: 1 + lineLimit: 1, + dirty: snapshot.lane.status.dirty ) { if snapshot.lane.status.dirty { Text("DIRTY") @@ -287,14 +288,16 @@ struct LaneManageSheet: View { value: String, monospaced: Bool, accessibilityNoun: String, - lineLimit: Int + lineLimit: Int, + dirty: Bool = false ) -> some View { metadataRow( symbol: symbol, value: value, monospaced: monospaced, accessibilityNoun: accessibilityNoun, - lineLimit: lineLimit + lineLimit: lineLimit, + dirty: dirty ) { EmptyView() } @@ -306,6 +309,7 @@ struct LaneManageSheet: View { monospaced: Bool, accessibilityNoun: String, lineLimit: Int, + dirty: Bool = false, @ViewBuilder trailing: () -> Trailing ) -> some View { HStack(alignment: .top, spacing: 10) { @@ -325,7 +329,13 @@ struct LaneManageSheet: View { trailing() } .accessibilityElement(children: .combine) - .accessibilityLabel("\(accessibilityNoun), \(value)") + .accessibilityLabel( + LaneManageRename.metadataAccessibilityLabel( + noun: accessibilityNoun, + value: value, + dirty: dirty + ) + ) } private var manageTabBar: some View { @@ -865,4 +875,8 @@ enum LaneManageRename { guard !trimmed.isEmpty else { return false } return trimmed != currentName.trimmingCharacters(in: .whitespacesAndNewlines) } + + static func metadataAccessibilityLabel(noun: String, value: String, dirty: Bool) -> String { + dirty ? "\(noun), \(value), dirty" : "\(noun), \(value)" + } } diff --git a/apps/ios/ADETests/LaneManageRenameTests.swift b/apps/ios/ADETests/LaneManageRenameTests.swift index 1a2024ead..4f31c28c3 100644 --- a/apps/ios/ADETests/LaneManageRenameTests.swift +++ b/apps/ios/ADETests/LaneManageRenameTests.swift @@ -48,6 +48,33 @@ final class LaneManageRenameTests: XCTestCase { ) } + func testMetadataAccessibilityLabelIncludesDirtyStatus() { + XCTAssertEqual( + LaneManageRename.metadataAccessibilityLabel( + noun: "Branch", + value: "ade/auth", + dirty: false + ), + "Branch, ade/auth" + ) + XCTAssertEqual( + LaneManageRename.metadataAccessibilityLabel( + noun: "Branch", + value: "ade/auth", + dirty: true + ), + "Branch, ade/auth, dirty" + ) + XCTAssertEqual( + LaneManageRename.metadataAccessibilityLabel( + noun: "Path", + value: "/tmp/lane", + dirty: false + ), + "Path, /tmp/lane" + ) + } + private func makeLane(id: String, name: String, archivedAt: String? = nil) -> LaneSummary { LaneSummary( id: id,