fix(app): preserve session ownership changes

This commit is contained in:
Brendan Allan 2026-06-25 13:27:19 +08:00
commit fec51424f7

View file

@ -72,6 +72,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
type FollowupItem = FollowupDraft & { id: string } type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@ -80,8 +81,36 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn" type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch" type VcsMode = "git" | "branch"
const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
})
async function runPromptRollbackMutation<T, R>(input: {
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
request: () => Promise<R>
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() { export default function Page() {
const navigate = useNavigate()
const serverSync = useServerSync() const serverSync = useServerSync()
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
@ -99,7 +128,9 @@ export default function Page() {
const terminal = useTerminal() const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => { createEffect(() => {
@ -267,9 +298,7 @@ export default function Page() {
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
messageId: undefined as string | undefined, ...sessionViewState(),
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false, deferRender: false,
}) })
@ -293,8 +322,9 @@ export default function Page() {
const key = sessionKey() const key = sessionKey()
if (key !== prev) { if (key !== prev) {
setStore("deferRender", true) setStore("deferRender", true)
const owner = sessionOwnership.capture()
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(() => setStore("deferRender", false), 0) setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
}) })
} }
return key return key
@ -560,8 +590,7 @@ export default function Page() {
on( on(
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore(sessionViewState())
setStore("changes", "git")
setUi("pendingMessage", undefined) setUi("pendingMessage", undefined)
}, },
{ defer: true }, { defer: true },
@ -1138,27 +1167,37 @@ export default function Page() {
let captureHistoryAnchor = () => {} let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {} let restoreHistoryAnchor = (_done: boolean) => {}
let historyRequest = false const historyRequests = new Set<string>()
let historyContinuationFrame: number | undefined let historyContinuationFrame: number | undefined
const loadOlder = async () => { const loadOlder = async () => {
if (historyRequest || historyLoading()) return const owner = sessionOwnership.capture()
historyRequest = true if (historyLoading() || historyRequests.has(owner.key)) return
historyRequests.add(owner.key)
const before = timeline.messages().length const before = timeline.messages().length
try { try {
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) await timeline.history.loadOlder({
before: () => owner.run(captureHistoryAnchor),
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
})
} finally { } 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 (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined historyContinuationFrame = undefined
onHistoryScroll() owner.run(onHistoryScroll)
}) })
} }
const 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 return
void loadOlder() void loadOlder()
} }
@ -1229,23 +1268,13 @@ export default function Page() {
}) })
} }
const merge = (next: NonNullable<ReturnType<typeof info>>) => const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
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 roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) => const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
sync().set("session", (list) => { const session = target.session.get(sessionID)
const idx = list.findIndex((item) => item.id === sessionID) if (!session) return
if (idx < 0) return list target.session.remember({ ...session, revert: next })
const out = list.slice() }
out[idx] = { ...out[idx], revert: next }
return out
})
const busy = (sessionID: string) => sync().data.session_working(sessionID) const busy = (sessionID: string) => sync().data.session_working(sessionID)
@ -1263,6 +1292,7 @@ export default function Page() {
const followupMutation = useMutation(() => ({ const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { 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) const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return if (!item) return
@ -1283,7 +1313,7 @@ export default function Page() {
if (!ok) return if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) 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(() => ({ const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => { mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice() const client = sdk().client
const last = info()?.revert const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID) const value = draft(input.messageID)
batch(() => { await runPromptRollbackMutation({
roll(input.sessionID, { messageID: input.messageID }) capturePrompt: prompt.capture,
prompt.set(value) 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 const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const client = sdk().client
const target = sync()
const next = userMessages().find((item) => item.id > id) const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice() const last = target.session.get(sessionID)?.revert
const last = info()?.revert
batch(() => { await runPromptRollbackMutation({
roll(sessionID, next ? { messageID: next.id } : undefined) capturePrompt: prompt.capture,
if (next) { optimistic: (promptSession) => {
prompt.set(draft(next.id)) roll(sessionID, next ? { messageID: next.id } : undefined, target)
return if (next) {
} promptSession.set(draft(next.id))
prompt.reset() 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)
})
}, },
})) }))