feat(ipad): W1-W3 — adaptive split-view layout + finding fixes

T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
This commit is contained in:
Yaojia Wang
2026-07-05 19:58:30 +02:00
parent 77502ec4fe
commit 823432b1c8
18 changed files with 1594 additions and 84 deletions

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// T-iPad-2 · size-class `LayoutPolicy.mode` compact
/// iPhone / iPad Slide Over `StackRootView`
/// regular iPad / / Stage Manager `SplitRootView`
///
///
/// **线**PLAN_IOS_IPAD §5 T-iPad-2
/// - ZStack **** split detail
/// `scenePhase != .active` `PrivacyShadePolicy`
/// split detail stack push
/// - `.task` bootstrap / `.onChange(scenePhase)` / `.onOpenURL` / deep-link
/// alert / add-host sheet / Projects sheet `RootView`
/// compact
///
/// size class ****iPad Slide Over regularcompact
/// UI `terminalController`
/// `AppCoordinator` T-iOS-29 `.id`
///
struct AdaptiveRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ZStack {
layoutBranch
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
PrivacyShadeView()
}
}
.task { await coordinator.bootstrap() }
.onChange(of: scenePhase) { _, phase in
coordinator.handleScenePhase(phase)
}
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
} message: {
Text(coordinator.deepLink.hintMessage ?? "")
}
.sheet(
isPresented: $coordinator.isAddHostPresented,
onDismiss: { coordinator.addHostDismissed() }
) {
addHostSheet
}
.sheet(
isPresented: $coordinator.isProjectsPresented,
onDismiss: { coordinator.projectsDismissed() }
) {
projectsSheet
}
}
// MARK: - Layout branch (the SOLE size-class consumer)
@ViewBuilder private var layoutBranch: some View {
// Split only makes sense once we're in the session list. `.loading` and
// `.pairing` (genuine iPad first-run, no paired host) get the full-screen
// stack flow regardless of size class a split sidebar has nothing to
// list yet and would strand the user on the not-paired empty state
// (T-iPad-5 finding). Route-gate the split branch.
switch LayoutPolicy.mode(horizontalSizeClass: horizontalSizeClass) {
case .split where coordinator.route == .sessions:
SplitRootView(coordinator: coordinator)
default:
StackRootView(coordinator: coordinator)
}
}
// MARK: - Deep-link hint alert (T-iOS-22)
/// unknown host / store failure `RootView`
private var deepLinkHintBinding: Binding<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
}
// MARK: - Add-host sheet (multi-host entry, list header)
@ViewBuilder private var addHostSheet: some View {
if let viewModel = coordinator.addHostPairingViewModel {
NavigationStack {
PairingScreen(viewModel: viewModel) { host in
coordinator.completeAddHost(host)
}
}
}
}
// MARK: - Projects sheet (T-iOS-26)
/// NavigationStack sheet push
/// "" sheet
@ViewBuilder private var projectsSheet: some View {
if let viewModel = coordinator.projectsViewModel {
NavigationStack {
ProjectsScreen(viewModel: viewModel) { request in
coordinator.openProject(request)
}
}
}
}
}

View File

@@ -170,6 +170,62 @@ final class AppCoordinator {
terminalController = nil
}
/// T-iPad-3 · kill
/// `SessionListViewModel.kill` `APIClient.killSession` Origin
/// detach detail adopted no-op
func killCurrentSession() {
guard let sessionId = terminalController?.terminalViewModel.sessionId else { return }
Task { await sessionList.kill(sessionId: sessionId) }
closeTerminal()
}
// MARK: - Split-view sidebar bridge (T-iPad-2)
/// Detail sidebar getter
/// **adopted** adopted / detail nil
/// stack `terminalController` coordinator
///
var selectedSidebarItem: SidebarItem? {
terminalController?.terminalViewModel.sessionId.map(SidebarItem.session)
}
/// `NavigationSplitView` getter detail setter
/// `open` / `presentProjects`
/// set nil detail
var sidebarSelection: Binding<SidebarItem?> {
Binding(
get: { self.selectedSidebarItem },
set: { item in
guard let item else { return }
self.selectSidebarItem(item)
}
)
}
/// sidebar **** open(id) / open(nil) /
/// presentProjects API沿 WS closeopen
/// new-in-cwd / deep-link `.projects` sheet
func selectSidebarItem(_ item: SidebarItem) {
switch item {
case .session(let sessionId):
switchTerminal(sessionId: sessionId)
case .newSession:
switchTerminal(sessionId: nil)
case .projects:
presentProjects()
}
}
/// WS no-op churn
/// `closeTerminal()` last-seen engine detach
/// `open()`**** activeHost no-op
private func switchTerminal(sessionId: UUID?) {
guard let host = sessionList.activeHost else { return }
if let sessionId, selectedSidebarItem == .session(sessionId) { return }
if terminalController != nil { closeTerminal() }
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// MARK: - "" (T-iOS-29)
/// cwd/live-sessions server-adopted
@@ -235,3 +291,12 @@ final class AppCoordinator {
PairingViewModel(store: environment.hostStore, probe: environment.probe)
}
}
/// T-iPad-2 · split sidebar `AppCoordinator`
/// `.session(id)` == `open(id)``.newSession` == `open(nil)`
/// `.projects` == `presentProjects()`
enum SidebarItem: Hashable {
case session(UUID)
case newSession
case projects
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
/// T-iPad-2 ·
public enum LayoutMode: Equatable {
/// iPhone `NavigationStack` push
case stack
/// iPad `NavigationSplitView`sidebar + detail
case split
}
/// T-iPad-2 · **** size-class 仿 `PrivacyShadePolicy`
/// `if sizeClass == `PLAN_IOS_IPAD §4
/// 100%
///
/// regular iPad / / Stage Manager `.split`
/// compact niliPhone / iPad Slide Over / / size class
/// `.stack`nil 退退 iPhone
public enum LayoutPolicy {
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode {
horizontalSizeClass == .regular ? .split : .stack
}
}

View File

@@ -1,62 +1,39 @@
import HostRegistry
import SwiftUI
/// T-iOS-15 · Root of the app: route switch (Pairing / SessionList), terminal
/// push, add-host sheet, scenePhase forwarding and the privacy shade.
/// T-iOS-15 / T-iPad-2 · App `NavigationStack` +
/// + sheets/scenePhase/deepLink 线iPad T-iPad-2
///
/// - `RootView` struct `@main` WebTermApp
/// `RootView(coordinator:)` `AdaptiveRootView`
/// - `AdaptiveRootView` `horizontalSizeClass` `StackRootView`compact
/// `SplitRootView`regulariPad
/// / scenePhase / deepLink / sheets **线**
/// ZStack
///
/// The shade is the TOPMOST layer of this ZStack and appears whenever
/// `scenePhase != .active` (exact rule PrivacyShadePolicy + tests); it
/// covers the whole navigation tree, terminal included. Sheets live above any
/// overlay, but no sheet renders terminal bytes (pairing / plan gate only).
/// `StackRootView` `RootView` body **** compact
/// iPhone
struct RootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
NavigationStack {
rootContent
.navigationDestination(isPresented: terminalBinding) {
terminalDestination
}
}
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
PrivacyShadeView()
}
}
.task { await coordinator.bootstrap() }
.onChange(of: scenePhase) { _, phase in
coordinator.handleScenePhase(phase)
}
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
} message: {
Text(coordinator.deepLink.hintMessage ?? "")
}
.sheet(
isPresented: $coordinator.isAddHostPresented,
onDismiss: { coordinator.addHostDismissed() }
) {
addHostSheet
}
.sheet(
isPresented: $coordinator.isProjectsPresented,
onDismiss: { coordinator.projectsDismissed() }
) {
projectsSheet
}
AdaptiveRootView(coordinator: coordinator)
}
}
/// Deep-link hint alert (unknown host / store failure, T-iOS-22).
private var deepLinkHintBinding: Binding<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
/// T-iPad-2 · compact iPhone / iPad Slide Over
/// `RootView` `NavigationStack` 线
/// /scenePhase/deepLink/sheets `AdaptiveRootView`
struct StackRootView: View {
@Bindable var coordinator: AppCoordinator
var body: some View {
NavigationStack {
rootContent
.navigationDestination(isPresented: terminalBinding) {
terminalDestination
}
}
}
// MARK: - Route switch
@@ -140,7 +117,8 @@ struct RootView: View {
if let controller = coordinator.terminalController {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
)
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
// (new-in-cwd, deep link) swaps the controller while the
@@ -151,40 +129,15 @@ struct RootView: View {
.id(controller.id)
}
}
// MARK: - Add-host sheet (multi-host entry, list header)
@ViewBuilder private var addHostSheet: some View {
if let viewModel = coordinator.addHostPairingViewModel {
NavigationStack {
PairingScreen(viewModel: viewModel) { host in
coordinator.completeAddHost(host)
}
}
}
}
// MARK: - Projects sheet (T-iOS-26)
/// NavigationStack sheet push
/// "" sheet
@ViewBuilder private var projectsSheet: some View {
if let viewModel = coordinator.projectsViewModel {
NavigationStack {
ProjectsScreen(viewModel: viewModel) { request in
coordinator.openProject(request)
}
}
}
}
}
private enum RootMetrics {
enum RootMetrics {
static let bannerHorizontalPadding: CGFloat = 16
static let bannerVerticalPadding: CGFloat = 8
}
private enum RootCopy {
/// internal `SplitRootView` DRY
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// T-iPad-2 · regular iPad / / Stage Manager
/// sidebar = `SessionListScreen` **** +
/// stack leading detail = `TerminalContainerView`
/// + gate/digest
///
///
/// - **detail `TerminalContainerView`** push destination
/// split detail PLAN_IOS_IPAD §1
/// - ** `AppCoordinator` **sidebar
/// `selectSidebarItem` / `open` stack
/// - **detail `.id(controller.id)`** controller
/// SwiftTerm ring bufferidentity T-iOS-29
/// - **** `AdaptiveRootView` ZStack
/// detail `scenePhase != .active`
struct SplitRootView: View {
@Bindable var coordinator: AppCoordinator
var body: some View {
NavigationSplitView {
sidebar
} detail: {
detail
}
}
// MARK: - Sidebar + SessionListScreen
private var sidebar: some View {
SessionListScreen(
viewModel: coordinator.sessionList,
onOpen: { request in
// selectSidebarItem
coordinator.selectSidebarItem(sidebarItem(for: request))
},
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.toolbar { projectsToolbarItem }
}
/// `StackRootView.continueLastBanner` 5
/// sidebar iPad T-iPad-5 finding
@ViewBuilder private var continueLastBanner: some View {
if coordinator.continueLastSessionId != nil {
Button {
coordinator.openContinueLast()
} label: {
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal, RootMetrics.bannerHorizontalPadding)
.padding(.vertical, RootMetrics.bannerVerticalPadding)
.background(.thinMaterial)
}
}
/// `OpenRequest` sidebar sessionId = /
private func sidebarItem(for request: SessionListViewModel.OpenRequest) -> SidebarItem {
request.sessionId.map(SidebarItem.session) ?? .newSession
}
/// `StackRootView.projectsToolbarItem` leading disabled
/// `presentProjects` iPad Projects sheet
/// T-iPad-4
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
}
// MARK: - Detail
@ViewBuilder private var detail: some View {
if let controller = coordinator.terminalController {
NavigationStack {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
)
.id(controller.id) // T-iOS-29 · identity per controller
}
} else {
placeholder
}
}
private var placeholder: some View {
ContentUnavailableView {
Label(SplitCopy.placeholderTitle, systemImage: "sidebar.left")
} description: {
Text(SplitCopy.placeholderHint)
}
}
}
/// split `RootCopy.projects`DRY
private enum SplitCopy {
static let placeholderTitle = "选择或新建会话"
static let placeholderHint = "从左侧选择一个运行中的会话,或新建一个开始工作。"
}

View File

@@ -22,6 +22,10 @@ struct TerminalContainerView: View {
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
/// "" action (RootView supplies the coordinator hop).
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · pass-through to TerminalScreen's pointer context-menu
/// "" action (root layers supply the coordinator hop). nil on
/// surfaces without a kill affordance (e.g. previews).
var onKillSession: (@MainActor () -> Void)? = nil
/// Epoch of a plan gate the user swiped away suppresses re-present for
/// THAT gate only; a new epoch re-presents automatically.
@State private var dismissedPlanGateEpoch: Int?
@@ -48,7 +52,8 @@ struct TerminalContainerView: View {
var body: some View {
TerminalScreen(
viewModel: controller.terminalViewModel,
onNewSessionInCwd: onNewSessionInCwd
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.id(controller.generation)
.navigationBarTitleDisplayMode(.inline)