Files
hapi/ios/Hapi/Features/Sessions/SessionListView.swift
T

332 lines
12 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
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):
/// - an active-filter summary, pull-to-refresh, empty/loading states;
/// home owns the filter menu and connection notice;
/// - pinned section first (the sort already puts globalPinned/pinned rows on
/// top; a header makes the boundary visible);
/// - per row: flavor brand icon + title, trailing relative `updatedAt` and
/// an optional unread dot; a quiet second line has project left and a small status
/// indicator right. No preview, task progress, machine/path details or badges.
/// Disconnected titles/icons are secondary, without dimming attention;
/// - long-press context menu → pin (none/project/global) + archive with
/// optimistic store updates; failures land in an alert.
struct SessionListView: View {
@Environment(\.hapiTheme) private var theme
let model: SessionListModel
let onOpenSession: (String) -> Void
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) {
if let summary = model.filterSummary {
SessionFilterSummary(summary: summary, onClear: model.clearFilters)
}
}
.onChange(of: model.machineFilterIds, initial: true) { _, _ in
model.reconcileFilters()
}
.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)
}
}
.listSectionSeparator(.hidden, edges: .top)
}
if rows.count > pinnedCount {
// Headerless when nothing is pinned: an empty-string Section
// header still reserves a blank sticky band above the list
// (device feedback: "一片空白").
if pinnedCount > 0 {
Section(String(localized: "Sessions")) {
ForEach(rows.dropFirst(pinnedCount)) { row in
rowCell(row, now: now)
}
}
.listSectionSeparator(.hidden, edges: .top)
} else {
// Top edge hidden: a plain list otherwise draws a stray
// separator above the very first row (device feedback).
Section {
ForEach(rows) { row in
rowCell(row, now: now)
}
}
.listSectionSeparator(.hidden, edges: .top)
}
}
}
.listStyle(.plain)
// An explicit filter change starts at the top. SSE/count/name changes
// keep this identity (and the reading position), as does chat return.
.id(model.filters)
.accessibilityIdentifier("home.sessions")
.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)
.frame(maxWidth: .infinity, alignment: .leading)
// Plain buttons otherwise ignore the label's empty space.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
// Default separator color reads heavy against these rows; the theme
// divider is the WeChat-style faint hairline.
.listRowSeparatorTint(theme.divider)
.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
@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 {
@Environment(\.hapiTheme) private var theme
@ScaledMetric(relativeTo: .footnote) private var statusSlotSize: CGFloat = 16
let row: SessionRowUI
let now: Date
// The agent owns the leading column; one quiet line of secondary text
// keeps the row focused on the title rather than a stack of indicators.
private let textInset: CGFloat = 24
var body: some View {
VStack(alignment: .leading, spacing: 4) {
titleLine
detailLine
.padding(.leading, textInset)
}
.alignmentGuide(.listRowSeparatorLeading) { _ in textInset }
.accessibilityElement(children: .combine)
.accessibilityIdentifier("home.session.\(row.id)")
}
private var titleLine: some View {
HStack(spacing: 8) {
AgentFlavorIconView(flavor: row.flavor)
.foregroundStyle(row.summary.active ? theme.textPrimary : theme.textSecondary)
.opacity(row.summary.active ? 1 : 0.5)
Text(verbatim: row.title)
.font(.body)
.fontWeight(row.unread ? .semibold : .regular)
.foregroundStyle(row.summary.active ? theme.textPrimary : theme.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
HStack(spacing: 6) {
Text(verbatim: formatRelativeAge(now: now, thenEpochMs: row.summary.updatedAt))
.font(.caption2)
.foregroundStyle(theme.textSecondary)
.monospacedDigit()
.fixedSize()
// Unread dots always occupy the trailing edge. Read rows
// remove the slot so the timestamp itself aligns right.
if row.unread {
Circle()
.fill(theme.accent)
.frame(width: 8, height: 8)
.frame(width: 12)
.accessibilityLabel("Unread")
}
}
.fixedSize()
}
}
@ViewBuilder
private var detailLine: some View {
if row.project != nil || row.status != nil {
HStack(alignment: .center, spacing: 0) {
// Keep the same text line height when metadata is missing,
// so the symbol does not jump vertically at large text sizes.
Text(verbatim: row.project ?? " ")
.lineLimit(1)
.accessibilityHidden(row.project == nil)
Spacer(minLength: 12)
if let status = row.status {
statusIndicator(status)
}
}
.font(.footnote)
.foregroundStyle(theme.textSecondary)
}
}
private func statusIndicator(_ status: SessionRowStatus) -> some View {
Group {
if let symbol = status.symbolName {
Image(systemName: symbol)
.symbolVariant(.none)
.symbolRenderingMode(.monochrome)
.foregroundStyle(attentionColor)
} else {
ProgressView()
// Native small loading already scales with Dynamic Type;
// an extra scaleEffect would overflow the shared slot.
.controlSize(.small)
.tint(theme.textSecondary)
}
}
// One shared slot keeps attention symbols and loading aligned.
// Footnote is 13pt at the default size and scales for AX.
.frame(width: statusSlotSize, height: statusSlotSize)
.fixedSize()
.layoutPriority(1)
.accessibilityLabel(statusAccessibilityText(status))
}
// Darker amber keeps thin strokes visible on white without a filled
// symbol, background badge or animation competing with the session title.
private var attentionColor: Color {
theme.isDark ? theme.warning : Color(hex: 0x9A6700)
}
/// Keep the exact request count available to VoiceOver without adding
/// another visible number or badge to every waiting conversation.
private func statusAccessibilityText(_ status: SessionRowStatus) -> Text {
let title = Text(LocalizedStringKey(status.titleKey))
if let count = status.count, count > 1 {
return Text("\(title) · \(count) requests")
}
return title
}
}
// MARK: - Recent-directory chip (used by NewSessionView)
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 : [])
}
}