import Foundation import Observation import WireProtocol /// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`). /// /// One VM per presentation (the container view builds a fresh one on each /// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`. /// The fetch closure is injected — production wraps `APIClient.events` via /// `TerminalSessionController.timelineEventsSource`; tests inject fakes. /// /// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode /// (malformed / unknown-class entries dropped, non-array → `[]`) already /// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM /// only distinguishes the three USER-visible outcomes: /// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and /// for timeline capture disabled (src/server.ts:589-591); NEVER an error; /// - thrown fetch → `.failed` — explicit, retryable (`load()` again); /// - events → `.loaded`, ordered exactly like the web panel. @MainActor @Observable final class TimelineViewModel: Identifiable { /// Rendering phase — an explicit enum so the sheet can never show an /// error and rows at the same time. enum Phase: Equatable { case loading /// Display-ready rows: capped + newest-first (web parity, see /// `presentation(for:)`). case loaded([TimelineEvent]) /// Server returned `[]` (no activity OR timeline disabled host-side). case empty /// Fetch failed — retryable via `load()`. case failed } /// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20). static let maxEvents = 50 /// `.sheet(item:)` identity — a fresh VM per presentation. nonisolated let id = UUID() private(set) var phase: Phase = .loading @ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent] init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) { self.fetch = fetch } /// Assembly seam for the digest「展开」entry point: no adopted sessionId /// yet → no sheet (defensive — a digest only arrives after `attached`, so /// the id is normally known). Otherwise the id is passed to `source` /// verbatim on every load. static func forSession( _ sessionId: UUID?, source: @escaping @Sendable (UUID) async throws -> [TimelineEvent] ) -> TimelineViewModel? { guard let sessionId else { return nil } return TimelineViewModel(fetch: { try await source(sessionId) }) } /// Fetch and present. Also the「重试」path: callable again from `.failed`. func load() async { phase = .loading do { let events = try await fetch() phase = Self.presentation(for: events) } catch { phase = .failed // user-facing copy lives in TimelineSheet } } /// Pure presentation reducer, mirroring the web panel's render() pipeline /// line for line (public/timeline.ts:174-189): the server returns /// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet /// shows the same capped slice newest-first as the web timeline panel. static func presentation(for events: [TimelineEvent]) -> Phase { guard !events.isEmpty else { return .empty } return .loaded(Array(events.prefix(maxEvents).reversed())) } }