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
parent dc569b5a5f
commit cfd75d62fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 837 additions and 273 deletions

View file

@ -17,9 +17,9 @@
"dev": "vite",
"build": "vite build",
"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: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:e2e": "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 { ErrorPage } from "./pages/error"
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 { NewHome, LegacyHome } from "@/pages/home"
@ -95,7 +95,7 @@ const TargetSessionRoute = () => {
})
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
@ -119,7 +119,7 @@ function ResolvedTargetSessionRoute() {
},
({ 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 targetDirectory = () => directory()!
@ -134,7 +134,7 @@ function ResolvedTargetSessionRoute() {
return (
<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={settings.general.newLayoutDesigns()}
@ -294,7 +294,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
<PermissionProvider directory={props.directory}>
<LayoutProvider>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
)

View file

@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
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 { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
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 serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const providers = useProviders()
const providers = useProviders(props.directory)
const all = () => {
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 { TextField } from "@opencode-ai/ui/text-field"
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 { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
type Props = {
back?: "providers" | "close"
directory?: Accessor<string | undefined>
}
export function DialogCustomProvider(props: Props) {
@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
dialog.close()
return
}
dialog.show(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
}
const addModel = () => {

View file

@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider directory={directory} />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
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 { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type ModelState = ReturnType<typeof useLocal>["model"]
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 providers = useProviders()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const connect = (provider: string) => {
void import("./dialog-connect-provider").then((x) => {
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
})
}
const all = () => {
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 { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
dismiss: null,
})
const dialog = useDialog()
const local = useLocal()
const directory = () => decode64(local.slug())
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
const handleConnectProvider = () => {
close("provider")
void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />)
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
})
}
const language = useLanguage()
@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const directory = () => decode64(local.slug())
const provider = () => {
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 { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
export const DialogSelectProvider: Component = () => {
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
const dialog = useDialog()
const providers = useProviders()
const providers = useProviders(props.directory)
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
onSelect={(x) => {
if (!x) return
if (x.id === CUSTOM_ID) {
dialog.show(() => <DialogCustomProvider back="providers" />)
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
return
}
dialog.show(() => <DialogConnectProvider provider={x.id} />)
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
}}
>
{(i) => (

View file

@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
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"),
)
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
historyIndex: number
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 [store, setStore] = createPromptInputTransientState(
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
const [picker, setPicker] = createStore({
projectOpen: false,
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 = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
getPathForFile?: (file: File) => string
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = input.prompt
const language = useLanguage()
const warn = () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const capture = (): AttachmentTarget | undefined => {
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
}
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)
if (!mime) {
if (toast) warn()
if (toast) input.warn?.()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
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
for (const file of files) {
const ok = await add(file, false)
const ok = await add(file, false, target)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
if (!found && files.length > 0 && toast) input.warn?.()
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 current = prompt.current()
const target = input.capture()
const current = target.current()
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 clipboardData = event.clipboardData
if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault()
event.stopPropagation()
@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
if (files.length > 0) {
await addAttachments(files)
await addAttachments(files, true, target)
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
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file) {
await addAttachment(file)
return
}
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
}
if (!plainText) return
@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const text = normalizePaste(plainText)
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor?.()
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
}
if (pasteMode(text) === "manual") {
@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
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) => {
if (input.isDialogActive()) return
@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
await addAttachments(Array.from(dropped))
await attachments.addAttachments(Array.from(dropped))
}
onMount(() => {
@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
makeEventListener(document, "drop", handleGlobalDrop)
})
return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}
return attachments
}

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

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 { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const providers = useProviders()
const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
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 { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const sync = useSync()
const language = useLanguage()
const providers = useProviders()
const sdk = useSDK()
const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout()
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 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 { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
@ -73,6 +73,10 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.directory === directory) return session

View file

@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const providers = useProviders()
const providers = useProviders(() => sdk().directory)
const models = useModels()
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 { DateTime } from "luxon"
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({
name: "Models",
gate: false,
init: () => {
const providers = useProviders()
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(props.directory)
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),

View file

@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { ServerConnection, useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
@ -169,6 +169,15 @@ type PromptStore = {
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) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
@ -213,7 +222,7 @@ function promptStore(): PromptStore {
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
return {
const value = {
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
},
set: actions.set,
reset: actions.reset,
capture: () => value,
}
return value
}
export function createPromptState() {
@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
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 serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: Scope) => {
const current = tab()
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const session = createMemo(() => load(scope()))
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: Scope) => pick(scope).capture(),
current: () => session().current(),
cursor: () => session().cursor(),
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 { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createMemo } from "solid-js"
import type { Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [
"opencode",
@ -16,16 +17,25 @@ export const popularProviders = [
]
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
export function useProviders(directory?: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
if (dir()) {
const [projectStore] = serverSync().child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync().data.provider
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
if (directory)
return selectProviderCatalog({
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 {
all: () => providers().all,

View file

@ -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>
)
}

View file

@ -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)
})
},
}))

View file

@ -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()

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 { 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 = () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
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", () => {
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")
})
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 }
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>) {
const seen = new Set([session.id])
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"
]