diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index 76f16f81..c1b363ce 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -437,8 +437,44 @@ interface CoachConversationDao { @Upsert suspend fun upsert(conversation: CoachConversationEntity) + + @Query("SELECT * FROM coach_conversations WHERE id = :id LIMIT 1") + suspend fun byId(id: String): CoachConversationEntity? + + /** + * The coach's past conversations for the history sheet (issue #131), newest first. Only ones + * with at least one message: "+" persists its thread up front, so an unused one is an empty + * row that would otherwise list as a blank entry. + */ + @Query( + """SELECT c.id, c.createdAt, c.updatedAt, + (SELECT m.body FROM coach_messages m WHERE m.conversationId = c.id AND m.role = 'user' + ORDER BY m.createdAt ASC LIMIT 1) AS firstQuestion, + (SELECT COUNT(*) FROM coach_messages m WHERE m.conversationId = c.id) AS messageCount + FROM coach_conversations c + WHERE EXISTS (SELECT 1 FROM coach_messages m WHERE m.conversationId = c.id) + ORDER BY c.updatedAt DESC""" + ) + fun historyFlow(): Flow> + + /** Delete its messages and tool calls first — see [CoachMessageDao], [CoachToolCallDao]. */ + @Query("DELETE FROM coach_conversations WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM coach_conversations") + suspend fun deleteAll() } +/** One row of the coach history sheet (issue #131). */ +data class CoachConversationSummary( + val id: String, + val createdAt: Long, + val updatedAt: Long, + /** The first thing the user asked; null only if the thread holds no user message. */ + val firstQuestion: String?, + val messageCount: Int, +) + @Dao interface CoachMessageDao { @Query("SELECT * FROM coach_messages WHERE conversationId = :convId ORDER BY createdAt ASC") @@ -449,6 +485,20 @@ interface CoachMessageDao { @Insert suspend fun insert(message: CoachMessageEntity) + + /** Attachment refs of a conversation's messages, so deleting it can delete the image files. */ + @Query("SELECT attachmentsJson FROM coach_messages WHERE conversationId = :convId AND attachmentsJson IS NOT NULL") + suspend fun attachmentsJsonFor(convId: String): List + + @Query("SELECT attachmentsJson FROM coach_messages WHERE attachmentsJson IS NOT NULL") + suspend fun allAttachmentsJson(): List + + /** Explicit rather than trusting the cascade, which only fires with SQLite's foreign keys on. */ + @Query("DELETE FROM coach_messages WHERE conversationId = :convId") + suspend fun deleteForConversation(convId: String) + + @Query("DELETE FROM coach_messages") + suspend fun deleteAll() } @Dao @@ -479,6 +529,13 @@ interface CoachToolCallDao { @Insert suspend fun insert(call: CoachToolCallEntity) + + /** `coach_tool_calls` has no foreign key to its conversation, so a delete has to clear it. */ + @Query("DELETE FROM coach_tool_calls WHERE conversationId = :convId") + suspend fun deleteForConversation(convId: String) + + @Query("DELETE FROM coach_tool_calls") + suspend fun deleteAll() } @Dao diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index ec098ca1..724a8a69 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -141,6 +141,9 @@ fun PulseLoopApp() { com.pulseloop.coach.attachments.CoachAttachmentStore.payloads(context, refs) }, environmentSnapshot = { weatherContextService.snapshot() }, + deleteAttachments = { refs -> + refs.forEach { com.pulseloop.coach.attachments.CoachAttachmentStore.delete(context, it) } + }, ) } @@ -391,10 +394,24 @@ fun PulseLoopApp() { // frosted glass bar the other tabs use so the chat scrolls under it // (owner choice, issue #22). var showCoachUsage by remember { mutableStateOf(false) } + var showCoachHistory by remember { mutableStateOf(false) } CoachHeader( onNewChat = { coachVM.newConversation() }, onOpenUsage = { showCoachUsage = true }, + onOpenHistory = { showCoachHistory = true }, ) + if (showCoachHistory) { + val history by coachVM.history.collectAsState(initial = emptyList()) + val coachState by coachVM.state.collectAsState() + com.pulseloop.ui.screens.CoachHistorySheet( + conversations = history, + currentId = coachState.conversationId, + onOpen = { id -> coachVM.openConversation(id); showCoachHistory = false }, + onDelete = { id -> coachVM.deleteConversation(id) }, + onDeleteAll = { coachVM.deleteAllConversations(); showCoachHistory = false }, + onDismiss = { showCoachHistory = false }, + ) + } if (showCoachUsage) { var usageData by remember { mutableStateOf>?>(null) diff --git a/app/src/main/java/com/pulseloop/ui/screens/CoachHistorySheet.kt b/app/src/main/java/com/pulseloop/ui/screens/CoachHistorySheet.kt new file mode 100644 index 00000000..8104f692 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachHistorySheet.kt @@ -0,0 +1,202 @@ +package com.pulseloop.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pulseloop.data.dao.CoachConversationSummary +import com.pulseloop.ui.theme.PulseColors +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * The coach's past conversations (issue #131): tap one to reopen it, swipe it away to delete it, + * or delete them all. Every delete asks first — it takes the messages, the tool-call trace and any + * attached photos with it, and there is no undo. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CoachHistorySheet( + conversations: List, + currentId: String?, + onOpen: (String) -> Unit, + onDelete: (String) -> Unit, + onDeleteAll: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var pendingDelete by remember { mutableStateOf(null) } + var confirmDeleteAll by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = PulseColors.background) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Past conversations", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = PulseColors.textPrimary) + if (conversations.isEmpty()) { + Text( + "No conversations yet. Ask the coach something and it will show up here.", + fontSize = 13.sp, color = PulseColors.textMuted, + ) + } else { + Text("Swipe a conversation to delete it.", fontSize = 12.sp, color = PulseColors.textMuted) + LazyColumn( + Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(conversations, key = { it.id }) { convo -> + HistoryRow( + convo = convo, + isCurrent = convo.id == currentId, + onOpen = { onOpen(convo.id) }, + onSwipedAway = { pendingDelete = convo }, + ) + } + } + TextButton( + onClick = { confirmDeleteAll = true }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { Text("Delete all conversations", color = PulseColors.danger) } + } + } + } + + pendingDelete?.let { convo -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Delete this conversation?") }, + text = { Text("“${CoachHistoryText.title(convo.firstQuestion)}” and its replies will be deleted. This can't be undone.") }, + confirmButton = { + TextButton(onClick = { onDelete(convo.id); pendingDelete = null }) { + Text("Delete", color = PulseColors.danger) + } + }, + dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } }, + ) + } + if (confirmDeleteAll) { + AlertDialog( + onDismissRequest = { confirmDeleteAll = false }, + title = { Text("Delete all conversations?") }, + text = { Text("All ${conversations.size} conversations and their replies will be deleted. This can't be undone.") }, + confirmButton = { + TextButton(onClick = { confirmDeleteAll = false; onDeleteAll() }) { + Text("Delete all", color = PulseColors.danger) + } + }, + dismissButton = { TextButton(onClick = { confirmDeleteAll = false }) { Text("Cancel") } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HistoryRow( + convo: CoachConversationSummary, + isCurrent: Boolean, + onOpen: () -> Unit, + onSwipedAway: () -> Unit, +) { + // The swipe only asks; the row snaps back and the dialog decides. Returning false keeps the + // state from settling on "dismissed", which would leave a cancelled row swiped off screen. + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value != SwipeToDismissBoxValue.Settled) onSwipedAway() + false + }, + ) + val shape = RoundedCornerShape(12.dp) + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .clip(shape) + .background(PulseColors.danger.copy(alpha = 0.25f)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon(Icons.Filled.Delete, "Delete", tint = PulseColors.danger) + } + }, + ) { + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(PulseColors.card) + .border(1.dp, if (isCurrent) PulseColors.accent else PulseColors.borderSubtle, shape) + .clickable(onClick = onOpen) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + CoachHistoryText.title(convo.firstQuestion), + fontSize = 14.sp, color = PulseColors.textPrimary, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + CoachHistoryText.subtitle(convo, isCurrent), + fontSize = 12.sp, color = PulseColors.textMuted, + ) + } + } +} + +/** The history row's text, kept out of the composable so it can be unit-tested. */ +internal object CoachHistoryText { + + fun title(firstQuestion: String?): String = + firstQuestion?.replace(Regex("\\s+"), " ")?.trim()?.takeIf { it.isNotEmpty() } + ?: "Untitled conversation" + + fun subtitle( + convo: CoachConversationSummary, + isCurrent: Boolean, + zone: ZoneId = ZoneId.systemDefault(), + today: LocalDate = LocalDate.now(zone), + ): String { + val parts = mutableListOf(whenText(convo.updatedAt, zone, today)) + parts += if (convo.messageCount == 1) "1 message" else "${convo.messageCount} messages" + if (isCurrent) parts += "open now" + return parts.joinToString(" · ") + } + + fun whenText(epochMs: Long, zone: ZoneId, today: LocalDate): String { + val at = Instant.ofEpochMilli(epochMs).atZone(zone) + val day = at.toLocalDate() + return when { + day == today -> "Today " + at.format(TIME) + day == today.minusDays(1) -> "Yesterday " + at.format(TIME) + day.year == today.year -> at.format(SAME_YEAR) + else -> at.format(OTHER_YEAR) + } + } + + private val TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + private val SAME_YEAR = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + private val OTHER_YEAR = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +} diff --git a/app/src/main/java/com/pulseloop/ui/screens/CoachScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/CoachScreen.kt index ab9dafd4..0e69331c 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/CoachScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -131,7 +132,11 @@ fun CoachScreen( * chat then scrolls under it. */ @Composable -fun CoachHeader(onNewChat: () -> Unit, onOpenUsage: (() -> Unit)? = null) { +fun CoachHeader( + onNewChat: () -> Unit, + onOpenUsage: (() -> Unit)? = null, + onOpenHistory: (() -> Unit)? = null, +) { Row( Modifier .fillMaxWidth() @@ -171,6 +176,19 @@ fun CoachHeader(onNewChat: () -> Unit, onOpenUsage: (() -> Unit)? = null) { Icon(Icons.Filled.Info, "Usage & cost", tint = PulseColors.textSecondary, modifier = Modifier.size(16.dp)) } } + if (onOpenHistory != null) { + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(PulseColors.card) + .border(1.dp, PulseColors.borderSubtle, CircleShape) + .clickable(onClick = onOpenHistory), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.History, "Past conversations", tint = PulseColors.textSecondary, modifier = Modifier.size(18.dp)) + } + } Box( Modifier .size(36.dp) diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index b13132db..e8a79943 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -2,6 +2,7 @@ package com.pulseloop.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.room.withTransaction import com.pulseloop.data.ActivityBucketDeletion import com.pulseloop.data.DemoDataPolicy import com.pulseloop.data.PulseLoopDatabase @@ -889,11 +890,15 @@ class CoachViewModel( private val attachmentPayloads: (List) -> List = { emptyList() }, /** Opt-in city + weather (iOS #65d), injected so the VM stays context-free like [attachmentPayloads]. */ private val environmentSnapshot: suspend () -> com.pulseloop.coach.context.EnvironmentContext? = { null }, + /** Deletes the image files behind attachment refs, when their conversation is deleted (issue #131). */ + private val deleteAttachments: (List) -> Unit = {}, ) : ViewModel() { data class CoachState( val messages: List = emptyList(), val isThinking: Boolean = false, val error: String? = null, + /** The thread on screen, so the history sheet can mark it (issue #131). */ + val conversationId: String? = null, ) data class ChatMessage( @@ -933,15 +938,69 @@ class CoachViewModel( private suspend fun loadOrCreateConversation() { val existing = db.coachConversationDao().recent(limit = 1).firstOrNull() if (existing != null) { - currentConversation = existing - val messages = db.coachMessageDao().forConversation(existing.id) - if (messages.isNotEmpty()) { - _state.update { it.copy(messages = messages.map { m -> m.toChatMessage() }) } - } + show(existing) return } currentConversation = CoachConversationEntity() db.coachConversationDao().upsert(currentConversation) + _state.update { it.copy(conversationId = currentConversation.id) } + } + + /** Make [conversation] the active thread and show its persisted messages (or the greeting). */ + private suspend fun show(conversation: CoachConversationEntity) { + currentConversation = conversation + val messages = db.coachMessageDao().forConversation(conversation.id) + _state.value = CoachState( + messages = if (messages.isEmpty()) listOf(greeting) else messages.map { m -> m.toChatMessage() }, + conversationId = conversation.id, + ) + } + + /** Past conversations for the history sheet (issue #131), newest first, empty ones left out. */ + val history: Flow> = + db.coachConversationDao().historyFlow() + + /** + * Reopen a past conversation (issue #131). A turn still in flight on the old thread keeps + * going and is stored under it; it just no longer lands on screen (see [sendMessage]). + */ + fun openConversation(id: String) { + if (id == currentConversation.id) return + conversationReady = viewModelScope.launch { + val conversation = db.coachConversationDao().byId(id) ?: return@launch + show(conversation) + } + } + + /** Delete one conversation with its messages, tool-call trace and attached images (issue #131). + * Deleting the one on screen starts a new thread in its place. */ + fun deleteConversation(id: String) { + val wasCurrent = id == currentConversation.id + if (wasCurrent) newConversation() + viewModelScope.launch { + val attachments = db.coachMessageDao().attachmentsJsonFor(id) + db.withTransaction { + db.coachToolCallDao().deleteForConversation(id) + db.coachMessageDao().deleteForConversation(id) + db.coachConversationDao().deleteById(id) + } + deleteAttachments(attachments.flatMap { com.pulseloop.coach.attachments.CoachAttachmentRef.decode(it) }) + } + } + + /** Delete every conversation (issue #131) and start a new one. */ + fun deleteAllConversations() { + viewModelScope.launch { + conversationReady.join() + val attachments = db.coachMessageDao().allAttachmentsJson() + db.withTransaction { + db.coachToolCallDao().deleteAll() + db.coachMessageDao().deleteAll() + db.coachConversationDao().deleteAll() + } + deleteAttachments(attachments.flatMap { com.pulseloop.coach.attachments.CoachAttachmentRef.decode(it) }) + newConversation() + } } /** The active thread's id, for the usage sheet — a one-shot fetch when the sheet opens rather @@ -971,8 +1030,8 @@ class CoachViewModel( /** Reset to a fresh thread (iOS CoachView "+" button). Starts a NEW persisted conversation — * the old one's rows are left in Room untouched, not deleted. */ fun newConversation() { - _state.value = CoachState(messages = listOf(greeting)) currentConversation = CoachConversationEntity() + _state.value = CoachState(messages = listOf(greeting), conversationId = currentConversation.id) conversationReady = viewModelScope.launch { db.coachConversationDao().upsert(currentConversation) } } @@ -1030,13 +1089,20 @@ class CoachViewModel( // `error`, or CoachScreen renders a duplicate "Error: " footer for the same // failure. `error` stays null here and is reserved for the catch-block path below, // which shows a plain (non-error-bubble) apology and needs the footer. - _state.update { it.copy( - messages = it.messages + reply, - isThinking = false, - ) } + // Only onto the thread it was asked in: after "+" or opening another conversation + // mid-turn, the reply is stored under its own thread but not shown on this one. + if (currentConversation.id == conversationId) { + _state.update { it.copy( + messages = it.messages + reply, + isThinking = false, + ) } + } // A failed turn still burned tokens, so it's persisted too (role "error", not // "assistant") — the context-replay filter above keys off isError, not the // persisted role, so this doesn't leak into future prompts. + // Deleted while the provider was answering (issue #131): storing the reply and + // touching the row below would bring the conversation back with half its turn. + if (db.coachConversationDao().byId(conversationId) == null) return@launch val persistedRole = if (reply.isError) "error" else "assistant" val usage = result.usage // Cost prefers the provider-reported figure (OpenRouter); else the catalog @@ -1073,10 +1139,12 @@ class CoachViewModel( // "+" tap mid-turn already moved `currentConversation` on to a newer one. if (currentConversation.id == conversationId) currentConversation = touched } catch (e: Exception) { - _state.update { it.copy( - messages = it.messages + ChatMessage("assistant", "Sorry, something went wrong: ${e.message}"), - isThinking = false, error = e.message, - ) } + if (currentConversation.id == conversationId) { + _state.update { it.copy( + messages = it.messages + ChatMessage("assistant", "Sorry, something went wrong: ${e.message}"), + isThinking = false, error = e.message, + ) } + } } } } diff --git a/app/src/test/java/com/pulseloop/ui/screens/CoachHistoryTextTest.kt b/app/src/test/java/com/pulseloop/ui/screens/CoachHistoryTextTest.kt new file mode 100644 index 00000000..ba47a129 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/screens/CoachHistoryTextTest.kt @@ -0,0 +1,54 @@ +package com.pulseloop.ui.screens + +import com.pulseloop.data.dao.CoachConversationSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +/** Issue #131: how a past coach conversation reads in the history sheet. */ +class CoachHistoryTextTest { + + private val zone = ZoneId.of("Europe/Paris") + private val today = LocalDate.of(2026, 10, 21) + + private fun ms(y: Int, mo: Int, d: Int, h: Int = 9, mi: Int = 15): Long = + LocalDateTime.of(y, mo, d, h, mi).atZone(zone).toInstant().toEpochMilli() + + private fun convo(updatedAt: Long, count: Int = 4) = + CoachConversationSummary("id", updatedAt, updatedAt, "How did I sleep?", count) + + @Test + fun `title is the first question on one line`() { + assertEquals("How did I sleep last night?", CoachHistoryText.title(" How did I sleep\n\nlast night? ")) + } + + @Test + fun `missing or blank question gets a placeholder`() { + assertEquals("Untitled conversation", CoachHistoryText.title(null)) + assertEquals("Untitled conversation", CoachHistoryText.title(" \n ")) + } + + @Test + fun `today and yesterday are named with the time`() { + assertTrue(CoachHistoryText.whenText(ms(2026, 10, 21), zone, today).startsWith("Today ")) + assertTrue(CoachHistoryText.whenText(ms(2026, 10, 20), zone, today).startsWith("Yesterday ")) + } + + @Test + fun `older dates show the year only when it differs`() { + assertFalse(CoachHistoryText.whenText(ms(2026, 10, 12), zone, today).contains("2026")) + assertTrue(CoachHistoryText.whenText(ms(2025, 12, 30), zone, today).contains("2025")) + } + + @Test + fun `subtitle counts messages and marks the open conversation`() { + val at = ms(2026, 10, 12) + val date = CoachHistoryText.whenText(at, zone, today) + assertEquals("$date · 1 message", CoachHistoryText.subtitle(convo(at, 1), false, zone, today)) + assertEquals("$date · 4 messages · open now", CoachHistoryText.subtitle(convo(at), true, zone, today)) + } +}