fix(app): separate provider lifetimes and reactive ownership (#33739)

This commit is contained in:
Luke Parker 2026-06-25 14:42:06 +10:00 committed by GitHub
commit cfd75d62fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 838 additions and 274 deletions

View file

@ -17,9 +17,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:virtualizer", "test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",

View file

@ -51,7 +51,7 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new" import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route"
import Session from "@/pages/session" import Session from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home" import { NewHome, LegacyHome } from "@/pages/home"
@ -95,7 +95,7 @@ const TargetSessionRoute = () => {
}) })
return ( return (
<Show when={`${params.serverKey}\0${params.id}`} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute /> <ResolvedTargetSessionRoute />
@ -119,7 +119,7 @@ function ResolvedTargetSessionRoute() {
}, },
({ id, sync }) => sync.session.lineage.resolve(id), ({ id, sync }) => sync.session.lineage.resolve(id),
) )
const current = createMemo(() => cached() ?? resolved()) const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory) const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
@ -134,7 +134,7 @@ function ResolvedTargetSessionRoute() {
return ( return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}> <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}> <Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}> <Show when={directory()}>
<Show <Show
when={settings.general.newLayoutDesigns()} when={settings.general.newLayoutDesigns()}
@ -294,7 +294,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</PermissionProvider> </PermissionProvider>
) )

View file

@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const language = useLanguage() const language = useLanguage()
const providers = useProviders() const providers = useProviders(props.directory)
const all = () => { const all = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
}) })
} }

View file

@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { type Accessor, batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
type Props = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
directory?: Accessor<string | undefined>
} }
export function DialogCustomProvider(props: Props) { export function DialogCustomProvider(props: Props) {
@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
dialog.close() dialog.close()
return return
} }
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider directory={props.directory} />)
} }
const addModel = () => { const addModel = () => {

View file

@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
export const DialogManageModels: Component = () => { export const DialogManageModels: Component = () => {
const local = useLocal() const local = useLocal()
const language = useLanguage() const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => { const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider directory={directory} />)
} }
const providerRank = (id: string) => popularProviders.indexOf(id) const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)

View file

@ -10,24 +10,27 @@ import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type ModelState = ReturnType<typeof useLocal>["model"] type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
const connect = (provider: string) => { const connect = (provider: string) => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} />) dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
}) })
} }
const all = () => { const all = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider directory={directory} />)
}) })
} }

View file

@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
dismiss: null, dismiss: null,
}) })
const dialog = useDialog() const dialog = useDialog()
const local = useLocal()
const directory = () => decode64(local.slug())
const close = (dismiss: Dismiss) => { const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss) setStore("dismiss", dismiss)
@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") close("provider")
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider directory={directory} />)
}) })
} }
const language = useLanguage() const language = useLanguage()
@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const local = useLocal()
const directory = () => decode64(local.slug())
const provider = () => { const provider = () => {
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider directory={directory} />)
}) })
} }

View file

@ -1,4 +1,4 @@
import { Component, Show } from "solid-js" import { type Accessor, Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component = () => { export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders(props.directory)
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
onSelect={(x) => { onSelect={(x) => {
if (!x) return if (!x) return
if (x.id === CUSTOM_ID) { if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" />) dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
return return
} }
dialog.show(() => <DialogConnectProvider provider={x.id} />) dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
}} }}
> >
{(i) => ( {(i) => (

View file

@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@ -346,25 +347,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
const [store, setStore] = createStore<{ const [store, setStore] = createPromptInputTransientState(
popover: "at" | "slash" | null () => prompt.capture(),
historyIndex: number Math.floor(Math.random() * EXAMPLES.length),
savedPrompt: PromptHistoryEntry | null )
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({ const [picker, setPicker] = createStore({
projectOpen: false, projectOpen: false,
projectSearch: "", projectSearch: "",

View file

@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) {
}) })
} }
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
type PromptAttachmentsCoreInput = {
capture: () => PromptTarget
editor: () => HTMLDivElement | undefined
focusEditor?: () => void
addPart?: (part: ContentPart) => boolean
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
} }
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const prompt = input.prompt const capture = (): AttachmentTarget | undefined => {
const language = useLanguage() const prompt = input.capture()
const editor = input.editor()
const warn = () => { if (!editor) return
showToast({ return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
} }
const add = async (file: File, toast = true) => { const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file) const mime = await attachmentMime(file)
if (!mime) { if (!mime) {
if (toast) warn() if (toast) input.warn?.()
return false return false
} }
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime) const url = await dataUrl(file, mime)
if (!url) return false if (!url) return false
@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
mime, mime,
dataUrl: url, dataUrl: url,
} }
const cursor = prompt.cursor() ?? getCursorPosition(editor) target.prompt.set([...target.prompt.current(), attachment], target.cursor)
prompt.set([...prompt.current(), attachment], cursor)
return true return true
} }
const addAttachment = (file: File) => add(file) const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => { const addAttachments = async (files: File[], toast = true, target = capture()) => {
let found = false let found = false
for (const file of files) { for (const file of files) {
const ok = await add(file, false) const ok = await add(file, false, target)
if (ok) found = true if (ok) found = true
} }
if (!found && files.length > 0 && toast) warn() if (!found && files.length > 0 && toast) input.warn?.()
return found return found
} }
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
const file = await pending
if (!file) return false
return add(file, true, target)
}
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
const current = prompt.current() const target = input.capture()
const current = target.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor()) target.set(next, target.cursor())
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}) })
if (files.length > 0) { if (files.length > 0) {
await addAttachments(files) await addAttachments(files, true, target)
return return
} }
@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
if (input.readClipboardImage && !plainText) { if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage() if (await addClipboardAttachment(input.readClipboardImage(), target)) return
if (file) {
await addAttachment(file)
return
}
} }
if (!plainText) return if (!plainText) return
@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const text = normalizePaste(plainText) const text = normalizePaste(plainText)
const put = () => { const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor() input.focusEditor?.()
return input.addPart({ type: "text", content: text, start: 0, end: 0 }) return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
} }
if (pasteMode(text) === "manual") { if (pasteMode(text) === "manual") {
@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
put() put()
} }
return {
addAttachment,
addAttachments,
addClipboardAttachment,
removeAttachment,
handlePaste,
}
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const attachments = createPromptAttachmentsCore({
...input,
capture: input.prompt.capture,
warn: () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
},
})
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return if (input.isDialogActive()) return
@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
await addAttachments(Array.from(dropped)) await attachments.addAttachments(Array.from(dropped))
} }
onMount(() => { onMount(() => {
@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
makeEventListener(document, "drop", handleGlobalDrop) makeEventListener(document, "drop", handleGlobalDrop)
}) })
return { return attachments
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}
} }

View file

@ -0,0 +1,31 @@
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
export function createPromptSubmissionState(input: {
target: PromptTarget
prompt: Prompt
context: (ContextItem & { key: string })[]
}) {
let target = input.target
let cleared: Prompt | undefined
return {
prompt: input.prompt,
context: input.context,
target: () => target,
clear() {
target.reset()
cleared = target.current()
},
retarget(next: PromptTarget) {
input.context.forEach(next.context.add)
target = next
},
current: (value: PromptTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
},
}
}

View file

@ -41,6 +41,7 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: () => prompt,
} }
const clientFor = (directory: string) => { const clientFor = (directory: string) => {

View file

@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js" import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@ -194,15 +194,6 @@ type PromptSubmitInput = {
onSubmit?: () => void onSubmit?: () => void
} }
type CommentItem = {
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch(() => {}) .catch(() => {})
} }
const restoreCommentItems = (items: CommentItem[]) => { const restoreCommentItems = (
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
items: (ContextItem & { key: string })[],
) => {
for (const item of items) { for (const item of items) {
prompt.context.add({ target.context.add({
type: "file", type: "file",
path: item.path, path: item.path,
selection: item.selection, selection: item.selection,
@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const removeCommentItems = (items: { key: string }[]) => { const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
for (const item of items) { for (const item of target.context.items()) {
prompt.context.remove(item.key) target.context.remove(item.key)
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
} }
} }
@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
const currentPrompt = prompt.current() const target = prompt.capture()
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: target.context.items().slice(),
})
const currentPrompt = submission.prompt
const context = submission.context
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = input.imageAttachments().slice() const images = input.imageAttachments().slice()
const mode = input.mode() const mode = input.mode()
@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const draftID = search.draftId const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
} }
} }
if (!session) { if (!session) {
@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const context = prompt.context.items().slice()
const draft: FollowupDraft = { const draft: FollowupDraft = {
sessionID: session.id, sessionID: session.id,
sessionDirectory, sessionDirectory,
@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const clearInput = () => { const clearInput = () => {
prompt.reset() submission.clear()
input.setMode("normal") input.setMode("normal")
input.setPopover(null) input.setPopover(null)
} }
const restoreInput = () => { const restoreInput = () => {
prompt.set(currentPrompt, input.promptLength(currentPrompt)) const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode) input.setMode(mode)
input.setPopover(null) input.setPopover(null)
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
setCursorPosition(editor, input.promptLength(currentPrompt)) setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll() input.queueScroll()
}) })
return true
} }
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft) input.onQueue?.(draft)
clearContext() clearContext(submission.target())
clearInput() clearInput()
return return
} }
@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}) })
} }
removeCommentItems(commentItems) for (const item of commentItems) submission.target().context.remove(item.key)
clearInput() clearInput()
const waitForWorktree = async () => { const waitForWorktree = async () => {
@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sync().set("session_status", session.id, { type: "idle" }) sync().set("session_status", session.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
restoreCommentItems(commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
restoreInput()
} }
pending.set(pendingKey(session.id), { abort: controller, cleanup }) pending.set(pendingKey(session.id), { abort: controller, cleanup })
@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
description: errorMessage(err), description: errorMessage(err),
}) })
removeOptimisticMessage() removeOptimisticMessage()
restoreCommentItems(commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
restoreInput()
}) })
} }

View file

@ -0,0 +1,43 @@
import { createComputed, on, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { PromptHistoryEntry } from "./history"
export type PromptInputTransientState = {
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
setStore({
popover: null,
historyIndex: -1,
savedPrompt: null,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
}
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
const [store, setStore] = createStore<PromptInputTransientState>({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
return [store, setStore] as const
}

View file

@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile() const file = useFile()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const providers = useProviders() const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")

View file

@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const sync = useSync() const sync = useSync()
const language = useLanguage() const language = useLanguage()
const providers = useProviders() const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))

View file

@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
@ -73,6 +73,10 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index] if (match.found) return serverSync.data.project[match.index]
}, },
session: { session: {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) { get(sessionID: string) {
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session if (session?.directory === directory) return session

View file

@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders() const providers = useProviders(() => sdk().directory)
const models = useModels() const models = useModels()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)

View file

@ -1,4 +1,4 @@
import { createMemo } from "solid-js" import { type Accessor, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models", name: "Models",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders() const providers = useProviders(props.directory)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]), Persist.global("model", ["model.v1"]),

View file

@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings" import { useSettings } from "./settings"
@ -169,6 +169,15 @@ type PromptStore = {
type Scope = { draftID: string } | { dir: string; id?: string } type Scope = { draftID: string } | { dir: string; id?: string }
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
({ type: "session", server, sessionId: scope.id } satisfies Tab)
)
}
function scopeKey(scope: Scope) { function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}` if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
@ -213,7 +222,7 @@ function promptStore(): PromptStore {
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore) const actions = createPromptActions(setStore)
return { const value = {
current: () => store.prompt, current: () => store.prompt,
cursor: createMemo(() => store.cursor), cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
}, },
set: actions.set, set: actions.set,
reset: actions.reset, reset: actions.reset,
capture: () => value,
} }
return value
} }
export function createPromptState() { export function createPromptState() {
@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const owner = getOwner() const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => { const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
if (!settings.general.newLayoutDesigns()) return const scope = () =>
if (search.draftId) { search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const load = (scope: Scope) => { const load = (scope: Scope) => {
const current = tab() const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) { if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope) return createTabPromptState(tabs, current, serverSDK().scope, scope)
} }
@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value return entry.value
} }
const session = createMemo(() => const session = createMemo(() => load(scope()))
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session) const ready = createPromptReady(session)
return { return {
ready, ready,
capture: (scope?: Scope) => pick(scope).capture(),
current: () => session().current(), current: () => session().current(),
cursor: () => session().cursor(), cursor: () => session().cursor(),
dirty: () => session().dirty(), dirty: () => session().dirty(),

View file

@ -0,0 +1,59 @@
import { expect, test } from "bun:test"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
test("selects the ready catalog for an explicit directory", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: true, providers: directory },
}),
).toBe(directory)
})
test("returns an empty catalog while an explicit directory is unresolved", () => {
expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
expect(
selectProviderCatalog({
explicit: true,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
}),
).toEqual({ all: new Map(), connected: [], default: {} })
})
test("uses the route catalog when it is ready", () => {
const directory = catalog("directory")
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: true, providers: directory },
global: catalog("global"),
}),
).toBe(directory)
})
test("falls back to the global catalog for route consumers", () => {
const global = catalog("global")
expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
expect(
selectProviderCatalog({
explicit: false,
directory: "/repo",
catalog: { ready: false, providers: catalog("directory") },
global,
}),
).toBe(global)
})

View file

@ -0,0 +1,27 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: NormalizedProviderListResponse
}
type ProviderCatalogInput =
| {
explicit: true
directory?: string
catalog?: DirectoryCatalog
}
| {
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: NormalizedProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
if (input.directory && input.catalog?.ready) return input.catalog.providers
if (input.explicit) return emptyProviderCatalog
return input.global
}

View file

@ -2,7 +2,8 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import { createMemo } from "solid-js" import type { Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [ export const popularProviders = [
"opencode", "opencode",
@ -16,16 +17,25 @@ export const popularProviders = [
] ]
const popularProviderSet = new Set(popularProviders) const popularProviderSet = new Set(popularProviders)
export function useProviders() { export function useProviders(directory?: Accessor<string | undefined>) {
const serverSync = useServerSync() const serverSync = useServerSync()
const params = useParams() const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "") const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => { const providers = () => {
if (dir()) { const value = dir()
const [projectStore] = serverSync().child(dir()) const projectStore = value ? serverSync().child(value)[0] : undefined
if (projectStore.provider_ready) return projectStore.provider if (directory)
} return selectProviderCatalog({
return serverSync().data.provider explicit: true,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
})
return selectProviderCatalog({
explicit: false,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
global: serverSync().data.provider,
})
} }
return { return {
all: () => providers().all, all: () => providers().all,

View file

@ -58,14 +58,18 @@ export function DirectoryDataProvider(
}) })
return ( return (
<DataProvider <Show when={directory()} keyed>
data={sync().data} {(directory) => (
directory={directory()} <DataProvider
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} data={sync().data}
onSessionHref={href} directory={directory}
> onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
<LocalProvider>{props.children}</LocalProvider> onSessionHref={href}
</DataProvider> >
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)}
</Show>
) )
} }

View file

@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
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">
@ -75,6 +76,35 @@ 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 serverSync = useServerSync() const serverSync = useServerSync()
const layout = useLayout() const layout = useLayout()
@ -94,6 +124,7 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
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(() => {
@ -256,9 +287,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,
}) })
@ -282,8 +311,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
@ -549,8 +579,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 },
@ -1127,27 +1156,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()
} }
@ -1218,23 +1257,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)
@ -1252,6 +1281,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
@ -1272,7 +1302,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)
}, },
})) }))
@ -1361,25 +1391,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)
})
}, },
})) }))
@ -1388,39 +1416,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)
})
}, },
})) }))

View file

@ -70,7 +70,7 @@ export function SessionComposerRegion(props: {
const sdk = useSDK() const sdk = useSDK()
const queryOptions = useQueryOptions() const queryOptions = useQueryOptions()
const local = useLocal() const local = useLocal()
const providers = useProviders() const providers = useProviders(() => sdk().directory)
const settings = useSettings() const settings = useSettings()
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()

View 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()
},
}
},
}
}

View file

@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route" import { requireServerKey } from "@/utils/session-route"
import { createSessionOwnership } from "./session-ownership"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionTabs = useTabs() const sessionTabs = useTabs()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() 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 info = () => {
const id = params.id const id = params.id
@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const openFile = () => { const openFile = () => {
void import("@/components/dialog-select-file").then((x) => { void openDialog(
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />) () => import("@/components/dialog-select-file"),
}) (x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
)
} }
const closeTab = () => { const closeTab = () => {
@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const chooseModel = () => { const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => { void openDialog(
dialog.show(() => <x.DialogSelectModel model={local.model} />) () => import("@/components/dialog-select-model"),
}) (x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
)
} }
const chooseMcp = () => { const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => { void openDialog(
dialog.show(() => <x.DialogSelectMcp />) () => import("@/components/dialog-select-mcp"),
}) (x) => dialog.show(() => <x.DialogSelectMcp />),
)
} }
const toggleAutoAccept = () => { const toggleAutoAccept = () => {
@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const undo = async () => { const undo = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const owner = sessionOwnership.capture()
if (sync().data.session_working(params.id ?? "")) { const client = sdk().client
await sdk() const directory = sdk().directory
.client.session.abort({ sessionID }) const promptSession = prompt.capture()
.catch(() => {})
}
const revert = info()?.revert?.messageID 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 if (!message) return
await sdk().client.session.revert({ sessionID, messageID: message.id })
const parts = sync().data.part[message.id] const parts = sync().data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk().directory }) if (sync().data.session_working(sessionID)) {
prompt.set(restored) await client.session.abort({ sessionID }).catch(() => {})
} }
const prev = findLast(userMessages(), (x) => x.id < message.id) await runCommand({
setActiveMessage(prev) 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 redo = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
const owner = sessionOwnership.capture()
const client = sdk().client
const messages = userMessages()
const promptSession = prompt.capture()
const revertMessageID = info()?.revert?.messageID const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID) const next = messages.find((x) => x.id > revertMessageID)
if (!next) { if (!next) {
await sdk().client.session.unrevert({ sessionID }) await runCommand({
prompt.reset() owner,
const last = findLast(userMessages(), (x) => x.id >= revertMessageID) prompt: promptSession,
setActiveMessage(last) request: () => client.session.unrevert({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
return return
} }
await sdk().client.session.revert({ sessionID, messageID: next.id }) await runCommand({
const prev = findLast(userMessages(), (x) => x.id < next.id) owner,
setActiveMessage(prev) prompt: promptSession,
request: () => client.session.revert({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
} }
const compact = async () => { const compact = async () => {
@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
const fork = () => { const fork = () => {
void import("@/components/dialog-fork").then((x) => { void openDialog(
dialog.show(() => <x.DialogFork />) () => import("@/components/dialog-fork"),
}) (x) => dialog.show(() => <x.DialogFork />),
)
} }
const shareCmds = () => { const shareCmds = () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route" import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route"
describe("session routes", () => { describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => { test("builds and decodes a server-keyed session route", () => {
@ -45,4 +45,10 @@ describe("session routes", () => {
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child") expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
}) })
test("ignores a resolved lineage retained from the previous route", () => {
const previous = { session: { id: "A" }, root: { id: "A" } }
expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
})
}) })

View file

@ -18,6 +18,15 @@ export function requireServerKey(segment: string | undefined) {
type SessionParent = { id: string; parentID?: string } type SessionParent = { id: string; parentID?: string }
export function selectSessionLineage<T extends { session: { id: string } }>(
sessionID: string,
cached: T | undefined,
resolved: T | undefined,
) {
if (cached?.session.id === sessionID) return cached
if (resolved?.session.id === sessionID) return resolved
}
export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) { export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
const seen = new Set([session.id]) const seen = new Set([session.id])
let current = session let current = session

View file

@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
active = "B"
await pending
expect(images(sessions.A)).toHaveLength(1)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
test("finishes the captured attachment after the active editor is removed", async () => {
await createRoot(async (dispose) => {
const prompt = createPromptState()
let editor: HTMLDivElement | undefined = document.createElement("div")
const attachments = createPromptAttachmentsCore({
capture: prompt.capture,
editor: () => editor,
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
editor = undefined
await pending
expect(images(prompt)).toHaveLength(1)
dispose()
})
})
test("keeps every file in a batch on the session where the batch started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addAttachments([
new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }),
new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }),
])
active = "B"
await pending
expect(images(sessions.A)).toHaveLength(2)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
test("keeps a delayed native clipboard image on the session where paste started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
const read = Promise.withResolvers<File | null>()
let active: "A" | "B" = "A"
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
})
const pending = attachments.addClipboardAttachment(read.promise)
active = "B"
read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" }))
await pending
expect(images(sessions.A)).toHaveLength(1)
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
})
})
function images(prompt: ReturnType<typeof createPromptState>) {
return prompt.current().filter((part) => part.type === "image")
}

View file

@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { selectPromptTab } from "@/context/prompt"
import type { Tab } from "@/context/tabs"
test("selects the explicitly scoped session tab instead of the active tab", () => {
const server = ServerConnection.Key.make("local")
const tabs: Tab[] = [
{ type: "session", server, sessionId: "A" },
{ type: "session", server, sessionId: "B" },
]
expect(selectPromptTab(tabs, { dir: "repo", id: "B" }, server)).toBe(tabs[1])
})

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import { createPromptState } from "@/context/prompt"
import { createPromptSubmissionState } from "@/components/prompt-input/submission-state"
describe("prompt submission state", () => {
test("keeps failed submission restoration with the prompt where it started", () => {
const target = createPromptState()
const submission = createPromptSubmissionState({
target,
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
expect(submission.restore()).toEqual({
target,
prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
})
test("moves first-submit restoration and context to the promoted session", () => {
const draft = createPromptState()
const session = createPromptState()
const submission = createPromptSubmissionState({
target: draft,
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
submission.retarget(session)
expect(submission.restore()).toEqual({
target: session,
prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
})
expect(session.context.items()).toHaveLength(1)
expect(session.context.items()[0]).toMatchObject({ type: "file", path: "src/index.ts" })
})
test("does not restore over a prompt edited after submission", () => {
const target = createPromptState()
target.set([{ type: "text", content: "submitted", start: 0, end: 9 }])
const submission = createPromptSubmissionState({
target,
prompt: target.current(),
context: [],
})
submission.clear()
target.set([{ type: "text", content: "new draft", start: 0, end: 9 }])
expect(submission.restore()).toBeUndefined()
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
})
})

View file

@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createPromptInputTransientState } from "@/components/prompt-input/transient-state"
test("resets transient prompt input state when the prompt session changes", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("A")
const [state, setState] = createPromptInputTransientState(identity, 3)
setState({
popover: "slash",
historyIndex: 2,
savedPrompt: {
prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }],
comments: [],
},
draggingType: "image",
mode: "shell",
applyingHistory: true,
variantOpen: true,
})
setIdentity("B")
expect(state).toMatchObject({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder: 3,
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
dispose()
})
})

View file

@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionOwnership } from "@/pages/session/session-ownership"
describe("createSessionOwnership", () => {
test("invalidates captured work when its Solid owner is disposed", () => {
let current = true
createRoot((dispose) => {
const owner = createSessionOwnership(() => "A").capture()
dispose()
current = owner.current()
})
expect(current).toBe(false)
})
test("does not run a continuation after navigation", () => {
createRoot((dispose) => {
const [session, setSession] = createSignal("A")
const owner = createSessionOwnership(session).capture()
let ran = false
setSession("B")
owner.run(() => {
ran = true
})
expect(ran).toBe(false)
dispose()
})
})
test("does not revive a continuation after A to B to A navigation", () => {
createRoot((dispose) => {
const [session, setSession] = createSignal("A")
const owner = createSessionOwnership(session).capture()
setSession("B")
setSession("A")
expect(owner.current()).toBe(false)
dispose()
})
})
})

View file

@ -1 +1,5 @@
["client-error.ts", "client.ts", "index.ts"] [
"client-error.ts",
"client.ts",
"index.ts"
]

View file

@ -1 +1,6 @@
["client-error.ts", "client.ts", "index.ts", "types.ts"] [
"client-error.ts",
"client.ts",
"index.ts",
"types.ts"
]