Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
168 lines
6.4 KiB
Swift
168 lines
6.4 KiB
Swift
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 = "重试"
|
||
}
|