feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
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/.
This commit is contained in:
@@ -7,6 +7,13 @@ import WireProtocol
|
||||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||||
/// unit-tested in `SessionListViewModelTests`.
|
||||
///
|
||||
/// UX polish (G1-list): the row is restructured for the 5-second glance —
|
||||
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
|
||||
/// color alone), a monospaced meta line, and telemetry as proper
|
||||
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
|
||||
/// `SectionHeader`s (presentation-only — the VM already groups exited last).
|
||||
/// Every color/spacing/radius/font/motion comes from `DS.*`.
|
||||
///
|
||||
/// 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).
|
||||
@@ -24,6 +31,10 @@ struct SessionListScreen: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
// Accent is not injected app-wide; scope it here so native chrome
|
||||
// (nav bar, bordered controls) picks up the DS indigo. Presentation
|
||||
// only — no behavior change.
|
||||
.tint(DS.Palette.accent)
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.toolbar { hostMenu }
|
||||
.onAppear { viewModel.appeared() }
|
||||
@@ -55,30 +66,80 @@ struct SessionListScreen: View {
|
||||
if let message = viewModel.killErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
ForEach(viewModel.rows) { row in
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||||
|
||||
newSessionRow
|
||||
|
||||
if !activeRows.isEmpty {
|
||||
Section {
|
||||
ForEach(activeRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.activeSection)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
if !exitedRows.isEmpty {
|
||||
Section {
|
||||
ForEach(exitedRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.exitedSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await viewModel.refresh() }
|
||||
}
|
||||
|
||||
/// Split the VM's (already exited-last) rows into the two visual groups.
|
||||
/// Presentation only — no ordering/logic decision lives here.
|
||||
private var activeRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { !$0.info.exited }
|
||||
}
|
||||
|
||||
private var exitedRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { $0.info.exited }
|
||||
}
|
||||
|
||||
/// Inviting primary entry — accent-tinted card row.
|
||||
private var newSessionRow: some View {
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
NewSessionRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
|
||||
/// so adjacent cards breathe.
|
||||
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
|
||||
EdgeInsets(
|
||||
top: vertical, leading: DS.Space.lg16,
|
||||
bottom: vertical, trailing: DS.Space.lg16
|
||||
)
|
||||
}
|
||||
|
||||
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive —
|
||||
/// rows imply a host) → no slot; the request key carries `lastOutputAt`
|
||||
/// so unchanged sessions render exactly once (pipeline cache).
|
||||
@@ -97,9 +158,12 @@ struct SessionListScreen: View {
|
||||
}
|
||||
|
||||
private func errorRow(_ message: String) -> some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - Empty states
|
||||
@@ -111,7 +175,7 @@ struct SessionListScreen: View {
|
||||
Text(ScreenCopy.notPairedHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.addHost) { onAddHost() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +186,7 @@ struct SessionListScreen: View {
|
||||
Text(ScreenCopy.noSessionsHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
}
|
||||
}
|
||||
@@ -162,6 +226,25 @@ struct SessionListScreen: View {
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
/// Inviting "新建会话" card — accent icon + accent title on a DS `Card`.
|
||||
private struct NewSessionRow: View {
|
||||
var body: some View {
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Text(ScreenCopy.newSession)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
|
||||
/// optional live thumbnail — laid out inside a DS `Card`.
|
||||
private struct SessionRowView: View {
|
||||
let row: SessionListViewModel.SessionRow
|
||||
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
|
||||
@@ -169,61 +252,69 @@ private struct SessionRowView: View {
|
||||
var thumbnail: SessionThumbnailView?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
|
||||
indicator
|
||||
.frame(width: Metrics.indicatorWidth)
|
||||
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
|
||||
HStack(spacing: Metrics.titleSpacing) {
|
||||
// T-iOS-23: OSC titles are attacker-controlled — already
|
||||
// sanitized in the VM, rendered verbatim (no Markdown /
|
||||
// LocalizedStringKey interpretation), one line only.
|
||||
Text(verbatim: title)
|
||||
.font(.body)
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(alignment: .top, spacing: DS.Space.md12) {
|
||||
// ⚠ pending / exited outrank the raw status (VM decides pending;
|
||||
// exited is a row fact) — resolved to a single DisplayStatus so
|
||||
// the badge shows color + distinct shape + Chinese VoiceOver.
|
||||
StatusBadge(status: displayStatus)
|
||||
.padding(.top, DS.Space.xs2)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
titleLine
|
||||
Text(meta)
|
||||
.dsMetaText()
|
||||
.lineLimit(1)
|
||||
if row.isUnread {
|
||||
unreadDot
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
}
|
||||
}
|
||||
Text(meta)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
if let thumbnail {
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
if let thumbnail {
|
||||
Spacer(minLength: Metrics.titleSpacing)
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
|
||||
.opacity(row.info.exited ? DS.Opacity.exited : 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))
|
||||
private var titleLine: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// T-iOS-23: OSC titles are attacker-controlled — already sanitized
|
||||
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
|
||||
// interpretation), one line only.
|
||||
Text(verbatim: title)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if row.isUnread {
|
||||
unreadDot
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
|
||||
/// Accent (indigo) continues the web selection color — distinct from the
|
||||
/// gray/green status shapes.
|
||||
private var unreadDot: some View {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize)
|
||||
.fill(DS.Palette.accent)
|
||||
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
|
||||
.accessibilityLabel(ScreenCopy.unreadLabel)
|
||||
}
|
||||
|
||||
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
|
||||
/// fact (top precedence); otherwise the VM's badge priority stands
|
||||
/// (pending ⚠ outranks the live status). No VM logic re-implemented here.
|
||||
private var displayStatus: DisplayStatus {
|
||||
if row.info.exited { return .exited }
|
||||
switch row.indicator {
|
||||
case .pendingApproval: return .pendingApproval
|
||||
case .status(let status): return DisplayStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
|
||||
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
|
||||
/// display text (untrusted → verbatim Text only).
|
||||
@@ -233,36 +324,14 @@ private struct SessionRowView: View {
|
||||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||||
}
|
||||
|
||||
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
|
||||
/// The exited state is conveyed by the badge + section + dimming, so it is
|
||||
/// not duplicated here.
|
||||
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
|
||||
static let titleSpacing: CGFloat = 6
|
||||
static let unreadDotSize: CGFloat = 8
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +348,9 @@ private enum ScreenCopy {
|
||||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||||
static let unknownDirectory = "未知目录"
|
||||
static let exitedTag = "已退出"
|
||||
static let pendingBadgeLabel = "等待审批"
|
||||
static let unreadLabel = "有新输出"
|
||||
static let activeSection = "运行中"
|
||||
static let exitedSection = "已结束"
|
||||
|
||||
static func clientCount(_ count: Int) -> String {
|
||||
"\(count) 台设备在看"
|
||||
|
||||
Reference in New Issue
Block a user