RFC-005: Session Management
RFC-005: Session Management
Document Information
- RFC ID: RFC-005
- Related PRD: FEAT-005 (Session Management)
- Related Design: UI Design Spec (Navigation Drawer, Section 1 Chat Screen empty state)
- Related Architecture: RFC-000 (Overall Architecture)
- Depends On: RFC-002 (Agent Management), RFC-003 (Provider Management)
- Depended On By: RFC-001 (Chat Interaction)
- Created: 2026-02-27
- Last Updated: 2026-02-27
- Status: Draft
- Author: TBD
Overview
Background
Session Management provides the infrastructure for creating, resuming, renaming, and deleting conversation sessions. A session is the container for a conversation – it holds message history, the current Agent reference, and metadata like title and timestamps. Sessions are displayed in a Navigation Drawer sorted by last active time. This RFC covers session data persistence, lazy session creation, two-phase title generation (truncated + AI-generated), soft-delete with undo, batch deletion, and the session list UI in the drawer.
Goals
- Implement session data persistence (Room entity, DAO, repository) with soft-delete support
- Implement lazy session creation (no DB record until first message sent)
- Implement two-phase title generation (immediate truncated + async AI-generated)
- Implement lightweight model selection with availability verification for AI title generation
- Implement single-delete with swipe + undo Snackbar (~5 second window)
- Implement batch-delete with selection mode + undo Snackbar
- Implement session rename (manual title editing)
- Implement the Navigation Drawer session list UI
- Implement
updateAgentForSessions()for agent deletion fallback (from RFC-002) - Provide enough implementation detail for AI-assisted code generation
Non-Goals
- Message display and rendering in chat (RFC-001)
- Sending messages and streaming responses (RFC-001)
- Agent switching within a session (RFC-001 integrates AgentSelectorSheet from RFC-002)
- Session folders, tags, or groups
- Session search
- Session pinning
- Session export
- Session sharing
- Data sync / backup (FEAT-007)
Technical Design
Architecture Overview
+-----------------------------------------------------------------------+
| UI Layer |
| SessionDrawerContent SessionListViewModel RenameDialog |
| | | |
| v v |
| (Drawer composable) SessionListViewModel |
+-----------------------------------------------------------------------+
| Domain Layer |
| CreateSessionUseCase DeleteSessionUseCase BatchDeleteSessionsUseCase|
| GenerateTitleUseCase RenameSessionUseCase CleanupSoftDeletedUseCase |
| | |
| v |
| SessionRepository (interface) MessageRepository (interface) |
+-----------------------------------------------------------------------+
| Data Layer |
| SessionRepositoryImpl |
| | | | |
| v v v |
| SessionDao MessageDao ModelApiAdapterFactory |
| (for AI title generation) |
+-----------------------------------------------------------------------+
Core Components
- SessionRepositoryImpl
- Responsibility: Session CRUD, soft-delete/restore/hard-delete, agent fallback
- Dependencies: SessionDao, MessageDao
- Lazy Session Creation
- Responsibility: Session is not persisted until first message is sent
- Mechanism: ChatViewModel holds an in-memory “pending session” object;
CreateSessionUseCasepersists it when the first message is sent - See Data Flow section for details
- GenerateTitleUseCase
- Responsibility: Two-phase title generation
- Phase 1: Truncate first user message to ~50 chars at word boundary
- Phase 2: Send async request to lightweight model for a better title
- Lightweight model: Hardcoded mapping verified against available models in DB
- Soft-Delete Manager
- Responsibility: Mark sessions as deleted, schedule hard-delete after undo window
- Mechanism:
deleted_atfield in sessions table +viewModelScope.launchwith delay - Cleanup: On app startup, hard-delete any sessions with
deleted_at != null
- SessionDrawerContent
- Responsibility: Compose content for the Navigation Drawer showing session list
- Integrates: Swipe-to-delete, selection mode, rename dialog
Data Model
Domain Models
The Session model is updated from RFC-000 to add deletedAt and lastMessagePreview.
Session (Updated)
data class Session(
val id: String, // UUID
val title: String, // Display title
val currentAgentId: String, // Current agent for this session
val messageCount: Int, // Number of messages
val lastMessagePreview: String?, // Truncated last message text for list display
val isActive: Boolean, // Whether a request is in-flight
val deletedAt: Long?, // Soft-delete timestamp (null = not deleted)
val createdAt: Long,
val updatedAt: Long // Last activity timestamp
)
Changes from RFC-000:
- Added
lastMessagePreview: Truncated text of the last message, used for session list display in the drawer. Avoids joining on the messages table for list rendering. - Added
deletedAt: Nullable timestamp for soft-delete support. When non-null, the session is pending permanent deletion.
Room Entity
SessionEntity
@Entity(tableName = "sessions")
data class SessionEntity(
@PrimaryKey
val id: String,
val title: String,
@ColumnInfo(name = "current_agent_id")
val currentAgentId: String,
@ColumnInfo(name = "message_count")
val messageCount: Int,
@ColumnInfo(name = "last_message_preview")
val lastMessagePreview: String?,
@ColumnInfo(name = "is_active")
val isActive: Boolean,
@ColumnInfo(name = "deleted_at")
val deletedAt: Long?,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long
)
Database Schema
CREATE TABLE sessions (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
current_agent_id TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0,
last_message_preview TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_updated_at ON sessions(updated_at);
CREATE INDEX idx_sessions_deleted_at ON sessions(deleted_at);
Changes from RFC-000 schema:
- Added
last_message_preview TEXT - Added
deleted_at INTEGER(nullable) - Added indexes for
updated_at(sort) anddeleted_at(filter)
Entity-Domain Mappers
// SessionMapper.kt
fun SessionEntity.toDomain(): Session = Session(
id = id,
title = title,
currentAgentId = currentAgentId,
messageCount = messageCount,
lastMessagePreview = lastMessagePreview,
isActive = isActive,
deletedAt = deletedAt,
createdAt = createdAt,
updatedAt = updatedAt
)
fun Session.toEntity(): SessionEntity = SessionEntity(
id = id,
title = title,
currentAgentId = currentAgentId,
messageCount = messageCount,
lastMessagePreview = lastMessagePreview,
isActive = isActive,
deletedAt = deletedAt,
createdAt = createdAt,
updatedAt = updatedAt
)
DAO Interface
SessionDao
@Dao
interface SessionDao {
/**
* Get all non-deleted sessions, sorted by updated_at descending (most recent first).
*/
@Query("SELECT * FROM sessions WHERE deleted_at IS NULL ORDER BY updated_at DESC")
fun getActiveSessions(): Flow<List<SessionEntity>>
@Query("SELECT * FROM sessions WHERE id = :id")
suspend fun getSessionById(id: String): SessionEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSession(session: SessionEntity)
@Update
suspend fun updateSession(session: SessionEntity)
/**
* Soft delete: set deleted_at timestamp.
*/
@Query("UPDATE sessions SET deleted_at = :deletedAt WHERE id = :id")
suspend fun softDeleteSession(id: String, deletedAt: Long)
/**
* Soft delete multiple sessions.
*/
@Query("UPDATE sessions SET deleted_at = :deletedAt WHERE id IN (:ids)")
suspend fun softDeleteSessions(ids: List<String>, deletedAt: Long)
/**
* Restore a soft-deleted session.
*/
@Query("UPDATE sessions SET deleted_at = NULL WHERE id = :id")
suspend fun restoreSession(id: String)
/**
* Restore multiple soft-deleted sessions.
*/
@Query("UPDATE sessions SET deleted_at = NULL WHERE id IN (:ids)")
suspend fun restoreSessions(ids: List<String>)
/**
* Hard delete a single session. CASCADE deletes its messages.
*/
@Query("DELETE FROM sessions WHERE id = :id")
suspend fun hardDeleteSession(id: String)
/**
* Hard delete all soft-deleted sessions (cleanup on app startup).
*/
@Query("DELETE FROM sessions WHERE deleted_at IS NOT NULL")
suspend fun hardDeleteAllSoftDeleted()
/**
* Update the current agent for all sessions referencing a specific agent.
* Used by DeleteAgentUseCase (RFC-002) for fallback to General Assistant.
*/
@Query("UPDATE sessions SET current_agent_id = :newAgentId WHERE current_agent_id = :oldAgentId")
suspend fun updateAgentForSessions(oldAgentId: String, newAgentId: String)
/**
* Update session title.
*/
@Query("UPDATE sessions SET title = :title, updated_at = :updatedAt WHERE id = :id")
suspend fun updateTitle(id: String, title: String, updatedAt: Long)
/**
* Set the AI-generated title.
*/
@Query("UPDATE sessions SET title = :title, updated_at = :updatedAt WHERE id = :id")
suspend fun setGeneratedTitle(id: String, title: String, updatedAt: Long)
/**
* Update message count and last message preview.
*/
@Query("UPDATE sessions SET message_count = :count, last_message_preview = :preview, updated_at = :updatedAt WHERE id = :id")
suspend fun updateMessageStats(id: String, count: Int, preview: String?, updatedAt: Long)
/**
* Set the is_active flag (request in-flight).
*/
@Query("UPDATE sessions SET is_active = :isActive WHERE id = :id")
suspend fun setActive(id: String, isActive: Boolean)
/**
* Update current agent for a specific session.
*/
@Query("UPDATE sessions SET current_agent_id = :agentId, updated_at = :updatedAt WHERE id = :id")
suspend fun updateCurrentAgent(id: String, agentId: String, updatedAt: Long)
/**
* Get count of all sessions (for statistics).
*/
@Query("SELECT COUNT(*) FROM sessions WHERE deleted_at IS NULL")
suspend fun getSessionCount(): Int
}
Repository Implementation
SessionRepository Interface
Updated from RFC-000 with soft-delete, restore, batch operations, and agent fallback.
interface SessionRepository {
/**
* Observe all non-deleted sessions, sorted by updated_at descending.
*/
fun getAllSessions(): Flow<List<Session>>
suspend fun getSessionById(id: String): Session?
/**
* Create a new session. Called when the first message is sent (lazy creation).
*/
suspend fun createSession(session: Session): Session
suspend fun updateSession(session: Session)
/**
* Soft-delete a session. Sets deleted_at to current time.
* The session is hidden from the list but can be restored.
*/
suspend fun softDeleteSession(id: String)
/**
* Soft-delete multiple sessions.
*/
suspend fun softDeleteSessions(ids: List<String>)
/**
* Restore a soft-deleted session (undo).
*/
suspend fun restoreSession(id: String)
/**
* Restore multiple soft-deleted sessions (batch undo).
*/
suspend fun restoreSessions(ids: List<String>)
/**
* Hard-delete a session and all its messages.
*/
suspend fun hardDeleteSession(id: String)
/**
* Hard-delete all soft-deleted sessions. Called on app startup.
*/
suspend fun hardDeleteAllSoftDeleted()
/**
* Update all sessions referencing oldAgentId to use newAgentId.
* Used by DeleteAgentUseCase (RFC-002).
*/
suspend fun updateAgentForSessions(oldAgentId: String, newAgentId: String)
/**
* Update session title.
*/
suspend fun updateTitle(id: String, title: String)
/**
* Set the AI-generated title and mark as generated.
*/
suspend fun setGeneratedTitle(id: String, title: String)
/**
* Update message stats (count + last message preview).
*/
suspend fun updateMessageStats(id: String, count: Int, preview: String?)
/**
* Set session active/inactive (request in-flight).
*/
suspend fun setActive(id: String, isActive: Boolean)
/**
* Switch the current agent for a session.
*/
suspend fun updateCurrentAgent(id: String, agentId: String)
}
SessionRepositoryImpl
class SessionRepositoryImpl(
private val sessionDao: SessionDao
) : SessionRepository {
override fun getAllSessions(): Flow<List<Session>> {
return sessionDao.getActiveSessions().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getSessionById(id: String): Session? {
return sessionDao.getSessionById(id)?.toDomain()
}
override suspend fun createSession(session: Session): Session {
val now = System.currentTimeMillis()
val newSession = session.copy(
id = if (session.id.isBlank()) java.util.UUID.randomUUID().toString() else session.id,
createdAt = now,
updatedAt = now
)
sessionDao.insertSession(newSession.toEntity())
return newSession
}
override suspend fun updateSession(session: Session) {
sessionDao.updateSession(session.toEntity())
}
override suspend fun softDeleteSession(id: String) {
sessionDao.softDeleteSession(id, System.currentTimeMillis())
}
override suspend fun softDeleteSessions(ids: List<String>) {
sessionDao.softDeleteSessions(ids, System.currentTimeMillis())
}
override suspend fun restoreSession(id: String) {
sessionDao.restoreSession(id)
}
override suspend fun restoreSessions(ids: List<String>) {
sessionDao.restoreSessions(ids)
}
override suspend fun hardDeleteSession(id: String) {
sessionDao.hardDeleteSession(id)
// Messages are CASCADE deleted via foreign key
}
override suspend fun hardDeleteAllSoftDeleted() {
sessionDao.hardDeleteAllSoftDeleted()
}
override suspend fun updateAgentForSessions(oldAgentId: String, newAgentId: String) {
sessionDao.updateAgentForSessions(oldAgentId, newAgentId)
}
override suspend fun updateTitle(id: String, title: String) {
sessionDao.updateTitle(id, title, System.currentTimeMillis())
}
override suspend fun setGeneratedTitle(id: String, title: String) {
sessionDao.setGeneratedTitle(id, title, System.currentTimeMillis())
}
override suspend fun updateMessageStats(id: String, count: Int, preview: String?) {
sessionDao.updateMessageStats(id, count, preview, System.currentTimeMillis())
}
override suspend fun setActive(id: String, isActive: Boolean) {
sessionDao.setActive(id, isActive)
}
override suspend fun updateCurrentAgent(id: String, agentId: String) {
sessionDao.updateCurrentAgent(id, agentId, System.currentTimeMillis())
}
}
Use Cases
CreateSessionUseCase
/**
* Creates a new session in the database. Called when the first message is sent.
* Implements lazy session creation: the session does not exist in DB until this is called.
*
* Located in: feature/session/usecase/CreateSessionUseCase.kt
*/
class CreateSessionUseCase(
private val sessionRepository: SessionRepository
) {
suspend operator fun invoke(
agentId: String = AgentConstants.GENERAL_ASSISTANT_ID
): Session {
val session = Session(
id = "", // Generated by repository
title = "New Conversation", // Placeholder, replaced by Phase 1 title
currentAgentId = agentId,
messageCount = 0,
lastMessagePreview = null,
isActive = false,
deletedAt = null,
createdAt = 0, // Set by repository
updatedAt = 0 // Set by repository
)
return sessionRepository.createSession(session)
}
}
DeleteSessionUseCase
/**
* Soft-deletes a single session. Returns the session ID so undo can reference it.
*
* Located in: feature/session/usecase/DeleteSessionUseCase.kt
*/
class DeleteSessionUseCase(
private val sessionRepository: SessionRepository
) {
suspend operator fun invoke(sessionId: String): AppResult<Unit> {
val session = sessionRepository.getSessionById(sessionId)
?: return AppResult.Error(
message = "Session not found.",
code = ErrorCode.VALIDATION_ERROR
)
sessionRepository.softDeleteSession(sessionId)
return AppResult.Success(Unit)
}
}
BatchDeleteSessionsUseCase
/**
* Soft-deletes multiple sessions at once.
*
* Located in: feature/session/usecase/BatchDeleteSessionsUseCase.kt
*/
class BatchDeleteSessionsUseCase(
private val sessionRepository: SessionRepository
) {
suspend operator fun invoke(sessionIds: List<String>): AppResult<Unit> {
if (sessionIds.isEmpty()) {
return AppResult.Error(
message = "No sessions selected.",
code = ErrorCode.VALIDATION_ERROR
)
}
sessionRepository.softDeleteSessions(sessionIds)
return AppResult.Success(Unit)
}
}
RenameSessionUseCase
/**
* Renames a session title. Validates that the title is non-empty.
*
* Located in: feature/session/usecase/RenameSessionUseCase.kt
*/
class RenameSessionUseCase(
private val sessionRepository: SessionRepository
) {
suspend operator fun invoke(sessionId: String, newTitle: String): AppResult<Unit> {
val trimmed = newTitle.trim()
if (trimmed.isBlank()) {
return AppResult.Error(
message = "Session title cannot be empty.",
code = ErrorCode.VALIDATION_ERROR
)
}
if (trimmed.length > 200) {
return AppResult.Error(
message = "Session title is too long (max 200 characters).",
code = ErrorCode.VALIDATION_ERROR
)
}
sessionRepository.updateTitle(sessionId, trimmed)
return AppResult.Success(Unit)
}
}
GenerateTitleUseCase
/**
* Two-phase title generation for a session.
*
* Phase 1: Immediate truncated title from first user message.
* Phase 2: Async AI-generated title using a lightweight model.
*
* Located in: feature/session/usecase/GenerateTitleUseCase.kt
*/
class GenerateTitleUseCase(
private val sessionRepository: SessionRepository,
private val providerRepository: ProviderRepository,
private val apiKeyStorage: ApiKeyStorage,
private val adapterFactory: ModelApiAdapterFactory
) {
companion object {
private const val TRUNCATED_TITLE_MAX_LENGTH = 50
private const val AI_RESPONSE_CONTEXT_MAX_LENGTH = 200
/**
* Hardcoded lightweight model IDs for each provider type.
* These are verified against the DB before use.
*/
private val LIGHTWEIGHT_MODELS = mapOf(
ProviderType.OPENAI to "gpt-4o-mini",
ProviderType.ANTHROPIC to "claude-haiku-4-20250414",
ProviderType.GEMINI to "gemini-2.0-flash"
)
}
/**
* Phase 1: Generate a truncated title from the first user message.
* Called immediately when the first message is sent.
* This is synchronous and instant.
*/
fun generateTruncatedTitle(firstMessage: String): String {
val trimmed = firstMessage.trim()
if (trimmed.length <= TRUNCATED_TITLE_MAX_LENGTH) {
return trimmed
}
// Truncate at word boundary
val truncated = trimmed.substring(0, TRUNCATED_TITLE_MAX_LENGTH)
val lastSpace = truncated.lastIndexOf(' ')
return if (lastSpace > TRUNCATED_TITLE_MAX_LENGTH / 2) {
// Found a reasonable word boundary
truncated.substring(0, lastSpace) + "..."
} else {
// No good word boundary, just truncate
truncated + "..."
}
}
/**
* Phase 2: Generate an AI-powered title using a lightweight model.
* Called asynchronously after the first AI response is received.
*
* @param sessionId The session to update
* @param firstUserMessage The user's first message
* @param firstAiResponse The AI's first response text
* @param currentModelId The model used for the conversation
* @param currentProviderId The provider used for the conversation
*/
suspend fun generateAiTitle(
sessionId: String,
firstUserMessage: String,
firstAiResponse: String,
currentModelId: String,
currentProviderId: String
) {
try {
// Resolve provider and model for title generation
val provider = providerRepository.getProviderById(currentProviderId) ?: return
val apiKey = apiKeyStorage.getApiKey(currentProviderId) ?: return
val modelId = resolveLightweightModel(provider, currentModelId)
// Build the title generation prompt
val truncatedResponse = if (firstAiResponse.length > AI_RESPONSE_CONTEXT_MAX_LENGTH) {
firstAiResponse.substring(0, AI_RESPONSE_CONTEXT_MAX_LENGTH) + "..."
} else {
firstAiResponse
}
val prompt = buildTitlePrompt(firstUserMessage, truncatedResponse)
// Send request via the appropriate adapter
val adapter = adapterFactory.getAdapter(provider.type)
val titleResult = adapter.generateSimpleCompletion(
apiBaseUrl = provider.apiBaseUrl,
apiKey = apiKey,
modelId = modelId,
prompt = prompt,
maxTokens = 30
)
when (titleResult) {
is AppResult.Success -> {
val generatedTitle = titleResult.data
.trim()
.removeSurrounding("\"") // Remove quotes if model wraps in them
.take(200) // Safety limit
if (generatedTitle.isNotBlank()) {
sessionRepository.setGeneratedTitle(sessionId, generatedTitle)
}
}
is AppResult.Error -> {
// Silently fail -- keep the truncated title
// No retry to avoid unnecessary API costs
}
}
} catch (e: Exception) {
// Silently fail
}
}
/**
* Resolve which model to use for title generation.
*
* Strategy:
* 1. Look up the hardcoded lightweight model for this provider type
* 2. Check if that model actually exists in the DB for this provider
* 3. If it exists, use it; otherwise fall back to the current conversation model
*/
private suspend fun resolveLightweightModel(
provider: Provider,
currentModelId: String
): String {
val lightweightId = LIGHTWEIGHT_MODELS[provider.type] ?: return currentModelId
// Verify the lightweight model actually exists for this provider
val models = providerRepository.getModelsForProvider(provider.id)
val exists = models.any { it.id == lightweightId }
return if (exists) lightweightId else currentModelId
}
private fun buildTitlePrompt(userMessage: String, aiResponse: String): String {
return """Generate a short title (5-10 words) for this conversation. Return only the title text, no quotes or extra formatting.
User: $userMessage
Assistant: $aiResponse"""
}
}
ModelApiAdapter Extension
The generateSimpleCompletion method is a new addition to the ModelApiAdapter interface for non-streaming single-response requests. It is used by title generation and potentially other simple API calls.
// Addition to ModelApiAdapter interface (data/remote/adapter/ModelApiAdapter.kt)
interface ModelApiAdapter {
// ... existing methods from RFC-003 ...
/**
* Send a simple (non-streaming) chat completion request.
* Used for lightweight tasks like title generation.
*
* IMPLEMENTATION: In RFC-005 for title generation.
* Full streaming implementation in RFC-001.
*
* @param prompt The user message to send
* @param maxTokens Maximum tokens in the response
* @return The assistant's text response
*/
suspend fun generateSimpleCompletion(
apiBaseUrl: String,
apiKey: String,
modelId: String,
prompt: String,
maxTokens: Int = 100
): AppResult<String>
}
OpenAI Implementation
// In OpenAiAdapter
override suspend fun generateSimpleCompletion(
apiBaseUrl: String,
apiKey: String,
modelId: String,
prompt: String,
maxTokens: Int
): AppResult<String> = withContext(Dispatchers.IO) {
try {
val requestBody = Json.encodeToString(
mapOf(
"model" to modelId,
"messages" to listOf(
mapOf("role" to "user", "content" to prompt)
),
"max_tokens" to maxTokens
)
)
val request = Request.Builder()
.url("${apiBaseUrl.trimEnd('/')}/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: return@withContext AppResult.Error(
message = "Empty response", code = ErrorCode.PROVIDER_ERROR
)
// Parse: response.choices[0].message.content
val json = Json.parseToJsonElement(body).jsonObject
val content = json["choices"]?.jsonArray?.get(0)
?.jsonObject?.get("message")
?.jsonObject?.get("content")
?.jsonPrimitive?.content
?: return@withContext AppResult.Error(
message = "Unexpected response format", code = ErrorCode.PROVIDER_ERROR
)
AppResult.Success(content)
} else {
AppResult.Error(
message = "API error: ${response.code}",
code = ErrorCode.PROVIDER_ERROR
)
}
} catch (e: Exception) {
AppResult.Error(message = "Request failed: ${e.message}", code = ErrorCode.UNKNOWN, exception = e)
}
}
Anthropic Implementation
// In AnthropicAdapter
override suspend fun generateSimpleCompletion(
apiBaseUrl: String,
apiKey: String,
modelId: String,
prompt: String,
maxTokens: Int
): AppResult<String> = withContext(Dispatchers.IO) {
try {
val requestBody = Json.encodeToString(
mapOf(
"model" to modelId,
"messages" to listOf(
mapOf("role" to "user", "content" to prompt)
),
"max_tokens" to maxTokens
)
)
val request = Request.Builder()
.url("${apiBaseUrl.trimEnd('/')}/messages")
.addHeader("x-api-key", apiKey)
.addHeader("anthropic-version", ANTHROPIC_VERSION)
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: return@withContext AppResult.Error(
message = "Empty response", code = ErrorCode.PROVIDER_ERROR
)
// Parse: response.content[0].text
val json = Json.parseToJsonElement(body).jsonObject
val content = json["content"]?.jsonArray?.get(0)
?.jsonObject?.get("text")
?.jsonPrimitive?.content
?: return@withContext AppResult.Error(
message = "Unexpected response format", code = ErrorCode.PROVIDER_ERROR
)
AppResult.Success(content)
} else {
AppResult.Error(
message = "API error: ${response.code}",
code = ErrorCode.PROVIDER_ERROR
)
}
} catch (e: Exception) {
AppResult.Error(message = "Request failed: ${e.message}", code = ErrorCode.UNKNOWN, exception = e)
}
}
Gemini Implementation
// In GeminiAdapter
override suspend fun generateSimpleCompletion(
apiBaseUrl: String,
apiKey: String,
modelId: String,
prompt: String,
maxTokens: Int
): AppResult<String> = withContext(Dispatchers.IO) {
try {
val requestBody = Json.encodeToString(
mapOf(
"contents" to listOf(
mapOf(
"parts" to listOf(
mapOf("text" to prompt)
)
)
),
"generationConfig" to mapOf(
"maxOutputTokens" to maxTokens
)
)
)
val url = "${apiBaseUrl.trimEnd('/')}/models/$modelId:generateContent?key=$apiKey"
val request = Request.Builder()
.url(url)
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: return@withContext AppResult.Error(
message = "Empty response", code = ErrorCode.PROVIDER_ERROR
)
// Parse: response.candidates[0].content.parts[0].text
val json = Json.parseToJsonElement(body).jsonObject
val content = json["candidates"]?.jsonArray?.get(0)
?.jsonObject?.get("content")
?.jsonObject?.get("parts")
?.jsonArray?.get(0)
?.jsonObject?.get("text")
?.jsonPrimitive?.content
?: return@withContext AppResult.Error(
message = "Unexpected response format", code = ErrorCode.PROVIDER_ERROR
)
AppResult.Success(content)
} else {
AppResult.Error(
message = "API error: ${response.code}",
code = ErrorCode.PROVIDER_ERROR
)
}
} catch (e: Exception) {
AppResult.Error(message = "Request failed: ${e.message}", code = ErrorCode.UNKNOWN, exception = e)
}
}
CleanupSoftDeletedUseCase
/**
* Hard-deletes all soft-deleted sessions. Called on app startup.
*
* Located in: feature/session/usecase/CleanupSoftDeletedUseCase.kt
*/
class CleanupSoftDeletedUseCase(
private val sessionRepository: SessionRepository
) {
suspend operator fun invoke() {
sessionRepository.hardDeleteAllSoftDeleted()
}
}
This is called in the Application class during startup:
// In OneclawApplication.kt
class OneclawApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin { /* ... */ }
// Cleanup any sessions that were soft-deleted but not hard-deleted
// (e.g., app was killed during undo window)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
val cleanup: CleanupSoftDeletedUseCase = get()
cleanup()
}
}
}
UI Layer
Navigation
The session list is not a standalone screen. It lives inside the Navigation Drawer of the main chat screen. The drawer is opened by tapping the hamburger icon.
// No new routes needed for session list -- it's part of the drawer.
// Session rename is done via a dialog, not a separate route.
UI State Definitions
SessionListUiState
data class SessionListUiState(
val sessions: List<SessionListItem> = emptyList(),
val isLoading: Boolean = true,
// Selection mode
val isSelectionMode: Boolean = false,
val selectedSessionIds: Set<String> = emptySet(),
// Undo state
val undoState: UndoState? = null,
// Rename dialog
val renameDialog: RenameDialogState? = null,
// General
val errorMessage: String? = null
)
data class SessionListItem(
val id: String,
val title: String,
val agentName: String, // Resolved from agent ID
val lastMessagePreview: String?,
val relativeTime: String, // "2 min ago", "Yesterday", "Feb 20"
val isActive: Boolean, // Request in-flight
val isSelected: Boolean // In selection mode
)
data class UndoState(
val deletedSessionIds: List<String>,
val message: String // "Session deleted" or "3 sessions deleted"
)
data class RenameDialogState(
val sessionId: String,
val currentTitle: String
)
SessionListViewModel
class SessionListViewModel(
private val sessionRepository: SessionRepository,
private val agentRepository: AgentRepository,
private val deleteSessionUseCase: DeleteSessionUseCase,
private val batchDeleteSessionsUseCase: BatchDeleteSessionsUseCase,
private val renameSessionUseCase: RenameSessionUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(SessionListUiState())
val uiState: StateFlow<SessionListUiState> = _uiState.asStateFlow()
// Job for the undo timeout -- cancelled if user taps undo
private var undoJob: Job? = null
// Cache of agent names for display
private val agentNameCache = mutableMapOf<String, String>()
init {
loadSessions()
loadAgentNames()
}
private fun loadSessions() {
viewModelScope.launch {
sessionRepository.getAllSessions().collect { sessions ->
val items = sessions.map { session ->
SessionListItem(
id = session.id,
title = session.title,
agentName = agentNameCache[session.currentAgentId] ?: "Agent",
lastMessagePreview = session.lastMessagePreview,
relativeTime = formatRelativeTime(session.updatedAt),
isActive = session.isActive,
isSelected = session.id in _uiState.value.selectedSessionIds
)
}
_uiState.update { it.copy(sessions = items, isLoading = false) }
}
}
}
private fun loadAgentNames() {
viewModelScope.launch {
agentRepository.getAllAgents().collect { agents ->
agentNameCache.clear()
agents.forEach { agentNameCache[it.id] = it.name }
// Re-emit sessions to update agent names
_uiState.update { state ->
state.copy(
sessions = state.sessions.map { item ->
item.copy(agentName = agentNameCache[item.id] ?: item.agentName)
}
)
}
}
}
}
// --- Delete ---
fun deleteSession(sessionId: String) {
viewModelScope.launch {
when (deleteSessionUseCase(sessionId)) {
is AppResult.Success -> {
startUndoTimer(listOf(sessionId), "Session deleted")
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = "Failed to delete session.") }
}
}
}
}
fun deleteSelectedSessions() {
val ids = _uiState.value.selectedSessionIds.toList()
if (ids.isEmpty()) return
viewModelScope.launch {
when (batchDeleteSessionsUseCase(ids)) {
is AppResult.Success -> {
exitSelectionMode()
startUndoTimer(ids, "${ids.size} sessions deleted")
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = "Failed to delete sessions.") }
}
}
}
}
private fun startUndoTimer(deletedIds: List<String>, message: String) {
// Cancel any existing undo timer
undoJob?.cancel()
_uiState.update {
it.copy(undoState = UndoState(deletedSessionIds = deletedIds, message = message))
}
undoJob = viewModelScope.launch {
delay(5_000) // 5 second undo window
// Undo window expired -- hard delete
deletedIds.forEach { id ->
sessionRepository.hardDeleteSession(id)
}
_uiState.update { it.copy(undoState = null) }
}
}
fun undoDelete() {
val undoState = _uiState.value.undoState ?: return
undoJob?.cancel()
viewModelScope.launch {
if (undoState.deletedSessionIds.size == 1) {
sessionRepository.restoreSession(undoState.deletedSessionIds.first())
} else {
sessionRepository.restoreSessions(undoState.deletedSessionIds)
}
_uiState.update { it.copy(undoState = null) }
}
}
// --- Selection mode ---
fun enterSelectionMode(sessionId: String) {
_uiState.update {
it.copy(
isSelectionMode = true,
selectedSessionIds = setOf(sessionId)
)
}
}
fun toggleSelection(sessionId: String) {
_uiState.update { state ->
val newSelection = if (sessionId in state.selectedSessionIds) {
state.selectedSessionIds - sessionId
} else {
state.selectedSessionIds + sessionId
}
// Exit selection mode if nothing selected
if (newSelection.isEmpty()) {
state.copy(isSelectionMode = false, selectedSessionIds = emptySet())
} else {
state.copy(selectedSessionIds = newSelection)
}
}
}
fun selectAll() {
_uiState.update { state ->
state.copy(selectedSessionIds = state.sessions.map { it.id }.toSet())
}
}
fun exitSelectionMode() {
_uiState.update { it.copy(isSelectionMode = false, selectedSessionIds = emptySet()) }
}
// --- Rename ---
fun showRenameDialog(sessionId: String, currentTitle: String) {
_uiState.update {
it.copy(renameDialog = RenameDialogState(sessionId, currentTitle))
}
}
fun dismissRenameDialog() {
_uiState.update { it.copy(renameDialog = null) }
}
fun renameSession(sessionId: String, newTitle: String) {
viewModelScope.launch {
when (renameSessionUseCase(sessionId, newTitle)) {
is AppResult.Success -> {
_uiState.update { it.copy(renameDialog = null) }
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = "Failed to rename session.") }
}
}
}
}
// --- Helpers ---
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
/**
* Format a timestamp into a relative time string.
*/
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
return when {
diff < 60_000 -> "Just now"
diff < 3_600_000 -> "${diff / 60_000} min ago"
diff < 86_400_000 -> {
val hours = diff / 3_600_000
if (hours == 1L) "1 hour ago" else "$hours hours ago"
}
diff < 172_800_000 -> "Yesterday"
else -> {
val sdf = java.text.SimpleDateFormat("MMM d", java.util.Locale.getDefault())
sdf.format(java.util.Date(timestamp))
}
}
}
}
Screen Composable Outlines
SessionDrawerContent
The session list is rendered as the content of a ModalNavigationDrawer. It is part of the chat screen layout.
/**
* Content of the Navigation Drawer showing the session list.
* Located in: feature/session/SessionDrawerContent.kt
*/
@Composable
fun SessionDrawerContent(
viewModel: SessionListViewModel,
onNewConversation: () -> Unit,
onSessionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = modifier.fillMaxHeight()) {
// "New conversation" button at top
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onNewConversation() }
.padding(16.dp),
tonalElevation = 1.dp,
shape = MaterialTheme.shapes.medium
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
Icon(Icons.Default.Add, contentDescription = "New conversation")
Spacer(modifier = Modifier.width(12.dp))
Text("New conversation", style = MaterialTheme.typography.bodyLarge)
}
}
// Selection mode toolbar
if (uiState.isSelectionMode) {
SelectionToolbar(
selectedCount = uiState.selectedSessionIds.size,
onSelectAll = { viewModel.selectAll() },
onDelete = { viewModel.deleteSelectedSessions() },
onCancel = { viewModel.exitSelectionMode() }
)
}
// Session list
if (uiState.isLoading) {
CenteredLoadingIndicator()
} else if (uiState.sessions.isEmpty()) {
EmptySessionsMessage()
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
items(
items = uiState.sessions,
key = { it.id }
) { session ->
SwipeToDismissItem(
onDismiss = { viewModel.deleteSession(session.id) },
enabled = !uiState.isSelectionMode
) {
SessionListItemRow(
item = session,
isSelectionMode = uiState.isSelectionMode,
onClick = {
if (uiState.isSelectionMode) {
viewModel.toggleSelection(session.id)
} else {
onSessionClick(session.id)
}
},
onLongClick = {
if (!uiState.isSelectionMode) {
viewModel.enterSelectionMode(session.id)
}
},
onRename = {
viewModel.showRenameDialog(session.id, session.title)
}
)
}
}
}
}
}
// Rename dialog
uiState.renameDialog?.let { renameState ->
RenameSessionDialog(
currentTitle = renameState.currentTitle,
onConfirm = { newTitle -> viewModel.renameSession(renameState.sessionId, newTitle) },
onDismiss = { viewModel.dismissRenameDialog() }
)
}
}
Integration with Chat Screen
/**
* In ChatScreen (RFC-001), the drawer wraps the chat content:
*/
@Composable
fun ChatScreen(/* ... */) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
SessionDrawerContent(
viewModel = sessionListViewModel,
onNewConversation = { /* create new session */ },
onSessionClick = { sessionId -> /* navigate to session */ }
)
}
}
) {
// Chat content (messages, input, top bar with hamburger)
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { /* open drawer */ }) {
Icon(Icons.Default.Menu, "Menu")
}
},
// ...
)
}
) {
// Chat messages and input
}
}
// Undo Snackbar (shown at bottom of chat screen, not inside drawer)
val undoState = sessionListViewModel.uiState.collectAsStateWithLifecycle().value.undoState
if (undoState != null) {
LaunchedEffect(undoState) {
val result = snackbarHostState.showSnackbar(
message = undoState.message,
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
sessionListViewModel.undoDelete()
}
}
}
}
SessionListItemRow
@Composable
fun SessionListItemRow(
item: SessionListItem,
isSelectionMode: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onRename: () -> Unit
) {
ListItem(
headlineContent = {
Text(
text = item.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge
)
},
supportingContent = {
if (item.lastMessagePreview != null) {
Text(
text = item.lastMessagePreview,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = {
Column(horizontalAlignment = Alignment.End) {
Text(
text = item.relativeTime,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// Agent badge
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = item.agentName,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
},
leadingContent = if (isSelectionMode) {
{
Checkbox(
checked = item.isSelected,
onCheckedChange = { onClick() }
)
}
} else if (item.isActive) {
{
// Pulsing dot indicator for active sessions
ActiveIndicatorDot()
}
} else null,
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
)
}
RenameSessionDialog
@Composable
fun RenameSessionDialog(
currentTitle: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var title by remember { mutableStateOf(currentTitle) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename Session") },
text = {
OutlinedTextField(
value = title,
onValueChange = { title = it },
singleLine = true,
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(title) },
enabled = title.isNotBlank()
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
Koin Dependency Injection
// Additions to existing Koin modules
// DatabaseModule.kt
val databaseModule = module {
// ... existing database setup ...
single { get<AppDatabase>().sessionDao() }
single { get<AppDatabase>().messageDao() }
// ... other DAOs ...
}
// RepositoryModule.kt
val repositoryModule = module {
single<SessionRepository> { SessionRepositoryImpl(get()) } // SessionDao
// ... other repositories ...
}
// FeatureModule.kt -- Session feature
val featureModule = module {
// Session Use Cases
factory { CreateSessionUseCase(get()) } // SessionRepository
factory { DeleteSessionUseCase(get()) } // SessionRepository
factory { BatchDeleteSessionsUseCase(get()) } // SessionRepository
factory { RenameSessionUseCase(get()) } // SessionRepository
factory { GenerateTitleUseCase(get(), get(), get(), get()) } // SessionRepository, ProviderRepository, ApiKeyStorage, ModelApiAdapterFactory
factory { CleanupSoftDeletedUseCase(get()) } // SessionRepository
// Session ViewModel
viewModel { SessionListViewModel(get(), get(), get(), get(), get()) }
// SessionRepository, AgentRepository, DeleteSessionUseCase,
// BatchDeleteSessionsUseCase, RenameSessionUseCase
}
Data Flow Examples
Flow 1: Lazy Session Creation + Title Generation
1. User opens app -> empty chat screen, no session in DB yet
-> ChatViewModel holds pendingSession = null
2. User types "Help me write a Python script to parse CSV files" and taps send
-> ChatViewModel: session does not exist yet
-> CreateSessionUseCase(agentId = "agent-general-assistant")
-> Creates Session in DB with title = "New Conversation"
-> Returns session with generated ID
-> ChatViewModel stores session ID
3. Immediately after session creation:
-> GenerateTitleUseCase.generateTruncatedTitle("Help me write a Python script to parse CSV files")
-> Truncates to "Help me write a Python script to parse..." (Phase 1)
-> SessionRepository.updateTitle(sessionId, truncatedTitle)
-> Session list in drawer shows: "Help me write a Python script to parse..."
4. Message is sent to AI model, streaming response arrives...
5. After first AI response is complete:
-> GenerateTitleUseCase.generateAiTitle(
sessionId = sessionId,
firstUserMessage = "Help me write a Python script to parse CSV files",
firstAiResponse = "I'll help you write a Python script...",
currentModelId = "gpt-4o",
currentProviderId = "provider-openai"
)
-> resolveLightweightModel():
-> LIGHTWEIGHT_MODELS[OPENAI] = "gpt-4o-mini"
-> Check: does "gpt-4o-mini" exist in models table for "provider-openai"? -> Yes
-> Use "gpt-4o-mini"
-> Build prompt and send to OpenAI
-> Response: "Python CSV Parser Script"
-> SessionRepository.setGeneratedTitle(sessionId, "Python CSV Parser Script")
6. Session list updates: title now shows "Python CSV Parser Script"
Flow 2: Delete with Undo
1. User opens drawer, sees session list
2. User swipes left on "Python CSV Parser Script"
-> SessionListViewModel.deleteSession(sessionId)
-> DeleteSessionUseCase: softDeleteSession(sessionId)
-> DB: UPDATE sessions SET deleted_at = now WHERE id = sessionId
-> Session disappears from list (Flow filters out deleted_at IS NOT NULL)
-> startUndoTimer(5 seconds)
-> Snackbar appears: "Session deleted [Undo]"
3a. User taps "Undo" within 5 seconds:
-> SessionListViewModel.undoDelete()
-> undoJob cancelled
-> SessionRepository.restoreSession(sessionId)
-> DB: UPDATE sessions SET deleted_at = NULL WHERE id = sessionId
-> Session reappears in list
3b. 5 seconds pass without undo:
-> undoJob fires
-> SessionRepository.hardDeleteSession(sessionId)
-> DB: DELETE FROM sessions WHERE id = sessionId
-> CASCADE: all messages for this session are deleted
-> Snackbar dismissed
-> Session permanently gone
Flow 3: Batch Delete with Undo
1. User long-presses a session -> enters selection mode
2. User taps 3 more sessions to select (total 4)
3. User taps "Delete" in toolbar
-> SessionListViewModel.deleteSelectedSessions()
-> BatchDeleteSessionsUseCase([id1, id2, id3, id4])
-> DB: UPDATE sessions SET deleted_at = now WHERE id IN (id1, id2, id3, id4)
-> All 4 sessions disappear from list
-> Exit selection mode
-> Snackbar: "4 sessions deleted [Undo]"
4a. Undo -> all 4 restored
4b. Timeout -> all 4 hard deleted
Flow 4: App Killed During Undo Window
1. User swipes to delete a session
2. Soft-delete applied, undo timer starts
3. App is killed (force stop, crash, etc.)
On next app launch:
1. OneclawApplication.onCreate()
2. CleanupSoftDeletedUseCase()
-> SessionRepository.hardDeleteAllSoftDeleted()
-> DB: DELETE FROM sessions WHERE deleted_at IS NOT NULL
3. Any sessions that were soft-deleted are now permanently gone
Flow 5: Agent Deletion Fallback
1. User has 3 sessions using "Code Helper" agent
2. User deletes "Code Helper" agent (from RFC-002)
-> DeleteAgentUseCase calls SessionRepository.updateAgentForSessions(
oldAgentId = "code-helper-uuid",
newAgentId = "agent-general-assistant"
)
-> DB: UPDATE sessions SET current_agent_id = 'agent-general-assistant'
WHERE current_agent_id = 'code-helper-uuid'
3. All 3 sessions now reference General Assistant
4. Session list updates agent badges accordingly
Error Handling
Error Scenarios and User-Facing Messages
| Scenario | ErrorCode | User Message | UI Behavior |
|---|---|---|---|
| Session creation fails (DB error) | STORAGE_ERROR | “Failed to create session. Please try again.” | Error toast, message not sent |
| Session deletion fails (DB error) | STORAGE_ERROR | “Failed to delete session.” | Error toast, session stays in list |
| Rename with empty title | VALIDATION_ERROR | “Session title cannot be empty.” | Inline error in dialog |
| Rename with title too long | VALIDATION_ERROR | “Session title is too long (max 200 characters).” | Inline error in dialog |
| AI title generation fails | – (silent) | – | Truncated title kept, no error shown |
| AI title generation offline | – (silent) | – | Skipped entirely, truncated title kept |
| Session resume fails (corrupted) | STORAGE_ERROR | “This session could not be loaded.” | Error dialog with option to delete |
Title Generation Error Handling
AI title generation is designed to be resilient:
- If the lightweight model doesn’t exist in DB -> fall back to current model
- If the API call fails -> keep truncated title silently
- If the response is empty or unparseable -> keep truncated title
- If the app is killed during generation -> truncated title persists (already saved)
- No retries – to avoid unnecessary API costs
Implementation Steps
Phase 1: Data Layer
- Update
Sessiondomain model incore/model/Session.kt(addlastMessagePreview,deletedAt) - Create
SessionEntityindata/local/entity/SessionEntity.kt - Create
SessionDaoindata/local/dao/SessionDao.kt - Create entity-domain mapper in
data/local/mapper/SessionMapper.kt - Update
sessionstable schema inAppDatabase(add new columns + indexes)
Phase 2: Repository
- Update
SessionRepositoryinterface incore/repository/SessionRepository.kt - Implement
SessionRepositoryImplindata/repository/SessionRepositoryImpl.kt
Phase 3: Title Generation
- Add
generateSimpleCompletion()toModelApiAdapterinterface - Implement
generateSimpleCompletion()inOpenAiAdapter - Implement
generateSimpleCompletion()inAnthropicAdapter - Implement
generateSimpleCompletion()inGeminiAdapter - Implement
GenerateTitleUseCasewith Phase 1 (truncation) and Phase 2 (AI) logic - Implement lightweight model resolution with DB verification
Phase 4: Use Cases
- Implement
CreateSessionUseCase - Implement
DeleteSessionUseCase - Implement
BatchDeleteSessionsUseCase - Implement
RenameSessionUseCase - Implement
CleanupSoftDeletedUseCase - Add cleanup call in
OneclawApplication.onCreate()
Phase 5: UI Layer
- Create
SessionListUiState,SessionListItem,UndoState,RenameDialogState - Implement
SessionListViewModel - Implement
SessionDrawerContent(Compose) - Implement
SessionListItemRowwith swipe-to-dismiss - Implement selection mode (long-press, checkboxes, toolbar)
- Implement
RenameSessionDialog(Compose) - Implement Snackbar undo integration
- Integrate drawer with
ChatScreenlayout (ModalNavigationDrawer)
Phase 6: DI & Integration
- Update Koin modules (DatabaseModule, RepositoryModule, FeatureModule)
- End-to-end testing: create session -> title gen -> rename -> delete -> undo
- Test batch delete + undo
- Test app restart cleanup of soft-deleted sessions
- Test AI title generation with lightweight model resolution
Testing Strategy
Unit Tests
SessionRepositoryImpl: CRUD, soft-delete/restore/hard-delete, agent fallbackCreateSessionUseCase: Default agent, timestamp generationDeleteSessionUseCase: Soft-delete, not-found errorBatchDeleteSessionsUseCase: Multiple IDs, empty list validationRenameSessionUseCase: Validation (empty, too long), successful renameGenerateTitleUseCase.generateTruncatedTitle(): Short message, long message at word boundary, no good word boundaryGenerateTitleUseCase.resolveLightweightModel(): Model exists in DB, model doesn’t exist, unknown provider typeSessionListViewModel: Delete + undo flow, selection mode, rename- Entity-domain mappers: All fields including nullable fields (
deletedAt,lastMessagePreview) formatRelativeTime(): Various time deltas
Integration Tests (Instrumented)
- Full session lifecycle: Create -> send message -> title generated -> rename -> delete -> undo -> hard delete
- Soft-delete cleanup on app restart
- CASCADE delete: Verify messages are deleted when session is hard-deleted
- AI title generation with real (mocked) API call
- Agent fallback: Delete agent -> verify sessions updated
UI Tests
- Drawer opens on hamburger tap
- Session list renders correctly with all fields
- Swipe-to-delete animation
- Snackbar appears and undo works
- Selection mode: long-press enters, checkboxes appear, delete works
- Rename dialog: opens, validates, saves
- Empty state message when no sessions
Edge Cases
- Create and immediately delete (before first message)
- Delete during active request (is_active = true)
- Rename to maximum length (200 chars)
- Very long session list (1000+ sessions) scroll performance
- Multiple rapid delete-undo cycles
- Delete session, undo, delete again
- AI title generation while offline
- App killed during AI title generation in-flight
- Batch delete all sessions, undo
- Session with 10,000+ messages (list render performance)
Layer 2 Visual Verification Flows
Each flow is independent. All flows that involve sending messages require a configured provider with a valid API key. Screenshot after each numbered step that says “Screenshot”.
Flow 5-1: Session Drawer Opens and Displays Sessions
Precondition: At least one session exists (send one message first if needed).
Goal: Verify the session drawer opens from the hamburger icon and lists sessions correctly.
Steps:
1. Navigate to the Chat screen.
2. Tap the hamburger icon (top-left).
3. Screenshot -> Verify:
- Drawer slides in from the left.
- "New Conversation" button at the top.
- Session list shows at least one session with: title, message preview, relative timestamp, agent chip.
4. Tap outside the drawer (or swipe left) to close it.
5. Screenshot -> Verify: Drawer is closed, Chat screen visible.
Flow 5-2: Lazy Session Creation — Session Appears After First Message
Precondition: Valid API key configured. Start a new conversation (empty chat, no session ID yet).
Goal: Verify that no session is created until the first message is sent, then it appears in the drawer.
Steps:
1. Start a new conversation (tap "New Conversation" in the drawer, or on fresh launch).
2. Open the drawer.
3. Screenshot -> Verify: The current (empty) conversation does NOT appear in the session list yet.
4. Close the drawer. Type a message and tap Send.
5. Wait for the AI to respond (streaming completes).
6. Open the drawer.
7. Screenshot -> Verify:
- The new session now appears in the session list.
- Title is a truncated version of the first message (Phase 1 title).
Flow 5-3: AI Title Generation After First Response
Precondition: Valid API key configured. Send one message and wait for a full response.
Goal: Verify the session title updates to an AI-generated title after the first exchange.
Steps:
1. Send a distinctive message: "Tell me about the history of the Eiffel Tower."
2. Wait for streaming to complete fully.
3. Open the session drawer.
4. Screenshot -> Verify:
- Session title has been updated from the truncated message to a meaningful AI-generated title
(e.g., "Eiffel Tower History" or similar — not the raw message text truncated).
Note: AI title is generated asynchronously; allow a few seconds after streaming ends.
Flow 5-4: Switch Between Sessions
Precondition: At least two sessions exist with different messages.
Goal: Verify tapping a session in the drawer switches the chat to that session's messages.
Steps:
1. Open the drawer.
2. Note the titles of at least two sessions.
3. Tap the second session.
4. Screenshot -> Verify:
- Drawer closed.
- Chat shows that session's messages (correct content visible).
- Top bar shows the correct session's agent name.
5. Open the drawer and tap the first session.
6. Screenshot -> Verify: Chat switches back to the first session's messages.
Flow 5-5: Swipe to Delete a Session — With Undo
Precondition: At least one session exists in the drawer.
Goal: Verify swipe-to-delete removes the session and the Undo snackbar appears.
Steps:
1. Open the drawer.
2. Swipe a session item to the left.
3. Screenshot -> Verify:
- Session is removed from the list.
- A Snackbar appears at the bottom with an "Undo" action.
4. Tap "Undo" in the Snackbar.
5. Screenshot -> Verify: The session reappears in the list (deletion undone).
6. Swipe the same session again.
7. Wait for the Snackbar to auto-dismiss (do NOT tap Undo).
8. Screenshot -> Verify: Session remains gone; Snackbar dismissed (hard-deleted).
Flow 5-6: Rename a Session
Precondition: At least one session exists.
Goal: Verify renaming a session via long-press updates the title in the drawer.
Steps:
1. Open the drawer.
2. Long-press a session item.
3. Screenshot -> Verify: Selection mode entered — checkboxes appear on session items.
Note: Rename may be accessible via a context menu or toolbar button in selection mode.
(If rename is via long-press context menu instead, adapt steps accordingly.)
4. Find and tap the Rename option.
5. Screenshot -> Verify: Rename dialog/input appears with current title pre-filled.
6. Clear the field and enter "My Renamed Session".
7. Tap "Save" (or "OK").
8. Screenshot -> Verify: Session item in the drawer now shows "My Renamed Session".
Flow 5-7: Batch Delete Sessions
Precondition: At least two sessions exist.
Goal: Verify long-press selection mode enables batch deletion of multiple sessions.
Steps:
1. Open the drawer.
2. Long-press a session to enter selection mode.
3. Screenshot -> Verify: Checkboxes visible; at least one session selected (highlighted).
4. Tap another session to select it too.
5. Screenshot -> Verify: Two sessions selected (both highlighted/checked).
6. Tap the Delete (trash) icon in the toolbar.
7. Screenshot -> Verify:
- Both sessions removed from the list.
- Snackbar appears with "Undo" action.
8. Tap "Undo".
9. Screenshot -> Verify: Both sessions reappear.
Security Considerations
- No sensitive data in sessions: Session metadata (title, agent ID, timestamps) is not sensitive.
- Message content in preview:
lastMessagePreviewis truncated and stored in the sessions table. This avoids needing to query the messages table for list rendering, but the preview is still user content. Same security level as messages. - AI title generation: The first user message and AI response are sent to the model for title generation. This is the same model the user is already using, so no new data exposure.
- Soft-delete window: During the ~5 second undo window, the session data is still in the DB (just marked). It is not encrypted differently during this window.
Dependencies
Depends On
- RFC-000 (Overall Architecture): Session domain model, project structure, Room database
- RFC-002 (Agent Management):
AgentConstants.GENERAL_ASSISTANT_IDfor default agent;AgentRepositoryfor agent name lookup - RFC-003 (Provider Management):
ProviderRepository,ApiKeyStorage,ModelApiAdapterFactoryfor AI title generation
Depended On By
- RFC-001 (Chat Interaction): Uses
CreateSessionUseCasefor lazy creation; usesGenerateTitleUseCasefor title generation after first AI response; usesSessionRepositoryfor message stats updates; integratesSessionDrawerContentin chat layout
Differences from RFC-000
This RFC introduces the following changes that should be reflected in RFC-000:
Sessionmodel updated: AddedlastMessagePreview: String?,deletedAt: Long?.sessionstable updated: Addedlast_message_preview,deleted_atcolumns + indexes.SessionRepositoryinterface expanded: Added soft-delete, restore, batch operations, rename, message stats, agent switch, active flag methods.ModelApiAdapterinterface expanded: AddedgenerateSimpleCompletion()method.
Open Questions
Soft-delete implementation: DB field vs ViewModel-only?Decision: DBdeleted_atfield for reliability across app restarts.AI title generation prompt?Decision: Simple prompt asking for 5-10 word title.Lightweight model selection?Decision: Hardcoded mapping verified against DB; fallback to current model.- Should the drawer show date group headers (“Today”, “Yesterday”, “Last week”)? The UI Design Spec shows them as optional. For V1, a flat list without date headers is simpler. Can be added later.
- Should session list support pull-to-refresh? Only useful when sync (FEAT-007) is implemented. Defer to FEAT-007.
References
- FEAT-005 PRD – Functional requirements
- UI Design Spec – Navigation Drawer layout, Session list items
- RFC-000 Overall Architecture – Session model, DB schema
- RFC-002 Agent Management – Agent fallback, AgentConstants
- RFC-003 Provider Management – ModelApiAdapter, ProviderRepository
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-02-27 | 0.1 | Initial version | - |