mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
HapiProtocol (pure, mirroring the Android reference port):
- SummaryPatching: sessionSummary.ts derivations (pending requests
cap-5/oldest-first/id-tiebreak, kinds, todo progress), toSessionSummary
projection with the per-flavor agentSessionId resolution (legacy chain
omits piSessionId, replicated), applySessionSummaryPatch with the
deliberate >= version gates (vs the detail path's strict >), the
keep-alive render-irrelevance filter (pendingRequests compare ignores
'since'; metadata compare ignores hapiMcpUrl), max-monotonic updatedAt,
post-patch updatedAt as fallback 'since', and the deprecated legacy
detail-required gate kept for rule pinning.
- SessionSorting: exact list comparator (globalPinned > pinned > active >
pendingRequestsCount desc among active > updatedAt desc; stable).
- SessionMetadata: per-flavor session-id fields (claude/codex/gemini/
opencode/grok/agy/cursor/kimi/copilot/pi).
HapiClient stores (@MainActor @Observable, per hub):
- DiskCache: 500 ms debounced atomic JSON snapshots + SnapshotLocations.
- SessionListStore: sorted summaries + detail cache; full-session upsert
preserving hub-computed scheduled fields; strict-> detail vs >= summary
patch paths; keep-alive identity preservation (listRevision); empty-{}
and unparseable payloads take the REST fallback; 16 ms coalesced
refresh; optimistic pin (roll-forward) and archive (restore).
- MachineStore: the exact machine-updated decision tree.
- LastSeenStore: monotonic unread watermarks + per-scope baseline.
- SyncEventRouter: SyncEvent fan-out to the stores, global-scope message
events refresh the list, gap handshake triggers the full resync.
App:
- HubSession owns the stores + router (replaces the TODO(M2) routing);
background flushes snapshots.
- Features/Sessions: SessionListView + SessionListModel (status dot with
thinking pulse, title cascade, flavor·machine·worktree meta, relative
age on a minute timeline, pending/todo badges, unread dots, pinned
section, machine filter chips >= 2, pull-to-refresh, empty/loading/
offline states, long-press pin/archive context menu) + an M2f chat
placeholder pushed on row tap.
- HomePlaceholderView -> HomeView hosting the list (hub switcher +
connection dot kept in the toolbar).
Tests transcribe the Android suites: SummaryPatchingTest (18),
SessionSortingTest (6), SessionStoreTest, MachineStoreTest,
LastSeenStoreTest, JsonSnapshotStoreTest, StoreSyncTargetsTest — using
the existing HTTPPerforming recording stub plus a path-routing performer
for concurrent refetches.
242 lines
9.0 KiB
Swift
242 lines
9.0 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 = "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 = "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 "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"
|
|
}
|