Files
hapi/ios/Hapi/Features/Sessions/SessionListModel.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

248 lines
9.2 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
/// Sessions whose metadata carries no machine id group under this filter id.
let unknownMachineFilterId = "__unknown__"
/// One rendered list row: the summary plus everything derived for display.
struct SessionRowUI: Identifiable, Equatable {
let summary: SessionSummary
/// `getSessionTitle` port: name → summary text → path tail → id prefix.
let title: String
/// Secondary line: summary text when it is not already the title, else
/// the path.
let subtitle: String?
let machineLabel: String?
/// Raw flavor id (`claude`, `codex`, …); labels resolve via the catalog.
let flavor: String?
let unread: Bool
var id: String { summary.id }
}
struct MachineFilterUI: Identifiable, Equatable {
/// Machine id or `unknownMachineFilterId`.
let id: String
let label: String
let sessionCount: Int
}
/// Session-list presentation state over the `HubSession` stores — the iOS
/// counterpart of the Android reference's `SessionListViewModel`: row/filter
/// derivation, refresh + offline/loaded flags, last-seen stamping, and
/// pin/archive forwarding (optimism lives in the store). The SSE
/// subscription itself is owned by `HubSession`, not this model.
@MainActor @Observable
final class SessionListModel {
private let session: HubSession
/// Selected machine filter id (`nil` = All). In-memory only, like the
/// web's sidebar selection.
var machineFilter: String?
private(set) var isRefreshing = false
/// Last refresh failed — show the offline state over snapshot data.
private(set) var isOffline = false
private(set) var hasRefreshedOnce = false
/// Transient pin/archive failure for an alert.
var actionError: String?
init(session: HubSession) {
self.session = session
}
// MARK: - Derived state
/// True once either the snapshot or a refresh produced a list.
var hasLoaded: Bool {
hasRefreshedOnce || !session.sessionStore.sessions.isEmpty
}
/// Filter chips derive from ALL sessions (pre-filter), like the web —
/// filtering first would drop chips and silently clear the selection.
/// Encounter order is kept for equal counts so chips do not reshuffle.
var machineFilters: [MachineFilterUI] {
var counts: [(id: String, count: Int)] = []
var indexById: [String: Int] = [:]
for summary in session.sessionStore.sessions {
let id = summary.metadata?.machineId ?? unknownMachineFilterId
if let index = indexById[id] {
counts[index].count += 1
} else {
indexById[id] = counts.count
counts.append((id: id, count: 1))
}
}
return counts
.map { entry in
MachineFilterUI(
id: entry.id,
label: entry.id == unknownMachineFilterId ? "" : (machineLabel(entry.id) ?? ""),
sessionCount: entry.count
)
}
.sorted { $0.sessionCount > $1.sessionCount }
}
/// Render the chip bar only when at least two machines have sessions.
var showMachineFilterBar: Bool {
machineFilters.count >= 2
}
/// A persisted pick whose machine no longer has sessions falls back to
/// All; with fewer than two machines the bar hides and never filters.
var activeMachineFilter: String? {
guard let machineFilter else { return nil }
let filters = machineFilters
guard filters.count >= 2, filters.contains(where: { $0.id == machineFilter }) else {
return nil
}
return machineFilter
}
var rows: [SessionRowUI] {
let lastSeen = session.lastSeenStore.state.lastSeen
let activeFilter = activeMachineFilter
let visible = session.sessionStore.sessions.filter { summary in
guard let activeFilter else { return true }
return (summary.metadata?.machineId ?? unknownMachineFilterId) == activeFilter
}
return visible.map { summary in
let title = Self.sessionTitle(summary)
let rawSummaryText = summary.metadata?.summary?.text
let isBlank = rawSummaryText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
let summaryText = isBlank ? nil : rawSummaryText
return SessionRowUI(
summary: summary,
title: title,
subtitle: (summaryText != nil && summaryText != title)
? summaryText
: summary.metadata?.path,
machineLabel: machineLabel(summary.metadata?.machineId),
flavor: summary.metadata?.flavor,
unread: LastSeenStore.isUnread(summary, lastSeenAt: lastSeen[summary.id] ?? 0)
)
}
}
/// The sort contract puts globalPinned/pinned rows first; this boundary
/// index is where the pinned section ends.
static func pinnedCount(of rows: [SessionRowUI]) -> Int {
rows.prefix { $0.summary.globalPinned == true || $0.summary.pinned == true }.count
}
// MARK: - Actions
/// Pull-to-refresh / initial load. Coalesces concurrent calls; the first
/// successful list seeds the unread baseline so historical sessions do
/// not all light up as unread.
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
do {
try await session.sessionStore.refresh()
try await session.machineStore.refresh()
isOffline = false
hasRefreshedOnce = true
session.lastSeenStore.initializeBaseline(
scopeKey: session.hubUrl,
sessions: session.sessionStore.sessions
)
} catch {
isOffline = true
}
}
/// Call when navigating into a session: stamps the last-seen watermark.
func onSessionOpened(_ sessionId: String) {
guard let summary = session.sessionStore.sessions.first(where: { $0.id == sessionId }) else {
return
}
session.lastSeenStore.markSeen(sessionId: sessionId, seenAt: summary.updatedAt)
}
/// `PUT /sessions/:id/pin` with store-side optimistic re-sort; failures
/// surface on `actionError`.
func setPinMode(sessionId: String, mode: SessionPinMode) {
let store = session.sessionStore
Task {
do {
try await store.setPinMode(sessionId: sessionId, mode: mode)
} catch {
self.actionError = String(
format: String(localized: "Pin failed: %@"),
error.localizedDescription
)
}
}
}
/// `POST /sessions/:id/archive` with store-side optimistic removal;
/// failures surface on `actionError`.
func archiveSession(sessionId: String) {
let store = session.sessionStore
Task {
do {
try await store.archiveSession(sessionId: sessionId)
} catch {
self.actionError = String(
format: String(localized: "Archive failed: %@"),
error.localizedDescription
)
}
}
}
// MARK: - Helpers
/// `getSessionTitle` (`web/src/lib/sessionTitle.ts`): name → summary
/// text → path tail → id prefix.
static func sessionTitle(_ summary: SessionSummary) -> String {
if let name = summary.metadata?.name, !name.isEmpty {
return name
}
if let text = summary.metadata?.summary?.text, !text.isEmpty {
return text
}
if let path = summary.metadata?.path,
let tail = path.split(separator: "/").last(where: { !$0.isEmpty }) {
return String(tail)
}
return String(summary.id.prefix(8))
}
private func machineLabel(_ machineId: String?) -> String? {
guard let machineId else { return nil }
guard let metadata = session.machineStore.machines.first(where: { $0.id == machineId })?.metadata else {
return String(machineId.prefix(8))
}
if let displayName = metadata.displayName,
!displayName.trimmingCharacters(in: .whitespaces).isEmpty {
return displayName
}
return metadata.host
}
}
/// Compact relative-age label for list rows ("now", "5m", "3h", "2d").
/// Minute granularity is deliberate: it is why sub-minute `activeAt` churn
/// can be dropped as render-irrelevant (`sse.md#keep-alive-noise`). Mirrors
/// the Android reference (`formatRelativeAge`).
func formatRelativeAge(now: Date, thenEpochMs: Int) -> String {
let delta = Int(now.timeIntervalSince1970 * 1000) - thenEpochMs
if delta < 60_000 { return String(localized: "now") }
let minutes = delta / 60_000
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
let days = hours / 24
if days < 7 { return "\(days)d" }
let weeks = days / 7
if weeks < 5 { return "\(weeks)w" }
let months = days / 30
if months < 12 { return "\(months)mo" }
return "\(days / 365)y"
}