@@ -1108,9 +1108,10 @@ class ChatViewModel(
11081108 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
11091109 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
11101110 }
1111− // Remember the sent text so an undoable Stop can re-send it. Only text-bearing prompts
1112− // are tracked (an image-only prompt has nothing to re-send).
1113− if (trimmed.isNotEmpty()) lastSentPrompt = trimmed
1111+ // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
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.
1114+ lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
11141115 viewModelScope.launch {
11151116 runCatchingCancellable {
11161117 conn.repository.sendPrompt(
@@ -1252,9 +1253,13 @@ class ChatViewModel(
12521253 // already true, send() returns false and the draft would be lost forever.
12531254 // Attachments aren't re-sent on retry (they were optimistically restored to the
12541255 // 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.
12551261 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
12561262 _failedDraft.value = null
1257− failedIdempotencyKey = null
12581263 }
12591264 }
12601265
@@ -1372,6 +1377,12 @@ class ChatViewModel(
13721377 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
13731378 .onSuccess {
13741379 _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
13751386 // Surface an undo opportunity so an accidental Stop is recoverable.
13761387 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
13771388 // last prompt. Only when there's a tracked prompt to undo back to.
@@ -1543,17 +1554,45 @@ class ChatViewModel(
15431554 }
15441555
15451556 /** 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. */
15471561 fun cancelQuoteReply() {
15481562 val removed = _pendingQuote.value ?: return
15491563 _pendingQuote.value = null
1550− val quotedBlock = removed.trim().lineSequence().joinToString("\n") { "> $it" }
1551− _draft.value = _draft.value
1552− .replace("\n\n$quotedBlock\n\n", "\n\n", ignoreCase = false)
1553− .replace("$quotedBlock\n\n", "", ignoreCase = false)
1554− .replace("\n\n$quotedBlock", "", ignoreCase = false)
1555− .replace(quotedBlock, "", ignoreCase = false)
1556− .trimStart()
1564+ val quoteLineCount = removed.trim().lineSequence().count()
1565+ // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1566+ // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1567+ // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1568+ // even when their trailing content diverges from the stashed original.
1569+ val lines = _draft.value.split("\n").toMutableList()
1570+ var runStart = -1
1571+ var runLen = 0
1572+ var i = 0
1573+ while (i < lines.size) {
1574+ if (lines[i].startsWith("> ") || lines[i] == ">") {
1575+ if (runStart < 0) runStart = i
1576+ runLen++
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()
1583+ } else {
1584+ lines.subList(runStart, stripUntil).clear()
1585+ }
1586+ break
1587+ }
1588+ i++
1589+ } else {
1590+ runStart = -1
1591+ runLen = 0
1592+ i++
1593+ }
1594+ }
1595+ _draft.value = lines.joinToString("\n").trimStart()
15571596 }
15581597
15591598 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
@@ -1565,8 +1604,10 @@ class ChatViewModel(
15651604 val prompt = text.trim()
15661605 if (prompt.isEmpty()) return
15671606 viewModelScope.launch {
1607+ var createdId: String? = null
15681608 runCatchingCancellable {
15691609 val session = conn.repository.createSession(title = null)
1610+ createdId = session.id
15701611 conn.repository.sendPrompt(
15711612 session.id,
15721613 prompt,
@@ -1576,7 +1617,15 @@ class ChatViewModel(
15761617 session.id
15771618 }
15781619 .onSuccess { newId -> _branchEvents.trySend(newId) }
1579− .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1620+ .onFailure {
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.
1623+ val id = createdId
1624+ if (id != null) {
1625+ runCatchingCancellable { conn.repository.deleteSession(id) }
1626+ }
1627+ _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1628+ }
15801629 }
15811630 }
15821631