Files
hapi/ios/Hapi/Features/Sessions/SessionListModel.swift
T
weishu d52a5a0e69 feat(sessions): one-line row meta — project · worktree · machine, no raw paths
Rows carried a machine-only line plus the full absolute path (prefix
noise, tail-truncated exactly where the information lives, machine
repeated under the filter chips). Replace both with a single meta line:
the last two segments of the worktree base path (session path fallback
— the web sidebar's group-name rule), the worktree name when present,
and the machine label only while several machines are known with no
machine filter active. The subtitle now carries the AI summary or
nothing; full paths stay in the session detail. Typical rows shrink
from three or four lines to two.
2026-08-18 22:09:33 +08:00

277 lines
11 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, only when it is not already the title.
let subtitle: String?
/// Single meta line, `project · worktree · machine`: project is the last
/// two segments of the worktree base path (session path fallback, the web
/// sidebar's group-name rule); the machine label is disambiguation only —
/// present only when several machines are known and no machine filter is
/// active. Full paths never render in the list (the session detail owns
/// them).
let meta: 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
}
// With one machine — or a machine filter active — every visible row
// shares the machine, so repeating it per row is noise.
let showMachine = machineFilters.count >= 2 && activeFilter == nil
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
var metaParts: [String] = []
if let project = Self.projectLabel(summary) {
metaParts.append(project)
}
if let tree = summary.metadata?.worktree {
let name = tree.name.trimmingCharacters(in: .whitespaces)
metaParts.append(name.isEmpty ? tree.branch : tree.name)
}
if showMachine, let machine = machineLabel(summary.metadata?.machineId) {
metaParts.append(machine)
}
return SessionRowUI(
summary: summary,
title: title,
subtitle: (summaryText != nil && summaryText != title) ? summaryText : nil,
meta: metaParts.isEmpty ? nil : metaParts.joined(separator: " · "),
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
}
/// Project identity for the meta line: last two segments of the worktree
/// base path, session path fallback — mirrors the web sidebar's
/// `getGroupDisplayName` rule (`SessionList.tsx`).
static func projectLabel(_ summary: SessionSummary) -> String? {
guard let path = summary.metadata?.worktree?.basePath ?? summary.metadata?.path,
!path.isEmpty else { return nil }
let parts = path.split(whereSeparator: { $0 == "/" || $0 == "\\" }).map(String.init)
if parts.isEmpty { return path }
if parts.count == 1 { return parts[0] }
return "\(parts[parts.count - 2])/\(parts[parts.count - 1])"
}
// 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"
}