feat: T-2.6 SessionRegistry + SessionSwitcher UI

This commit is contained in:
jay 2026-05-16 04:16:51 +02:00
parent fb56c11a29
commit e29461b675
4 changed files with 270 additions and 0 deletions

View File

@ -0,0 +1,120 @@
// Sources/Core/Sessions/SessionRegistry.swift
// T-2.6: Session listing, creation, and deletion via the sidecar REST API.
import Foundation
// MARK: - SessionInfo
public struct SessionInfo: Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let state: String // "running", "idle", etc.
}
// MARK: - SessionRegistry
@MainActor
public final class SessionRegistry: ObservableObject {
@Published public var sessions: [SessionInfo] = []
@Published public var isLoading = false
@Published public var error: String? = nil
public init() {}
// MARK: - Private helpers
private func baseURL(credential: SidecarCredential) -> URL {
URL(string: "http://\(credential.host):\(credential.port)")!
}
private func authorizedRequest(url: URL, credential: SidecarCredential) -> URLRequest {
var req = URLRequest(url: url)
req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
return req
}
// MARK: - GET /sessions
/// Fetches the current session list from the sidecar and updates `sessions`.
public func refresh(credential: SidecarCredential) async {
isLoading = true
error = nil
defer { isLoading = false }
let url = baseURL(credential: credential).appendingPathComponent("sessions")
let req = authorizedRequest(url: url, credential: credential)
do {
let (data, _) = try await URLSession.shared.data(for: req)
let items = try JSONDecoder().decode([SessionItem].self, from: data)
sessions = items.map { SessionInfo(id: $0.id, name: $0.name, state: $0.state) }
} catch {
self.error = error.localizedDescription
}
}
// MARK: - POST /sessions
/// Creates a new session with the given name and returns the created `SessionInfo`.
@discardableResult
public func spawnSession(name: String, credential: SidecarCredential) async throws -> SessionInfo {
let url = baseURL(credential: credential).appendingPathComponent("sessions")
var req = authorizedRequest(url: url, credential: credential)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(["name": name])
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw SessionRegistryError.unexpectedStatus
}
let item = try JSONDecoder().decode(SessionItem.self, from: data)
let info = SessionInfo(id: item.id, name: item.name, state: item.state)
// Append optimistically; a follow-up refresh will normalise order.
sessions.append(info)
return info
}
// MARK: - DELETE /sessions/<id>
/// Deletes the session with the given id.
public func deleteSession(id: String, credential: SidecarCredential) async throws {
let url = baseURL(credential: credential)
.appendingPathComponent("sessions")
.appendingPathComponent(id)
var req = authorizedRequest(url: url, credential: credential)
req.httpMethod = "DELETE"
let (_, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw SessionRegistryError.unexpectedStatus
}
sessions.removeAll { $0.id == id }
}
}
// MARK: - Private Codable helper
private struct SessionItem: Decodable {
let id: String
let name: String
let state: String
}
// MARK: - Errors
public enum SessionRegistryError: LocalizedError {
case unexpectedStatus
public var errorDescription: String? {
switch self {
case .unexpectedStatus:
return "Unexpected HTTP status from sidecar."
}
}
}

View File

@ -0,0 +1,40 @@
// Sources/UI/Sessions/SessionRow.swift
// T-2.6: Row view for a single session in the SessionSwitcher list.
import SwiftUI
struct SessionRow: View {
let session: SessionInfo
var body: some View {
HStack {
Text(session.name)
.font(.body)
Spacer()
stateBadge
}
}
@ViewBuilder
private var stateBadge: some View {
HStack(spacing: 4) {
Circle()
.fill(badgeColor)
.frame(width: 8, height: 8)
Text(session.state)
.font(.caption)
.foregroundStyle(badgeColor)
}
}
private var badgeColor: Color {
switch session.state.lowercased() {
case "running", "active":
return .green
case "idle":
return .yellow
default:
return .secondary
}
}
}

View File

@ -0,0 +1,92 @@
// Sources/UI/Sessions/SessionSwitcher.swift
// T-2.6: Sheet UI for listing, selecting, and creating sessions.
import SwiftUI
struct SessionSwitcher: View {
@ObservedObject var registry: SessionRegistry
let credential: SidecarCredential
let onSelect: (SessionInfo) -> Void
@Environment(\.dismiss) private var dismiss
@State private var showNewSessionAlert = false
@State private var newSessionName = ""
@State private var spawnError: String? = nil
var body: some View {
NavigationStack {
Group {
if registry.isLoading {
ProgressView("Loading sessions…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(registry.sessions) { session in
Button {
onSelect(session)
dismiss()
} label: {
SessionRow(session: session)
}
.foregroundStyle(.primary)
}
.overlay {
if registry.sessions.isEmpty {
ContentUnavailableView(
"No Sessions",
systemImage: "terminal",
description: Text("Tap + to create a session.")
)
}
}
}
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button {
newSessionName = ""
spawnError = nil
showNewSessionAlert = true
} label: {
Image(systemName: "plus")
}
}
}
.alert("New Session", isPresented: $showNewSessionAlert) {
TextField("Session name", text: $newSessionName)
.autocorrectionDisabled()
Button("Create") {
let name = newSessionName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
Task {
do {
try await registry.spawnSession(name: name, credential: credential)
await registry.refresh(credential: credential)
} catch {
spawnError = error.localizedDescription
}
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Enter a name for the new session.")
}
.safeAreaInset(edge: .bottom) {
if let err = registry.error ?? spawnError {
Text(err)
.font(.caption)
.foregroundStyle(.red)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemBackground))
}
}
}
.task { await registry.refresh(credential: credential) }
}
}

View File

@ -14,6 +14,9 @@ struct MainTerminalView: View {
@State private var statusText = "Connecting…"
@State private var cancellables = Set<AnyCancellable>()
@EnvironmentObject var appState: AppState
@StateObject private var registry = SessionRegistry()
@State private var showSwitcher = false
@State private var activeSessionId: String? = nil
var body: some View {
VStack(spacing: 0) {
@ -23,6 +26,10 @@ struct MainTerminalView: View {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Spacer()
Button { showSwitcher = true } label: {
Image(systemName: "list.bullet")
}
.font(.caption)
Button("Unpair") {
appState.unpair()
}
@ -51,6 +58,17 @@ struct MainTerminalView: View {
.background(Color(uiColor: .secondarySystemBackground))
}
.task { await bootstrap() }
.task { await registry.refresh(credential: credential) }
.sheet(isPresented: $showSwitcher) {
SessionSwitcher(
registry: registry,
credential: credential,
onSelect: { session in
activeSessionId = session.id
showSwitcher = false
}
)
}
}
// MARK: - Bootstrap