import SwiftUI import WireProtocol /// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the /// away-digest「展开」affordance (TerminalContainerView wiring). /// /// Mirrors the web timeline panel (public/timeline.ts): rows are /// "HH:MM · icon · label", newest-first, capped at /// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display /// input) — rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row /// discipline as AwayDigestView; web sets it via textContent, SEC-H6). struct TimelineSheet: View { let viewModel: TimelineViewModel var body: some View { NavigationStack { content .navigationTitle(TimelineCopy.title) .navigationBarTitleDisplayMode(.inline) } .presentationDetents([.medium, .large]) .task { await viewModel.load() } // fresh VM per presentation → one fetch } // MARK: - Phase switch @ViewBuilder private var content: some View { switch viewModel.phase { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) case .empty: emptyState case .failed: failedState case .loaded(let events): eventList(events) } } /// Server replied `[]` — covers both "no activity yet" and timeline /// capture disabled host-side (src/server.ts:589-591). An empty timeline /// is a normal state, NEVER an error (task spec). private var emptyState: some View { ContentUnavailableView( TimelineCopy.emptyTitle, systemImage: "clock.badge.questionmark", description: Text(TimelineCopy.emptyDetail) ) } /// Explicit, retryable error state — the fetch can fail transiently /// (LAN hop, host asleep); retry re-runs the same load path. private var failedState: some View { ContentUnavailableView { Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark") } description: { Text(TimelineCopy.loadFailedDetail) } actions: { Button(TimelineCopy.retry) { Task { await viewModel.load() } } .buttonStyle(.borderedProminent) .tint(DS.Palette.accent) } } // MARK: - Rows (newest-first, already ordered by the VM) private func eventList(_ events: [TimelineEvent]) -> some View { List { // Offset identity (not `at`): the server can ingest several // events in the same millisecond, and rows are static. ForEach(Array(events.enumerated()), id: \.offset) { _, event in row(event) } } .listStyle(.plain) } private func row(_ event: TimelineEvent) -> some View { HStack(spacing: DS.Space.md12) { // Timestamp — mono/tabular so HH:mm columns line up (direction). Text(TimelineRowFormat.timeLabel(atMs: event.at)) .font(DS.Typography.mono(.caption)) .foregroundStyle(DS.Palette.textSecondary) // class → glyph + semantic color (frozen mapping; unit-tested). Text(verbatim: TimelineClassStyle.glyph(for: event.class)) .font(DS.Typography.callout) .foregroundStyle(TimelineClassStyle.color(for: event.class)) .frame(width: DS.Space.xxl24) // Server-derived phrase — untrusted: verbatim (never // LocalizedStringKey/Markdown) + hard single-line truncation. Text(verbatim: event.label) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) Spacer(minLength: 0) } .padding(.vertical, DS.Space.xs2) .accessibilityElement(children: .combine) } } // MARK: - class → icon / color mapping /// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96). /// Colors are semantic — the web CSS defines NO tl-icon-* colors, so iOS /// aligns with SessionListScreen's status color convention (waiting=orange, /// stuck=red) and extends it. Total functions: the server is untrusted, so an /// unknown class degrades to a neutral glyph/color instead of trapping (even /// though `APIClient.events` already drops unknown classes — defense in depth). enum TimelineClassStyle { static let fallbackGlyph = "•" static func glyph(for cls: String) -> String { switch cls { case "tool": return "🔧" case "waiting": return "⏳" case "done": return "✓" case "stuck": return "⚠" case "user": return "💬" default: return fallbackGlyph } } static func color(for cls: String) -> Color { // All from the frozen design system — no raw SwiftUI colors (UX finding). switch cls { case "tool": return DS.Palette.timelineTool case "waiting": return DS.Palette.statusWaiting case "done": return DS.Palette.statusWorking case "stuck": return DS.Palette.statusStuck case "user": return DS.Palette.timelineUser default: return DS.Palette.textSecondary } } } // MARK: - Row time formatting /// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock /// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into /// the fixed format; timezone injectable for deterministic tests. enum TimelineRowFormat { private static let millisecondsPerSecond = 1_000.0 static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = timeZone formatter.dateFormat = "HH:mm" let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond) return formatter.string(from: date) } } // MARK: - 用户可见文案(中文具名常量,plan 工程标准) enum TimelineCopy { static let title = "活动时间线" static let emptyTitle = "暂无活动" static let emptyDetail = "会话还没有可展示的事件;主机关闭时间线(TIMELINE_ENABLED=0)时也会显示为空。" static let loadFailed = "时间线加载失败" static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。" static let retry = "重试" }