From fec51424f7723920e5a3981aafbfccf6ceff0e22 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 25 Jun 2026 13:27:19 +0800 Subject: [PATCH] fix(app): preserve session ownership changes --- packages/app/src/pages/session.tsx | 178 ++++++++++++++++------------- 1 file changed, 99 insertions(+), 79 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4bcc723e89d..898977e390d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -72,6 +72,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { createSessionOwnership } from "./session/session-ownership" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -80,8 +81,36 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" +const sessionViewState = () => ({ + messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, +}) + +async function runPromptRollbackMutation(input: { + capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } + optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void + request: () => Promise + complete: (result: R) => void + rollback: () => void + fail: (error: unknown) => void +}) { + const prompt = input.capturePrompt() + const previous = prompt.current().slice() + batch(() => input.optimistic(prompt)) + await input + .request() + .then(input.complete) + .catch((error) => { + batch(() => { + input.rollback() + prompt.set(previous) + }) + input.fail(error) + }) +} + export default function Page() { - const navigate = useNavigate() const serverSync = useServerSync() const layout = useLayout() const local = useLocal() @@ -99,7 +128,9 @@ export default function Page() { const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() + const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => { @@ -267,9 +298,7 @@ export default function Page() { ) const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, + ...sessionViewState(), newSessionWorktree: "main", deferRender: false, }) @@ -293,8 +322,9 @@ export default function Page() { const key = sessionKey() if (key !== prev) { setStore("deferRender", true) + const owner = sessionOwnership.capture() requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) + setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) }) } return key @@ -560,8 +590,7 @@ export default function Page() { on( sessionKey, () => { - setStore("messageId", undefined) - setStore("changes", "git") + setStore(sessionViewState()) setUi("pendingMessage", undefined) }, { defer: true }, @@ -1138,27 +1167,37 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = (_done: boolean) => {} - let historyRequest = false + const historyRequests = new Set() let historyContinuationFrame: number | undefined const loadOlder = async () => { - if (historyRequest || historyLoading()) return - historyRequest = true + const owner = sessionOwnership.capture() + if (historyLoading() || historyRequests.has(owner.key)) return + historyRequests.add(owner.key) const before = timeline.messages().length try { - await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + await timeline.history.loadOlder({ + before: () => owner.run(captureHistoryAnchor), + after: (done) => owner.run(() => restoreHistoryAnchor(done)), + }) } finally { - historyRequest = false + historyRequests.delete(owner.key) } - if (timeline.messages().length <= before) return + if (!owner.current() || timeline.messages().length <= before) return if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = undefined - onHistoryScroll() + owner.run(onHistoryScroll) }) } const onHistoryScroll = () => { - if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) + if ( + historyRequests.has(sessionOwnership.key()) || + historyLoading() || + !autoScroll.userScrolled() || + !scroller || + scroller.scrollTop >= 200 + ) return void loadOlder() } @@ -1229,23 +1268,13 @@ export default function Page() { }) } - const merge = (next: NonNullable>) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) + const merge = (next: NonNullable>, target = sync()) => target.session.remember(next) - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) + const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { + const session = target.session.get(sessionID) + if (!session) return + target.session.remember({ ...session, revert: next }) + } const busy = (sessionID: string) => sync().data.session_working(sessionID) @@ -1263,6 +1292,7 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1283,7 +1313,7 @@ export default function Page() { if (!ok) return setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() + if (input.manual) owner.run(resumeScroll) }, })) @@ -1372,25 +1402,23 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert + const client = sdk().client + const target = sync() + const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (prompt) => { + roll(input.sessionID, { messageID: input.messageID }, target) + prompt.set(value) + }, + request: () => halt(input.sessionID).then(() => client.session.revert(input)), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(input.sessionID, last, target), + fail, }) - await halt(input.sessionID) - .then(() => sdk().client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) @@ -1399,39 +1427,31 @@ export default function Page() { const sessionID = params.id if (!sessionID) return + const client = sdk().client + const target = sync() const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert + const last = target.session.get(sessionID)?.revert - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (promptSession) => { + roll(sessionID, next ? { messageID: next.id } : undefined, target) + if (next) { + promptSession.set(draft(next.id)) + return + } + promptSession.reset() + }, + request: () => + !next + ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(sessionID, last, target), + fail, }) - - const task = !next - ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk().client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, }))