fix(ai-sessions): keep the last-chance checkpoint and the live record fields

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-31 17:50:29 +02:00
co-authored by Claude Opus 5
parent bed7d9a478
commit e8816ac9f9
5 changed files with 59 additions and 21 deletions
@@ -26,7 +26,12 @@
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared'
import {
pendingUserAction,
pendingUserActionDetail,
RUN_PROMPT_ECHO_MAX,
type DisplayMessage
} from './shared'
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import type { ContextElement } from './context'
@@ -554,15 +559,21 @@
//
// Suppressed once the transcript already carries the message: a tab that
// mounted after the driver's opening save reads it from the store, and the
// echo beside it would be the same text drawn twice. The echo is a prefix of
// what the driver holds (it is truncated at the source), which is what makes
// `startsWith` the right test.
// echo beside it would be the same text drawn twice.
//
// Compared whole, and by prefix only where the echo was cut at the source's
// ceiling and a prefix is all there is to compare. Matching on prefix
// unconditionally would hide the echo whenever a new prompt opens with the
// previous turn's text ("Fix" after "Fix the bug") — which is the case the
// sender pins by position precisely to keep.
const remoteUserEcho = $derived.by(() => {
const echo = aiChatManager.remoteUserMessage
if (!echo || !aiChatManager.runHeldElsewhere) return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue
return messages[i].content.trim().startsWith(echo) ? undefined : echo
const held = messages[i].content.trim()
const already = echo.length >= RUN_PROMPT_ECHO_MAX ? held.startsWith(echo) : held === echo
return already ? undefined : echo
}
return echo
})
@@ -30,7 +30,6 @@ import {
backgroundJobCompletionNote,
deriveChatJobStatus,
pendingToolImagesMessage,
pendingUserAction,
trimJob
} from './shared'
import type {
@@ -3274,14 +3273,6 @@ export class AIChatManager {
// the poll exactly when nobody is watching. `pending` reads it without
// disturbing the animation.
const streaming = this.currentReply + this.replyReveal.pending
// Parked on the user, so nothing is advancing to preserve — and a
// snapshot taken here would store the question closed as interrupted,
// which is what it means only if this tab died. Another tab that opens
// the session meanwhile reads that as a failed call while the run is in
// fact waiting. The checkpoint before this one still holds everything
// the turn did up to the question, and the question itself does not
// survive a reload either way: its resolver goes with the page.
if (pendingUserAction(this.displayMessages)) return
// Write only when the turn advanced, so a parked confirmation costs
// nothing and the rate follows steps taken rather than time.
const shape = `${collectedMessages.length}:${this.displayMessages.length}:${streaming.length}`
@@ -659,6 +659,13 @@ export type DisplayMessage =
// is paused on the user. Drives the question card's interactivity, the
// "waiting for user" indicator, and routing a composer send to the answer —
// keep those in sync by going through this single predicate.
/** Longest prompt a driving tab echoes to the tabs watching its run, and the
* ceiling that keeps the run-status message bounded — it is the one field whose
* length a user sets. Lives here rather than beside the sender so the receiver
* can tell a truncated echo from a whole one without importing from `sessions`,
* which only imports in the other direction. */
export const RUN_PROMPT_ECHO_MAX = 2000
export function isActiveUserQuestion(message: DisplayMessage | undefined): boolean {
return Boolean(
message &&
@@ -91,7 +91,11 @@ import type {
} from '$lib/components/raw_apps/rawAppDom'
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import { sendUserToast } from '$lib/toast'
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
import {
pendingUserAction,
RUN_PROMPT_ECHO_MAX,
type DisplayMessage
} from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import {
broadcastRunStatus,
@@ -1011,11 +1015,6 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
// ticks and is not reaped as a closed tab.
const statusTimers = new Map<string, ReturnType<typeof setInterval>>()
/** Longest prompt echoed to the other tabs. Enough for the prompts people
* actually type, and a hard ceiling on the one field of the status message
* whose length a user controls. */
const RUN_PROMPT_ECHO_MAX = 2000
/** The prompt this run is working on: the last thing the user said, searching no
* further back than `from`. Read off the driver's rendered transcript rather
* than the request, so it is the same text the driving tab has on screen. */
@@ -511,6 +511,36 @@ let remoteReadSeq = 0
// would roll the record back on the next write. Reading instead always lands on
// what the shared store actually holds. In-memory only — re-persisting here
// would echo straight back out through the row funnel.
/** Take on what another tab stored, into the record object this tab already
* holds rather than in place of it.
*
* Identity matters: the debounced writers capture the object they were called
* on (`setSessionDraftPrompt` flushes 400ms later through `persistTouched(s)`),
* so swapping the array slot leaves that flush writing a record this list no
* longer contains — putting back the state it held before the other tab wrote.
*
* Preview tabs keep their live overrides. `friendlyLabel` / `friendlyPath` /
* `editorNamed` are stamped by the open editor and deliberately not persisted,
* so they are absent from every stored row; taking the row's tabs wholesale
* would drop a watching tab's breadcrumbs back to raw paths on any write from
* the other tab, a seen-watermark bump included. */
function adoptRemoteRow(held: Session, row: Session): void {
const stamped = new Map((held.previewTabs ?? []).map((t) => [t.id, t]))
Object.assign(held, row)
if (row.previewTabs) {
held.previewTabs = row.previewTabs.map((t) => {
const live = stamped.get(t.id)
if (!live) return t
return {
...t,
friendlyLabel: live.friendlyLabel,
friendlyPath: live.friendlyPath,
editorNamed: live.editorNamed
}
})
}
}
async function applyRemoteSessionPut(id: string): Promise<void> {
if (deletedSessionIds.has(id)) return
const token = ++remoteReadSeq
@@ -531,7 +561,7 @@ async function applyRemoteSessionPut(id: string): Promise<void> {
if (!row || deletedSessionIds.has(id)) return
const i = sessionState.sessions.findIndex((s) => s.id === id)
if (i >= 0) {
sessionState.sessions[i] = row
adoptRemoteRow(sessionState.sessions[i], row)
return
}
// New elsewhere: slot it in by createdAt, after any local transient drafts,