fix: hold every turn during a move, and state the repoint's contract

- The chat manager refuses sends, retries and message edits while a move
  runs, from its first step, handing the text back. The composer was
  locked but a retry or an edit could still start a turn in the parent.
- A move into an existing fork checks again for a running turn after
  saving that fork's pending edits.
- repoint_unresolvable_draft_identities documents that it authorizes
  nothing and when its unscoped form may be used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-27 01:54:31 -07:00
co-authored by Claude Opus 5.5
parent d84a8f4551
commit d7b38031af
4 changed files with 30 additions and 5 deletions
@@ -8583,6 +8583,11 @@ fn principal_resolves_sql(principal: &str) -> String {
/// The draft half of `repoint_unresolvable_cloned_identities`: an app policy or trigger in a draft
/// copied into `target_workspace_id` that names nobody there is re-pointed at `authed`. `items`
/// narrows it to `authed`'s own rows at those keys, for drafts copied into an existing workspace.
///
/// **Authorizes nothing itself.** The caller must have checked that `authed` (loaded in
/// `target_workspace_id`) may write those drafts there. `items = None` rewrites every draft in the
/// workspace, other users' included, so it is only for a fork being created inside the same
/// transaction, before anyone else has drafts in it.
pub async fn repoint_unresolvable_draft_identities(
tx: &mut Transaction<'_, Postgres>,
target_workspace_id: &str,
@@ -788,6 +788,9 @@ export class AIChatManager implements ChatViewHost {
/** The session is moving into a fork. Its editors stay closed meanwhile: an edit would save a
* draft in the workspace being left, after the move listed what it brings along. */
moving = $state(false)
/** A move into a fork has started: no turn starts until it ends, retries and edits included.
* Set before `moving`, which waits for the editors to hand over their pending edits. */
moveStarted = $state(false)
onWorkModeChanged: ((mode: AIWorkMode) => void) | undefined = undefined
workModesAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode))
/** Use is the only mode: the workspace is protected, or the user's role builds nothing in it. */
@@ -3646,7 +3649,9 @@ export class AIChatManager implements ChatViewHost {
// false`), and a copy here would double it. Paste tokens are expanded
// into the text, as the queue does, because the restore lanes carry no
// pastes.
if (this.runHeldElsewhere) {
// A move into a fork holds turns the same way: one started now would act on the
// workspace being left. The composer is locked then, but a retry or an edit is not.
if (this.runHeldElsewhere || this.moveStarted) {
if (options.synthetic) {
// Client-authored prompt (a job auto-resume), not user input: nothing
// to hand back and no toast. Releasing the staged text un-blocks the
@@ -3664,7 +3669,12 @@ export class AIChatManager implements ChatViewHost {
options.files
)
}
sendUserToast('This session is running in another tab. Your message was kept.', true)
sendUserToast(
this.moveStarted
? 'This session is moving to a fork. Your message was kept.'
: 'This session is running in another tab. Your message was kept.',
true
)
}
return false
}
@@ -4833,7 +4843,7 @@ export class AIChatManager implements ChatViewHost {
// nothing yet, so there is nothing to restore. Un-submitted context-chip
// edits are the one loss — the chips re-seed from the untouched message
// on the next edit.
if (this.runHeldElsewhere) {
if (this.runHeldElsewhere || this.moveStarted) {
if (newContent !== undefined) {
this.restoreToInput(
expanded(chatDraft(newContent, pastes ?? [])),
@@ -4842,7 +4852,12 @@ export class AIChatManager implements ChatViewHost {
)
}
// "Text", not "message": chip edits are the part that does not survive.
sendUserToast('This session is running in another tab. Your text was kept.', true)
sendUserToast(
this.moveStarted
? 'This session is moving to a fork. Your text was kept.'
: 'This session is running in another tab. Your text was kept.',
true
)
return
}
@@ -540,6 +540,7 @@
return
}
moving = true
if (runtime) runtime.manager.moveStarted = true
const releaseAutoResume = runtime?.manager.holdAutoResume()
// Bracketed like a turn, so another tab on this session cannot send while its drafts
// move, then re-reads the session (its new workspace, the model note) once it ends.
@@ -621,7 +622,10 @@
sendUserToast(`Session moved to ${req.targetName}`)
} finally {
moving = false
if (runtime) runtime.manager.moving = false
if (runtime) {
runtime.manager.moving = false
runtime.manager.moveStarted = false
}
releaseAutoResume?.()
if (chatId)
localRunEnded(session.id, runtime?.manager.historyManager.getCurrentChatId() ?? chatId)
@@ -128,6 +128,7 @@ export async function moveSessionToFork(
// conflict they are; still parked, they would land on top of the moved draft later.
const unsavedThere = await flushPendingSaves(fork.id)
if (unsavedThere) return { ok: false, error: unsavedThere, retry: fork }
if (opts.isBusy?.()) return { ok: false, error: busy, retry: fork }
}
let copied = 0