|
|
@@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|
|
import { extractPromptFromParts } from "@/utils/prompt"
|
|
|
import { formatServerError } from "@/utils/server-errors"
|
|
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
|
|
+import { createSessionOwnership } from "./session/session-ownership"
|
|
|
|
|
|
type FollowupItem = FollowupDraft & { id: string }
|
|
|
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
|
|
@@ -75,6 +76,35 @@ 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<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() {
|
|
|
const serverSync = useServerSync()
|
|
|
const layout = useLayout()
|
|
|
@@ -94,6 +124,7 @@ export default function Page() {
|
|
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
|
|
const location = useLocation()
|
|
|
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
|
|
+ const sessionOwnership = createSessionOwnership(sessionKey)
|
|
|
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
|
|
|
|
|
createEffect(() => {
|
|
|
@@ -256,9 +287,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,
|
|
|
})
|
|
|
@@ -282,8 +311,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
|
|
|
@@ -549,8 +579,7 @@ export default function Page() {
|
|
|
on(
|
|
|
sessionKey,
|
|
|
() => {
|
|
|
- setStore("messageId", undefined)
|
|
|
- setStore("changes", "git")
|
|
|
+ setStore(sessionViewState())
|
|
|
setUi("pendingMessage", undefined)
|
|
|
},
|
|
|
{ defer: true },
|
|
|
@@ -1127,27 +1156,37 @@ export default function Page() {
|
|
|
|
|
|
let captureHistoryAnchor = () => {}
|
|
|
let restoreHistoryAnchor = (_done: boolean) => {}
|
|
|
- let historyRequest = false
|
|
|
+ const historyRequests = new Set<string>()
|
|
|
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()
|
|
|
}
|
|
|
@@ -1218,23 +1257,13 @@ export default function Page() {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
|
|
- 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<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
|
|
|
|
|
|
- const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["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<ReturnType<typeof info>>["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)
|
|
|
|
|
|
@@ -1252,6 +1281,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
|
|
|
|
|
|
@@ -1272,7 +1302,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)
|
|
|
},
|
|
|
}))
|
|
|
|
|
|
@@ -1361,25 +1391,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)
|
|
|
- })
|
|
|
},
|
|
|
}))
|
|
|
|
|
|
@@ -1388,39 +1416,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
|
|
|
-
|
|
|
- batch(() => {
|
|
|
- roll(sessionID, next ? { messageID: next.id } : undefined)
|
|
|
- if (next) {
|
|
|
- prompt.set(draft(next.id))
|
|
|
- return
|
|
|
- }
|
|
|
- prompt.reset()
|
|
|
+ const last = target.session.get(sessionID)?.revert
|
|
|
+
|
|
|
+ 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)
|
|
|
- })
|
|
|
},
|
|
|
}))
|
|
|
|