fix(app): separate provider lifetimes and reactive ownership (#33739)
This commit is contained in:
parent
dc569b5a5f
commit
cfd75d62fe
37 changed files with 838 additions and 274 deletions
|
|
@ -58,14 +58,18 @@ export function DirectoryDataProvider(
|
|||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
<Show when={directory()} keyed>
|
||||
{(directory) => (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function SessionComposerRegion(props: {
|
|||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
|
|
|||
37
packages/app/src/pages/session/session-ownership.ts
Normal file
37
packages/app/src/pages/session/session-ownership.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createComputed, onCleanup } from "solid-js"
|
||||
|
||||
export function createSessionOwnership(sessionKey: () => string) {
|
||||
let current = sessionKey()
|
||||
let generation = 0
|
||||
const transition = () => {
|
||||
const next = sessionKey()
|
||||
if (next === current) return
|
||||
current = next
|
||||
generation++
|
||||
}
|
||||
createComputed(transition)
|
||||
onCleanup(() => generation++)
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
transition()
|
||||
return `${generation}:${current}`
|
||||
},
|
||||
capture() {
|
||||
transition()
|
||||
const captured = generation
|
||||
return {
|
||||
key: `${captured}:${current}`,
|
||||
current: () => {
|
||||
transition()
|
||||
return generation === captured
|
||||
},
|
||||
run<T>(action: () => T) {
|
||||
transition()
|
||||
if (generation !== captured) return
|
||||
return action()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
|
|
@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const sessionTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const value = await load()
|
||||
owner.run(() => show(value))
|
||||
}
|
||||
const runCommand = async <T,>(input: {
|
||||
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
|
||||
prompt: T
|
||||
request: () => Promise<unknown>
|
||||
updatePrompt: (prompt: T) => void
|
||||
updateViewport: () => void
|
||||
}) => {
|
||||
await input.request()
|
||||
input.updatePrompt(input.prompt)
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
|
||||
const info = () => {
|
||||
const id = params.id
|
||||
|
|
@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const openFile = () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-file"),
|
||||
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
|
|
@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-model"),
|
||||
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
||||
)
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-mcp"),
|
||||
(x) => dialog.show(() => <x.DialogSelectMcp />),
|
||||
)
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
|
|
@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (sync().data.session_working(params.id ?? "")) {
|
||||
await sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
const messages = userMessages()
|
||||
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk().client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync().data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
|
||||
prompt.set(restored)
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
|
||||
})
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
const next = messages.find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk().client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.unrevert({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sdk().client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||
})
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
|
|
@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const fork = () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
)
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue