@@ -1108,9 +1108,10 @@ class ChatViewModel(
1108 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1108 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1109 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1109 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1111− // Remember the sent text so an undoable Stop can re-send it. Only text-bearing prompts
1111+ // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1112− // are tracked (an image-only prompt has nothing to re-send).
1112+ // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1113+ // would make Stop-Undo silently re-send that unrelated earlier message.
1113− if (trimmed.isNotEmpty()) lastSentPrompt = trimmed
1114+ lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1114 viewModelScope.launch {
1115 viewModelScope.launch {
1115 runCatchingCancellable {
1116 runCatchingCancellable {
1116 conn.repository.sendPrompt(
1117 conn.repository.sendPrompt(
@@ -1252,9 +1253,13 @@ class ChatViewModel(
1252 // already true, send() returns false and the draft would be lost forever.
1253 // already true, send() returns false and the draft would be lost forever.
1253 // Attachments aren't re-sent on retry (they were optimistically restored to the
1254 // Attachments aren't re-sent on retry (they were optimistically restored to the
1254 // composer on the original failure, so they'll ride the next manual send).
1255 // composer on the original failure, so they'll ride the next manual send).
1256+ // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1257+ // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1258+ // synchronous clear and the async onFailure (which re-stashes the key) during which a
1259+ // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1260+ // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1255 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1261 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1256 _failedDraft.value = null
1262 _failedDraft.value = null
1257− failedIdempotencyKey = null
@@ -1372,6 +1377,12 @@ class ChatViewModel(
1372 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1377 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1373 .onSuccess {
1378 .onSuccess {
1374 _running.value = false
1379 _running.value = false
1380+ // Close the re-light gate so trailing SSE events in flight between the
1381+ // abort POST's ack and the server's SessionIdle don't re-light the run
1382+ // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1383+ // send()/runCommand()/continueRun() all open this gate on beginRun();
1384+ // abort() is the one run-ending path that previously missed it.
1385+ runEndedByIdle = true
1375 // Surface an undo opportunity so an accidental Stop is recoverable.
1386 // Surface an undo opportunity so an accidental Stop is recoverable.
1376 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1387 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1377 // last prompt. Only when there's a tracked prompt to undo back to.
1388 // last prompt. Only when there's a tracked prompt to undo back to.
@@ -1543,17 +1554,45 @@ class ChatViewModel(
1545 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1556 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1546− * the draft so canceling the quote card actually removes the quoted text, not just the chip. */
1557+ * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1558+ * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1559+ * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1560+ * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1547 fun cancelQuoteReply() {
1561 fun cancelQuoteReply() {
1548 val removed = _pendingQuote.value ?: return
1562 val removed = _pendingQuote.value ?: return
1549 _pendingQuote.value = null
1563 _pendingQuote.value = null
1550− val quotedBlock = removed.trim().lineSequence().joinToString("\n") { "> $it" }
1564+ val quoteLineCount = removed.trim().lineSequence().count()
1551− _draft.value = _draft.value
1565+ // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1552− .replace("\n\n$quotedBlock\n\n", "\n\n", ignoreCase = false)
1566+ // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1553− .replace("$quotedBlock\n\n", "", ignoreCase = false)
1567+ // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1554− .replace("\n\n$quotedBlock", "", ignoreCase = false)
1568+ // even when their trailing content diverges from the stashed original.
1555− .replace(quotedBlock, "", ignoreCase = false)
1569+ val lines = _draft.value.split("\n").toMutableList()
1556− .trimStart()
1570+ var runStart = -1
1573+ while (i < lines.size) {
1574+ if (lines[i].startsWith("> ") || lines[i] == ">") {
1575+ if (runStart < 0) runStart = i
1577+ if (runLen == quoteLineCount) {
1578+ // Strip the run plus a single trailing blank line that separated the quote
1579+ // from the reply (quoteReply appends "\n\n" after the block).
1580+ val stripUntil = i + 1
1581+ if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1582+ lines.subList(runStart, stripUntil + 1).clear()
1584+ lines.subList(runStart, stripUntil).clear()
1595+ _draft.value = lines.joinToString("\n").trimStart()
1559 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1598 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1565,8 +1604,10 @@ class ChatViewModel(
1565 val prompt = text.trim()
1604 val prompt = text.trim()
1566 if (prompt.isEmpty()) return
1605 if (prompt.isEmpty()) return
1567 viewModelScope.launch {
1606 viewModelScope.launch {
1607+ var createdId: String? = null
1568 runCatchingCancellable {
1608 runCatchingCancellable {
1569 val session = conn.repository.createSession(title = null)
1609 val session = conn.repository.createSession(title = null)
1610+ createdId = session.id
1570 conn.repository.sendPrompt(
1611 conn.repository.sendPrompt(
1571 session.id,
1612 session.id,
@@ -1576,7 +1617,15 @@ class ChatViewModel(
1576 session.id
1617 session.id
1578 .onSuccess { newId -> _branchEvents.trySend(newId) }
1619 .onSuccess { newId -> _branchEvents.trySend(newId) }
1621+ // If the session was created but the prompt failed, delete the orphaned
1622+ // empty session so failed branches don't accumulate on the server.
1625+ runCatchingCancellable { conn.repository.deleteSession(id) }
1579− .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1627+ _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))