import HostRegistry import SwiftUI import WireProtocol /// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure /// presentation over `SessionListViewModel` — polling cadence, badge priority, /// staleness, optimistic kill and the navigation signal are all VM logic, /// unit-tested in `SessionListViewModelTests`. /// /// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with /// `request.sessionId` — nil = new session) and `onAddHost` (present the /// pairing sheet; call `viewModel.reloadHosts()` when it completes). struct SessionListScreen: View { var viewModel: SessionListViewModel /// Navigation hook: fired once per `OpenRequest` (unique id per tap). var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in } /// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15). var onAddHost: () -> Void = {} var body: some View { content .navigationTitle(ScreenCopy.title) .toolbar { hostMenu } .onAppear { viewModel.appeared() } .onDisappear { viewModel.disappeared() } .onChange(of: viewModel.openRequest) { _, request in guard let request else { return } onOpen(request) } } @ViewBuilder private var content: some View { switch viewModel.emptyState { case .notPaired: notPairedView case .noSessions: noSessionsView case nil: sessionList } } // MARK: - List private var sessionList: some View { List { if let message = viewModel.fetchErrorMessage { errorRow(message) } if let message = viewModel.killErrorMessage { errorRow(message) } Button { viewModel.requestNewSession() } label: { Label(ScreenCopy.newSession, systemImage: "plus.circle.fill") } ForEach(viewModel.rows) { row in Button { viewModel.openSession(id: row.id) } label: { SessionRowView(row: row) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { Task { await viewModel.kill(sessionId: row.id) } } label: { Label(ScreenCopy.kill, systemImage: "xmark.circle.fill") } } } } .refreshable { await viewModel.refresh() } } private func errorRow(_ message: String) -> some View { Label(message, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.red) } // MARK: - Empty states private var notPairedView: some View { ContentUnavailableView { Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot") } description: { Text(ScreenCopy.notPairedHint) } actions: { Button(ScreenCopy.addHost) { onAddHost() } .buttonStyle(.borderedProminent) } } private var noSessionsView: some View { ContentUnavailableView { Label(ScreenCopy.noSessionsTitle, systemImage: "terminal") } description: { Text(ScreenCopy.noSessionsHint) } actions: { Button(ScreenCopy.newSession) { viewModel.requestNewSession() } .buttonStyle(.borderedProminent) } } // MARK: - Host-switch header (multi-host from HostStore) private var hostMenu: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Menu { ForEach(viewModel.hosts) { host in Button { Task { await viewModel.selectHost(id: host.id) } } label: { if host.id == viewModel.activeHost?.id { Label(host.name, systemImage: "checkmark") } else { Text(host.name) } } } Divider() Button { onAddHost() } label: { Label(ScreenCopy.addHost, systemImage: "plus") } } label: { Label( viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback, systemImage: "desktopcomputer" ) } } } } // MARK: - Row private struct SessionRowView: View { let row: SessionListViewModel.SessionRow var body: some View { HStack(alignment: .top, spacing: Metrics.rowSpacing) { indicator .frame(width: Metrics.indicatorWidth) VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) { Text(title) .font(.body) .lineLimit(1) Text(meta) .font(.caption) .foregroundStyle(.secondary) if let telemetry = row.telemetry, !telemetry.isEmpty { TelemetryChips(model: telemetry) } } } .opacity(row.info.exited ? Metrics.exitedOpacity : 1) .accessibilityElement(children: .combine) } /// ⚠ badge outranks the status dot (VM decides via `indicator`). @ViewBuilder private var indicator: some View { switch row.indicator { case .pendingApproval: Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) .accessibilityLabel(ScreenCopy.pendingBadgeLabel) case .status(let status): Circle() .fill(Self.dotColor(status)) .frame(width: Metrics.dotSize, height: Metrics.dotSize) .padding(.top, Metrics.dotTopPadding) .accessibilityLabel(Text(status.rawValue)) } } /// `cwd` is server-supplied display text (untrusted → plain Text only). private var title: String { guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory } return URL(fileURLWithPath: cwd).lastPathComponent } private var meta: String { var parts = [ ScreenCopy.clientCount(row.info.clientCount), "\(row.info.cols)×\(row.info.rows)", ] if row.info.exited { parts.append(ScreenCopy.exitedTag) } return parts.joined(separator: " · ") } private static func dotColor(_ status: ClaudeStatus) -> Color { switch status { case .working: return .green case .waiting: return .orange case .idle: return .blue case .stuck: return .red case .unknown: return .gray } } private enum Metrics { static let rowSpacing: CGFloat = 10 static let rowInnerSpacing: CGFloat = 3 static let indicatorWidth: CGFloat = 18 static let dotSize: CGFloat = 10 static let dotTopPadding: CGFloat = 5 static let exitedOpacity = 0.55 } } // MARK: - Screen copy private enum ScreenCopy { static let title = "会话" static let newSession = "新建会话" static let kill = "结束" static let addHost = "配对新主机" static let hostMenuFallback = "主机" static let notPairedTitle = "还没有配对的主机" static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。" static let noSessionsTitle = "主机上没有运行中的会话" static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。" static let unknownDirectory = "未知目录" static let exitedTag = "已退出" static let pendingBadgeLabel = "等待审批" static func clientCount(_ count: Int) -> String { "\(count) 台设备在看" } }