Files
hapi/ios/Hapi/Features/Sessions/SessionListView.swift
T
weishu 6066f20c20 feat(ios): zh-CN localization + catalog dedupe (A-M5)
Part 1 — Simplified Chinese localization of the iOS app layer:
- ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog
  (395 keys, zh-Hans; en implicit as source). Terminology mined from
  web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/
  机器/语音输入/用量/草稿夹 …).
- Mechanical edits only: merged multi-part string concatenations into
  single LocalizedStringKey literals, converted ternary/plain-String user
  copy to String(localized:), switched helper params (detailRow,
  DashboardCard, optionPicker, notice) to LocalizedStringKey.
- LocalizedNoticeMapper (app layer): display-point translation of the 27
  known HapiKit-emitted strings (ChatInteractor notices, dictation errors,
  window-sync warnings, files fallbacks, worktree-name validation) with
  verbatim passthrough for server-originated text; package stays
  language-free.
- Deliberately untranslated (web parity / non-copy): event rows
  (EventPresentation — web renders presentation.ts verbatim), catalog
  option labels (Default/Auto/Sonnet/permission modes — web shows them
  verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill,
  Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators.
- Language row wired: AppLanguage gains .system (follow system, new
  default); explicit picks write the AppleLanguages override, Follow
  system removes it; footer notes a relaunch applies it (no supported
  in-place SwiftUI locale swap).
- project.pbxproj: zh-Hans added to knownRegions.

Part 2 — #39 catalog dedupe (HapiProtocol):
- NewSessionCatalogs.claudeModels/claudeEfforts now derive from
  ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog);
  codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS
  minus max); effortLabel delegates to ModelCatalog.capitalizedFirst.
- New CatalogTests lock test for the derived option lists.

Gate: bash ios/scripts/linux-test.sh — 459 tests green.
2026-08-18 16:04:03 +08:00

390 lines
13 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// The session list (A-M2a) — standalone screen: navigation and hub chrome
/// stay outside; taps surface through `onOpenSession`.
///
/// Inventory (mirrors the web sidebar semantics via the Android port):
/// - offline state over snapshot data, machine filter chips (≥ 2 machines),
/// pull-to-refresh, empty/loading states;
/// - pinned section first (the sort already puts globalPinned/pinned rows on
/// top; a header makes the boundary visible);
/// - per row: status dot (active / thinking pulse), title, flavor + machine +
/// worktree meta line, summary/path line, relative `updatedAt`,
/// pending-request badge, todo-progress chip, unread dot;
/// - long-press context menu → pin (none/project/global) + archive with
/// optimistic store updates; failures land in an alert.
struct SessionListView: View {
@State private var model: SessionListModel
private let onOpenSession: (String) -> Void
init(session: HubSession, onOpenSession: @escaping (String) -> Void) {
_model = State(initialValue: SessionListModel(session: session))
self.onOpenSession = onOpenSession
}
var body: some View {
// Minute-tick timeline keeps the relative-age labels honest without
// any store churn.
TimelineView(.periodic(from: .now, by: 60)) { context in
sessionList(now: context.date)
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
if model.isOffline && model.hasLoaded {
offlineBanner
}
if model.showMachineFilterBar {
MachineFilterBar(
filters: model.machineFilters,
activeFilter: model.activeMachineFilter,
onSelect: { model.machineFilter = $0 }
)
}
}
}
.task {
// Explicit fetch on entry: the snapshot may be stale and a
// `resume: ok` handshake deliberately skips the REST resync.
await model.refresh()
}
.refreshable {
await model.refresh()
}
.alert(
"Action failed",
isPresented: Binding(
get: { model.actionError != nil },
set: { presented in
if !presented {
model.actionError = nil
}
}
)
) {
Button("OK") {
model.actionError = nil
}
} message: {
Text(model.actionError ?? "")
}
}
// MARK: - List
private func sessionList(now: Date) -> some View {
let rows = model.rows
let pinnedCount = SessionListModel.pinnedCount(of: rows)
return List {
if pinnedCount > 0 {
Section("Pinned") {
ForEach(rows.prefix(pinnedCount)) { row in
rowCell(row, now: now)
}
}
}
if rows.count > pinnedCount {
Section(pinnedCount > 0 ? String(localized: "Sessions") : "") {
ForEach(rows.dropFirst(pinnedCount)) { row in
rowCell(row, now: now)
}
}
}
}
.listStyle(.plain)
.overlay {
if rows.isEmpty {
emptyState
.allowsHitTesting(false) // keep the pull-to-refresh gesture
}
}
}
private func rowCell(_ row: SessionRowUI, now: Date) -> some View {
Button {
model.onSessionOpened(row.id)
onOpenSession(row.id)
} label: {
SessionRowView(row: row, now: now)
}
.buttonStyle(.plain)
.contextMenu {
contextMenuActions(row)
}
}
@ViewBuilder
private func contextMenuActions(_ row: SessionRowUI) -> some View {
let summary = row.summary
if summary.pinned == true || summary.globalPinned == true {
Button {
model.setPinMode(sessionId: row.id, mode: .none)
} label: {
Label("Unpin", systemImage: "pin.slash")
}
}
if summary.pinned != true {
Button {
model.setPinMode(sessionId: row.id, mode: .project)
} label: {
Label("Pin to Project", systemImage: "pin")
}
}
if summary.globalPinned != true {
Button {
model.setPinMode(sessionId: row.id, mode: .global)
} label: {
Label("Pin Globally", systemImage: "pin.circle")
}
}
Button(role: .destructive) {
model.archiveSession(sessionId: row.id)
} label: {
Label("Archive", systemImage: "archivebox")
}
}
// MARK: - Chrome
private var offlineBanner: some View {
Text("Offline — showing cached sessions")
.font(.footnote)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(.orange.opacity(0.15))
.foregroundStyle(.orange)
}
@ViewBuilder
private var emptyState: some View {
if !model.hasLoaded && !model.isOffline {
ContentUnavailableView {
Label("Loading sessions…", systemImage: "arrow.triangle.2.circlepath")
} description: {
Text("Fetching the session list from the hub.")
}
} else if model.isOffline {
ContentUnavailableView {
Label("Hub unreachable", systemImage: "wifi.slash")
} description: {
Text("Pull to retry once you are back online.")
}
} else {
ContentUnavailableView {
Label("No sessions yet", systemImage: "tray")
} description: {
Text("Start an agent with the hapi CLI and it will appear here.")
}
}
}
}
// MARK: - Row
struct SessionRowView: View {
let row: SessionRowUI
let now: Date
var body: some View {
HStack(alignment: .top, spacing: 10) {
StatusDot(active: row.summary.active, thinking: row.summary.thinking)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 2) {
titleLine
metaLine
if let subtitle = row.subtitle {
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
badgeLine
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
private var titleLine: some View {
HStack(spacing: 6) {
Text(row.title)
.font(.body)
.fontWeight(row.unread ? .semibold : .regular)
.lineLimit(1)
if row.unread {
Circle()
.fill(.tint)
.frame(width: 8, height: 8)
.accessibilityLabel("Unread")
}
Spacer(minLength: 4)
Text(formatRelativeAge(now: now, thenEpochMs: row.summary.updatedAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var metaLine: some View {
let worktree = row.summary.metadata?.worktree
let worktreeLabel = worktree.map { tree in
tree.name.trimmingCharacters(in: .whitespaces).isEmpty ? tree.branch : tree.name
}
let parts: [String] = [
row.flavor.map { flavorLabel(forFlavor: $0) },
row.machineLabel,
worktreeLabel,
].compactMap { $0 }
if !parts.isEmpty {
Text(parts.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
@ViewBuilder
private var badgeLine: some View {
let summary = row.summary
if summary.pendingRequestsCount > 0 || summary.todoProgress != nil {
HStack(spacing: 6) {
if summary.pendingRequestsCount > 0 {
PendingBadge(
count: summary.pendingRequestsCount,
kinds: summary.pendingRequestKinds,
requests: summary.pendingRequests
)
}
if let progress = summary.todoProgress {
TodoChip(progress: progress)
}
}
.padding(.top, 2)
}
}
}
/// Solid green for active (pulsing while thinking), muted gray when idle.
struct StatusDot: View {
let active: Bool
let thinking: Bool
var body: some View {
let dot = Circle()
.fill(active ? Color.green : Color.gray.opacity(0.45))
.frame(width: 10, height: 10)
Group {
if thinking {
dot.phaseAnimator([1.0, 0.25]) { view, opacity in
view.opacity(opacity)
} animation: { _ in
.easeInOut(duration: 0.7)
}
} else {
dot
}
}
.accessibilityLabel(active
? (thinking ? String(localized: "Thinking") : String(localized: "Active"))
: String(localized: "Inactive"))
}
}
/// Pending badge: authoritative `pendingRequestsCount` + kind wording; the
/// capped `pendingRequests` slice names the first tool.
struct PendingBadge: View {
let count: Int
let kinds: [PendingRequestKind]
let requests: [PendingRequest]
var body: some View {
let needsInput = kinds.contains(.input) && !kinds.contains(.permission)
let label: String
if needsInput {
label = String(localized: "needs input")
} else if let first = requests.first {
label = String(format: String(localized: "approve %@"), first.tool)
} else {
label = String(localized: "pending")
}
let text = count > 1 ? "\(count) · \(label)" : label
return Text(text)
.font(.caption2)
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.opacity(0.18), in: RoundedRectangle(cornerRadius: 6))
.foregroundStyle(.orange)
}
}
struct TodoChip: View {
let progress: TodoProgress
var body: some View {
Label("\(progress.completed)/\(progress.total)", systemImage: "checklist")
.font(.caption2)
.lineLimit(1)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15), in: RoundedRectangle(cornerRadius: 6))
.foregroundStyle(.secondary)
}
}
// MARK: - Machine filter
struct MachineFilterBar: View {
let filters: [MachineFilterUI]
let activeFilter: String?
let onSelect: (String?) -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: String(localized: "All"), selected: activeFilter == nil) {
onSelect(nil)
}
ForEach(filters) { filter in
let label = filter.label.isEmpty ? String(localized: "Unknown machine") : filter.label
FilterChip(
label: "\(label) · \(filter.sessionCount)",
selected: activeFilter == filter.id
) {
// Tapping the active chip toggles back to All.
onSelect(activeFilter == filter.id ? nil : filter.id)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.background(.bar)
}
}
struct FilterChip: View {
let label: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.footnote)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
selected
? AnyShapeStyle(.tint.opacity(0.18))
: AnyShapeStyle(Color.secondary.opacity(0.12)),
in: Capsule()
)
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.primary))
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}