mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
The API wrappers existed but iOS had no UI for these actions, while web and Android did. The session row's context menu and the chat's … menu now offer Rename for every session. Active sessions keep Archive. Inactive sessions get Reopen and a confirmed Delete instead of Archive, which the hub rejects with 409 for them. The store applies renames and deletes optimistically. A failed rename refetches server state; a failed delete restores the row and the cached detail. Reopen marks the returned session active right away. When reopen returns a superseding id, the chat seeds the new window, moves the draft and navigates to it, as send-resume already does. A reopen from the list also moves the draft and opens the session. A 422 shows the hub's message with the missing fields. On iPhone, an authoritative removal (delete, or archive elsewhere) now pops that chat, matching the iPad split, which already clears the selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
16 KiB
Swift
426 lines
16 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
|
|
/// Short project name only. Paths, machine/worktree details and the
|
|
/// conversation summary stay out of the compact home row.
|
|
let project: String?
|
|
/// Raw flavor id (`claude`, `codex`, …); labels resolve via the catalog.
|
|
let flavor: String?
|
|
let unread: Bool
|
|
|
|
var id: String { summary.id }
|
|
var status: SessionRowStatus? { SessionRowStatus(summary: summary) }
|
|
}
|
|
|
|
/// Presentation only: request totals/kinds come from the hub, independently
|
|
/// of the capped request slice and the local read watermark.
|
|
enum SessionRowStatus: Equatable {
|
|
case needsReply(Int)
|
|
case needsApproval(Int)
|
|
case needsAttention(Int)
|
|
case running
|
|
|
|
init?(summary: SessionSummary) {
|
|
let count = summary.pendingRequestsCount
|
|
if count > 0 {
|
|
let kinds = Set(summary.pendingRequestKinds)
|
|
if kinds == [.input] {
|
|
self = .needsReply(count)
|
|
} else if kinds == [.permission] {
|
|
self = .needsApproval(count)
|
|
} else {
|
|
self = .needsAttention(count)
|
|
}
|
|
} else if summary.active && summary.thinking {
|
|
self = .running
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Localize in the view so its locale also governs previews/specimens.
|
|
var titleKey: String {
|
|
switch self {
|
|
case .needsReply: return "Needs reply"
|
|
case .needsApproval: return "Needs approval"
|
|
case .needsAttention: return "Needs attention"
|
|
case .running: return "Running"
|
|
}
|
|
}
|
|
|
|
var count: Int? {
|
|
switch self {
|
|
case .needsReply(let count), .needsApproval(let count), .needsAttention(let count): return count
|
|
case .running: return nil
|
|
}
|
|
}
|
|
|
|
/// Unfilled attention symbols; running uses the native loading indicator.
|
|
/// Status wording/count remain available to VoiceOver in either case.
|
|
var symbolName: String? {
|
|
switch self {
|
|
case .needsReply: return "bubble.left"
|
|
case .needsApproval: return "hand.raised"
|
|
case .needsAttention: return "exclamationmark.bubble"
|
|
case .running: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MachineFilterUI: Identifiable, Equatable {
|
|
/// Machine id or `unknownMachineFilterId`.
|
|
let id: String
|
|
let label: String
|
|
let sessionCount: Int
|
|
}
|
|
|
|
/// Transient, per-home filters. Add future dimensions here, not to navigation.
|
|
struct SessionListFilters: Equatable, Hashable {
|
|
var machineId: String?
|
|
|
|
var isActive: Bool { machineId != nil }
|
|
}
|
|
|
|
/// 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/rename/delete/reopen forwarding (optimism lives in the
|
|
/// store). The SSE
|
|
/// subscription itself is owned by `HubSession`, not this model.
|
|
@MainActor @Observable
|
|
final class SessionListModel {
|
|
private let sessionStore: any SessionListStoring
|
|
private let machineStore: any MachineListStoring
|
|
private let lastSeenStore: LastSeenStore
|
|
private let hubUrl: String
|
|
/// Reopen follows a superseding id with the composer draft (web
|
|
/// `transferComposerDraftThenNavigate`).
|
|
private let drafts: (any ChatDrafts)?
|
|
|
|
/// Not persisted: a new home / hub starts with all sessions.
|
|
private(set) var filters = SessionListFilters()
|
|
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
|
|
@ObservationIgnored private var hasAttemptedRefresh = false
|
|
/// Transient row-action failure for an alert.
|
|
var actionError: String?
|
|
|
|
convenience init(session: HubSession) {
|
|
self.init(
|
|
sessionStore: session.sessionStore, machineStore: session.machineStore,
|
|
lastSeenStore: session.lastSeenStore, hubUrl: session.hubUrl,
|
|
drafts: UserDefaultsChatDrafts(scope: session.hubUrl)
|
|
)
|
|
}
|
|
|
|
init(
|
|
sessionStore: any SessionListStoring,
|
|
machineStore: any MachineListStoring,
|
|
lastSeenStore: LastSeenStore,
|
|
hubUrl: String,
|
|
drafts: (any ChatDrafts)? = nil
|
|
) {
|
|
self.sessionStore = sessionStore
|
|
self.machineStore = machineStore
|
|
self.lastSeenStore = lastSeenStore
|
|
self.hubUrl = hubUrl
|
|
self.drafts = drafts
|
|
}
|
|
|
|
// MARK: - Derived state
|
|
|
|
/// True once either the snapshot or a refresh produced a list.
|
|
var hasLoaded: Bool {
|
|
hasRefreshedOnce || !sessionStore.sessions.isEmpty
|
|
}
|
|
|
|
/// All session groups, including historical / unidentified machines.
|
|
/// The online roster is only a source of labels, never filter membership.
|
|
var machineFilterIds: Set<String> {
|
|
Set(sessionStore.sessions.map { $0.metadata?.machineId ?? unknownMachineFilterId })
|
|
}
|
|
|
|
/// Counts are pre-filter. Names, not live counts, determine menu order.
|
|
var machineFilters: [MachineFilterUI] {
|
|
var counts: [String: Int] = [:]
|
|
for summary in sessionStore.sessions {
|
|
let id = summary.metadata?.machineId ?? unknownMachineFilterId
|
|
counts[id, default: 0] += 1
|
|
}
|
|
var names: [String: String] = [:]
|
|
for machine in machineStore.machines where counts[machine.id] != nil {
|
|
guard let metadata = machine.metadata else { continue }
|
|
let displayName = metadata.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let host = metadata.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let name = displayName.isEmpty ? host : displayName
|
|
if !name.isEmpty { names[machine.id] = name }
|
|
}
|
|
let nameCounts = Dictionary(grouping: names.values, by: { $0.lowercased() }).mapValues(\.count)
|
|
return counts
|
|
.map { entry in
|
|
let id = entry.key
|
|
let label: String
|
|
if id == unknownMachineFilterId {
|
|
label = String(localized: "Unknown machine")
|
|
} else if let name = names[id] {
|
|
label = nameCounts[name.lowercased(), default: 0] > 1
|
|
? "\(name) · \(id.prefix(8))" : name
|
|
} else {
|
|
label = String(format: String(localized: "Machine · %@"), String(id.prefix(8)))
|
|
}
|
|
return MachineFilterUI(id: id, label: label, sessionCount: entry.value)
|
|
}
|
|
.sorted { lhs, rhs in
|
|
func rank(_ id: String) -> Int {
|
|
id == unknownMachineFilterId ? 2 : (names[id] == nil ? 1 : 0)
|
|
}
|
|
if rank(lhs.id) != rank(rhs.id) { return rank(lhs.id) < rank(rhs.id) }
|
|
let order = lhs.label.localizedStandardCompare(rhs.label)
|
|
return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending
|
|
}
|
|
}
|
|
|
|
var showsFilterMenu: Bool {
|
|
machineFilterIds.count >= 2
|
|
}
|
|
|
|
/// Never render an invalid filter, even before the view reconciles it.
|
|
var activeMachineFilter: String? {
|
|
guard let machineId = filters.machineId else { return nil }
|
|
let ids = machineFilterIds
|
|
guard ids.count >= 2, ids.contains(machineId) else {
|
|
return nil
|
|
}
|
|
return machineId
|
|
}
|
|
|
|
var filterSummary: String? {
|
|
guard let id = activeMachineFilter,
|
|
let machine = machineFilters.first(where: { $0.id == id }) else { return nil }
|
|
return String(format: String(localized: "Machine: %@"), machine.label)
|
|
}
|
|
|
|
func selectMachine(_ id: String?) {
|
|
filters.machineId = id
|
|
reconcileFilters()
|
|
}
|
|
|
|
func clearFilters() {
|
|
filters = SessionListFilters()
|
|
}
|
|
|
|
/// Clear the stored pick as well, so a vanished group cannot resurrect it.
|
|
func reconcileFilters() {
|
|
if filters.machineId != activeMachineFilter {
|
|
filters.machineId = nil
|
|
}
|
|
}
|
|
|
|
var rows: [SessionRowUI] {
|
|
let lastSeen = lastSeenStore.state.lastSeen
|
|
let activeFilter = activeMachineFilter
|
|
let visible = sessionStore.sessions.filter { summary in
|
|
guard let activeFilter else { return true }
|
|
return (summary.metadata?.machineId ?? unknownMachineFilterId) == activeFilter
|
|
}
|
|
return visible.map { summary in
|
|
SessionRowUI(
|
|
summary: summary,
|
|
title: Self.sessionTitle(summary),
|
|
project: Self.projectLabel(summary),
|
|
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
|
|
}
|
|
|
|
/// Use the worktree's owning project, not its temporary checkout name.
|
|
/// The home needs a scan key, not a repeated path or machine inventory.
|
|
static func projectLabel(_ summary: SessionSummary) -> String? {
|
|
guard let path = summary.metadata?.worktree?.basePath ?? summary.metadata?.path else { return nil }
|
|
return path.split(whereSeparator: { $0 == "/" || $0 == "\\" }).last.map(String.init)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
/// A split sidebar can disappear/reappear just because the window
|
|
/// collapses. Fetch once per home, not once per layout transition;
|
|
/// global SSE recovery and explicit pull-to-refresh still fetch normally.
|
|
func refreshOnFirstAppearance() async {
|
|
guard !hasAttemptedRefresh else { return }
|
|
hasAttemptedRefresh = true
|
|
// The sidebar's SwiftUI task is cancelled when it collapses. The
|
|
// initial fetch belongs to the model, not that transient presentation.
|
|
await Task { await self.refresh() }.value
|
|
}
|
|
|
|
/// 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 }
|
|
hasAttemptedRefresh = true
|
|
isRefreshing = true
|
|
defer { isRefreshing = false }
|
|
do {
|
|
try await sessionStore.refresh()
|
|
reconcileFilters()
|
|
try await machineStore.refresh()
|
|
isOffline = false
|
|
hasRefreshedOnce = true
|
|
lastSeenStore.initializeBaseline(
|
|
scopeKey: hubUrl,
|
|
sessions: sessionStore.sessions
|
|
)
|
|
} catch {
|
|
isOffline = true
|
|
}
|
|
}
|
|
|
|
/// Call when navigating into a session: stamps the last-seen watermark.
|
|
func onSessionOpened(_ sessionId: String) {
|
|
guard let summary = sessionStore.sessions.first(where: { $0.id == sessionId }) else {
|
|
return
|
|
}
|
|
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 = 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 = sessionStore
|
|
Task {
|
|
do {
|
|
try await store.archiveSession(sessionId: sessionId)
|
|
} catch {
|
|
self.actionError = String(
|
|
format: String(localized: "Archive failed: %@"),
|
|
error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `PATCH /sessions/:id` with a store-side optimistic name.
|
|
func renameSession(sessionId: String, name: String) {
|
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
let store = sessionStore
|
|
Task {
|
|
do {
|
|
try await store.renameSession(sessionId: sessionId, name: trimmed)
|
|
} catch {
|
|
self.actionError = String(
|
|
format: String(localized: "Rename failed: %@"),
|
|
error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `DELETE /sessions/:id` (inactive sessions only) with store-side
|
|
/// optimistic removal; a 409 means it became active again.
|
|
func deleteSession(sessionId: String) {
|
|
let store = sessionStore
|
|
Task {
|
|
do {
|
|
try await store.deleteSession(sessionId: sessionId)
|
|
} catch {
|
|
self.actionError = isActiveSessionConflict(error)
|
|
? String(localized: "Active sessions can't be deleted — archive first")
|
|
: String(format: String(localized: "Delete failed: %@"), error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `POST /sessions/:id/reopen`, then open the (possibly superseding)
|
|
/// session, carrying its composer draft along.
|
|
func reopenSession(sessionId: String, onReopened: @escaping @MainActor (String) -> Void) {
|
|
let store = sessionStore
|
|
Task {
|
|
do {
|
|
let response = try await store.reopenSession(sessionId: sessionId)
|
|
if response.sessionId != sessionId {
|
|
self.drafts?.move(fromSessionId: sessionId, toSessionId: response.sessionId)
|
|
}
|
|
onReopened(response.sessionId)
|
|
} catch {
|
|
self.actionError = formatReopenError(error)
|
|
?? String(localized: "Failed to reopen session")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
/// 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"
|
|
}
|