Skip to main content

Session Management

How LucidPal persists and navigates multiple chat sessions.

Data Model

Two types represent session data at different granularities:

// Lightweight — stored in index.json, loaded for the session list
struct ChatSessionMeta: Identifiable, Codable {
let id: UUID
var title: String
let createdAt: Date
var updatedAt: Date
var lastMessagePreview: String? // first 120 chars of last non-system message
var isPinned: Bool // pinned sessions float to the top of the list
}

// Full session — loaded on demand when opening a chat
struct ChatSession: Identifiable, Codable {
let id: UUID
var title: String
let createdAt: Date
var updatedAt: Date
var messages: [ChatMessage]

var templateID: String? // optional conversation template ID
var meta: ChatSessionMeta { get } // computed — no duplication
static func new(templateID: String? = nil) -> ChatSession // creates empty session with UUID
}

The split avoids loading all messages into memory just to render the session list. loadIndex() is called to populate the session list; loadSession(id:) is called lazily only when the user opens a specific chat.

Storage Layout

Documents/
└── sessions/
├── index.json ← [ChatSessionMeta] array (sorted newest first)
├── <uuid>.json ← Full ChatSession per conversation
└── <uuid>.json

System messages (role .system) are excluded from persistence — the system prompt is rebuilt on each launch from the current model and settings.

Save Path

save(_:) is non-blocking — it dispatches a Task.detached(priority: .utility) for the JSON encode + write and returns the task handle. The in-memory index is updated synchronously so the list reflects changes immediately.

@discardableResult
func save(_ session: ChatSession) -> Task<Void, Never> {
let task = Task.detached(priority: .utility) {
// encode + atomic write on background thread
}
updateIndex(with: session.meta) // synchronous index update
return task
}

SessionManager exposes a full-text search over all persisted messages:

func searchMessages(query: String) -> [(meta: ChatSessionMeta, snippet: String)]

The search is case-insensitive and scans message content across every session. Each result includes a centred snippet (≈60 characters of context around the first match) for display in the session list. The ViewModel layer debounces the query and calls this to power the session list search bar.

SessionListViewModel

SessionListViewModel is the @MainActor ObservableObject that drives the session list UI. It owns the in-memory [ChatSessionMeta] array and bridges between SessionManager and the SwiftUI views.

Responsibilities

ResponsibilityMethod(s)
Session CRUDcreateSession(templateID:), deleteSession(id:), renameSession(id:title:), refreshSessions()
PinningtogglePin(id:)
Session grouping & searchgroupedSessions(searchText:), filteredSessions(searchText:)
Siri routingscheduleSiriQuery(_:), siriNavigationMeta published property
Siri "Add Event" routingscheduleCreateEvent(_:), pendingEventCreation published property
Calendar event creationcreateCalendarEvent(title:start:end:isAllDay:location:notes:)
ChatViewModel factorymakeChatViewModel(for:initialQuery:startWithVoice:)
Calendar hero panel datanextUpcomingEvent(), todayEventCount()

groupedSessions(searchText:)

Returns [SessionGroup] for display in the session list. Groups are built from filteredSessions(searchText:) and ordered:

  1. Pinned — sessions with isPinned == true (omitted when empty)
  2. TodayupdatedAt is today
  3. YesterdayupdatedAt is yesterday
  4. This WeekupdatedAt within the last 7 days (exclusive of today and yesterday)
  5. Earlier — everything older

Empty groups are omitted. Within each group, sessions retain their sort order from filteredSessions.

When searchText is non-empty, filteredSessions runs a two-phase search:

  1. Title match — localizedCaseInsensitiveContains
  2. Content match — calls SessionManager.searchMessages(query:) for sessions not already matched by title; the matching message snippet replaces lastMessagePreview for display

pendingQueryBySessionID

var pendingQueryBySessionID: [UUID: String] = [:]

Set by scheduleSiriQuery(_:) when a Siri intent arrives. SessionListView creates a new session, stores the query here keyed by the new session's UUID, then navigates to it. ChatSessionContainer consumes the entry on init and auto-sends the query as the first message.

createCalendarEvent()

Delegates to CalendarServiceProtocol.createEvent(...) with the parameters supplied by CreateEventSheet. This is the write path for Siri "Add Event" intents — scheduleCreateEvent(_:) populates pendingEventCreation which triggers CreateEventSheet; the sheet calls createCalendarEvent() on confirmation.

NoOpChatHistoryManager

When ChatViewModel operates in session mode (i.e., a SessionManager is active), the old single-file ChatHistoryManager is replaced by NoOpChatHistoryManager:

/// No-op history manager — used when ChatViewModel operates in session mode.
/// Persistence is handled by SessionManager instead.
final class NoOpChatHistoryManager: ChatHistoryManagerProtocol {
func load() -> [ChatMessage] { [] }
func save(_ messages: [ChatMessage]) -> Task<Void, Never> { Task {} }
func clear() {}
}

This ensures the legacy chat_history.json single-file format is never written when sessions are enabled. The ChatHistoryManagerProtocol abstraction lets ChatViewModel remain unaware of which backing store is active.

Legacy Migration

On first launch after upgrade from the single-session version, SessionManager automatically migrates chat_history.json:

Documents/chat_history.json (legacy)

migrate() called in init

Creates a new ChatSession from the message array

Saves to sessions/<uuid>.json + updates index

Removes chat_history.json

Migration is a no-op if the legacy file doesn't exist.