mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:38:31 +00:00
fix(app): separate provider lifetimes and reactive ownership (#33739)
This commit is contained in:
parent
dc569b5a5f
commit
cfd75d62fe
37 changed files with 837 additions and 273 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
31
packages/app/src/components/prompt-input/submission-state.ts
Normal file
31
packages/app/src/components/prompt-input/submission-state.ts
Normal 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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ const prompt = {
|
|||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
}
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
43
packages/app/src/components/prompt-input/transient-state.ts
Normal file
43
packages/app/src/components/prompt-input/transient-state.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
59
packages/app/src/hooks/provider-catalog.test.ts
Normal file
59
packages/app/src/hooks/provider-catalog.test.ts
Normal 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)
|
||||
})
|
||||
27
packages/app/src/hooks/provider-catalog.ts
Normal file
27
packages/app/src/hooks/provider-catalog.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -58,14 +58,18 @@ export function DirectoryDataProvider(
|
|||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
<Show when={directory()} keyed>
|
||||
{(directory) => (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
|
|
@ -75,6 +76,35 @@ const emptyFollowups: FollowupItem[] = []
|
|||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
const sessionViewState = () => ({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "git" as ChangeMode,
|
||||
})
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
request: () => Promise<R>
|
||||
complete: (result: R) => void
|
||||
rollback: () => void
|
||||
fail: (error: unknown) => void
|
||||
}) {
|
||||
const prompt = input.capturePrompt()
|
||||
const previous = prompt.current().slice()
|
||||
batch(() => input.optimistic(prompt))
|
||||
await input
|
||||
.request()
|
||||
.then(input.complete)
|
||||
.catch((error) => {
|
||||
batch(() => {
|
||||
input.rollback()
|
||||
prompt.set(previous)
|
||||
})
|
||||
input.fail(error)
|
||||
})
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
|
|
@ -94,6 +124,7 @@ export default function Page() {
|
|||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -256,9 +287,7 @@ export default function Page() {
|
|||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "git" as ChangeMode,
|
||||
...sessionViewState(),
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
|
@ -282,8 +311,9 @@ export default function Page() {
|
|||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
const owner = sessionOwnership.capture()
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
|
|
@ -549,8 +579,7 @@ export default function Page() {
|
|||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "git")
|
||||
setStore(sessionViewState())
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
|
|
@ -1127,27 +1156,37 @@ export default function Page() {
|
|||
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
let historyRequest = false
|
||||
const historyRequests = new Set<string>()
|
||||
let historyContinuationFrame: number | undefined
|
||||
const loadOlder = async () => {
|
||||
if (historyRequest || historyLoading()) return
|
||||
historyRequest = true
|
||||
const owner = sessionOwnership.capture()
|
||||
if (historyLoading() || historyRequests.has(owner.key)) return
|
||||
historyRequests.add(owner.key)
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
await timeline.history.loadOlder({
|
||||
before: () => owner.run(captureHistoryAnchor),
|
||||
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
|
||||
})
|
||||
} finally {
|
||||
historyRequest = false
|
||||
historyRequests.delete(owner.key)
|
||||
}
|
||||
if (timeline.messages().length <= before) return
|
||||
if (!owner.current() || timeline.messages().length <= before) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
onHistoryScroll()
|
||||
owner.run(onHistoryScroll)
|
||||
})
|
||||
}
|
||||
const onHistoryScroll = () => {
|
||||
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
||||
if (
|
||||
historyRequests.has(sessionOwnership.key()) ||
|
||||
historyLoading() ||
|
||||
!autoScroll.userScrolled() ||
|
||||
!scroller ||
|
||||
scroller.scrollTop >= 200
|
||||
)
|
||||
return
|
||||
void loadOlder()
|
||||
}
|
||||
|
|
@ -1218,23 +1257,13 @@ export default function Page() {
|
|||
})
|
||||
}
|
||||
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
out[idx] = next
|
||||
return out
|
||||
})
|
||||
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
|
||||
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
|
||||
sync().set("session", (list) => {
|
||||
const idx = list.findIndex((item) => item.id === sessionID)
|
||||
if (idx < 0) return list
|
||||
const out = list.slice()
|
||||
out[idx] = { ...out[idx], revert: next }
|
||||
return out
|
||||
})
|
||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
|
||||
const session = target.session.get(sessionID)
|
||||
if (!session) return
|
||||
target.session.remember({ ...session, revert: next })
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
|
||||
|
|
@ -1252,6 +1281,7 @@ export default function Page() {
|
|||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
|
||||
|
|
@ -1272,7 +1302,7 @@ export default function Page() {
|
|||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) resumeScroll()
|
||||
if (input.manual) owner.run(resumeScroll)
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -1361,25 +1391,23 @@ export default function Page() {
|
|||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
batch(() => {
|
||||
roll(input.sessionID, { messageID: input.messageID })
|
||||
prompt.set(value)
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (prompt) => {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
await halt(input.sessionID)
|
||||
.then(() => sdk().client.session.revert(input))
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(input.sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -1388,39 +1416,31 @@ export default function Page() {
|
|||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const prev = prompt.current().slice()
|
||||
const last = info()?.revert
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
|
||||
batch(() => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||
if (next) {
|
||||
prompt.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
prompt.reset()
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (promptSession) => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined, target)
|
||||
if (next) {
|
||||
promptSession.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
promptSession.reset()
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
|
||||
const task = !next
|
||||
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() =>
|
||||
sdk().client.session.revert({
|
||||
sessionID,
|
||||
messageID: next.id,
|
||||
}),
|
||||
)
|
||||
|
||||
await task
|
||||
.then((result) => {
|
||||
if (result.data) merge(result.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
batch(() => {
|
||||
roll(sessionID, last)
|
||||
prompt.set(prev)
|
||||
})
|
||||
fail(err)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function SessionComposerRegion(props: {
|
|||
const sdk = useSDK()
|
||||
const queryOptions = useQueryOptions()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
|
|
|||
37
packages/app/src/pages/session/session-ownership.ts
Normal file
37
packages/app/src/pages/session/session-ownership.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createComputed, onCleanup } from "solid-js"
|
||||
|
||||
export function createSessionOwnership(sessionKey: () => string) {
|
||||
let current = sessionKey()
|
||||
let generation = 0
|
||||
const transition = () => {
|
||||
const next = sessionKey()
|
||||
if (next === current) return
|
||||
current = next
|
||||
generation++
|
||||
}
|
||||
createComputed(transition)
|
||||
onCleanup(() => generation++)
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
transition()
|
||||
return `${generation}:${current}`
|
||||
},
|
||||
capture() {
|
||||
transition()
|
||||
const captured = generation
|
||||
return {
|
||||
key: `${captured}:${current}`,
|
||||
current: () => {
|
||||
transition()
|
||||
return generation === captured
|
||||
},
|
||||
run<T>(action: () => T) {
|
||||
transition()
|
||||
if (generation !== captured) return
|
||||
return action()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
|
|
@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const sessionTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const value = await load()
|
||||
owner.run(() => show(value))
|
||||
}
|
||||
const runCommand = async <T,>(input: {
|
||||
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
|
||||
prompt: T
|
||||
request: () => Promise<unknown>
|
||||
updatePrompt: (prompt: T) => void
|
||||
updateViewport: () => void
|
||||
}) => {
|
||||
await input.request()
|
||||
input.updatePrompt(input.prompt)
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
|
||||
const info = () => {
|
||||
const id = params.id
|
||||
|
|
@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const openFile = () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-file"),
|
||||
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
|
|
@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-model"),
|
||||
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
||||
)
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-mcp"),
|
||||
(x) => dialog.show(() => <x.DialogSelectMcp />),
|
||||
)
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
|
|
@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (sync().data.session_working(params.id ?? "")) {
|
||||
await sdk()
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
const messages = userMessages()
|
||||
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk().client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync().data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
|
||||
prompt.set(restored)
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
|
||||
})
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const client = sdk().client
|
||||
const messages = userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
const next = messages.find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk().client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.unrevert({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sdk().client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||
})
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
|
|
@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const fork = () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
)
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
packages/app/test-browser/prompt-attachments.test.ts
Normal file
90
packages/app/test-browser/prompt-attachments.test.ts
Normal 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")
|
||||
}
|
||||
14
packages/app/test-browser/prompt-scope.test.ts
Normal file
14
packages/app/test-browser/prompt-scope.test.ts
Normal 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])
|
||||
})
|
||||
56
packages/app/test-browser/prompt-submission-state.test.ts
Normal file
56
packages/app/test-browser/prompt-submission-state.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
36
packages/app/test-browser/prompt-transient-state.test.ts
Normal file
36
packages/app/test-browser/prompt-transient-state.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
45
packages/app/test-browser/session-ownership.test.ts
Normal file
45
packages/app/test-browser/session-ownership.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1 +1,5 @@
|
|||
["client-error.ts", "client.ts", "index.ts"]
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
["client-error.ts", "client.ts", "index.ts", "types.ts"]
|
||||
[
|
||||
"client-error.ts",
|
||||
"client.ts",
|
||||
"index.ts",
|
||||
"types.ts"
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue