fix(app): separate provider lifetimes and reactive ownership (#33739)
This commit is contained in:
parent
dc569b5a5f
commit
cfd75d62fe
37 changed files with 838 additions and 274 deletions
|
|
@ -17,9 +17,9 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:virtualizer",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ import LegacyLayout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route"
|
||||||
|
|
||||||
import Session from "@/pages/session"
|
import Session from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
|
|
@ -95,7 +95,7 @@ const TargetSessionRoute = () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<ResolvedTargetSessionRoute />
|
<ResolvedTargetSessionRoute />
|
||||||
|
|
@ -119,7 +119,7 @@ function ResolvedTargetSessionRoute() {
|
||||||
},
|
},
|
||||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||||
)
|
)
|
||||||
const current = createMemo(() => cached() ?? resolved())
|
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||||
const directory = createMemo(() => current()?.session.directory)
|
const directory = createMemo(() => current()?.session.directory)
|
||||||
const targetDirectory = () => directory()!
|
const targetDirectory = () => directory()!
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ function ResolvedTargetSessionRoute() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||||
<Show when={directory()}>
|
<Show when={directory()}>
|
||||||
<Show
|
<Show
|
||||||
when={settings.general.newLayoutDesigns()}
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
|
@ -294,7 +294,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||||
<ModelsProvider>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
|
|
@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||||
<ModelsProvider>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
|
@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const all = () => {
|
const all = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { type Accessor, batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
|
@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogCustomProvider(props: Props) {
|
export function DialogCustomProvider(props: Props) {
|
||||||
|
|
@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
|
|
||||||
|
|
@ -10,24 +10,27 @@ import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||||
const model = props.model ?? useLocal().model
|
const local = useLocal()
|
||||||
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const directory = () => decode64(local.slug())
|
||||||
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
const connect = (provider: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = () => {
|
const all = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const local = useLocal()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const close = (dismiss: Dismiss) => {
|
const close = (dismiss: Dismiss) => {
|
||||||
setStore("dismiss", dismiss)
|
setStore("dismiss", dismiss)
|
||||||
|
|
@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
close("provider")
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
|
||||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const local = useLocal()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, Show } from "solid-js"
|
import { type Accessor, Component, Show } from "solid-js"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
|
||||||
export const DialogSelectProvider: Component = () => {
|
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
|
|
@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
if (x.id === CUSTOM_ID) {
|
if (x.id === CUSTOM_ID) {
|
||||||
dialog.show(() => <DialogCustomProvider back="providers" />)
|
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
@ -346,25 +347,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createPromptInputTransientState(
|
||||||
popover: "at" | "slash" | null
|
() => prompt.capture(),
|
||||||
historyIndex: number
|
Math.floor(Math.random() * EXAMPLES.length),
|
||||||
savedPrompt: PromptHistoryEntry | null
|
)
|
||||||
placeholder: number
|
|
||||||
draggingType: "image" | "@mention" | null
|
|
||||||
mode: "normal" | "shell"
|
|
||||||
applyingHistory: boolean
|
|
||||||
variantOpen: boolean
|
|
||||||
}>({
|
|
||||||
popover: null,
|
|
||||||
historyIndex: -1,
|
|
||||||
savedPrompt: null as PromptHistoryEntry | null,
|
|
||||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
|
||||||
draggingType: null,
|
|
||||||
mode: "normal",
|
|
||||||
applyingHistory: false,
|
|
||||||
variantOpen: false,
|
|
||||||
})
|
|
||||||
const [picker, setPicker] = createStore({
|
const [picker, setPicker] = createStore({
|
||||||
projectOpen: false,
|
projectOpen: false,
|
||||||
projectSearch: "",
|
projectSearch: "",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||||
|
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||||
|
|
||||||
|
type PromptAttachmentsCoreInput = {
|
||||||
|
capture: () => PromptTarget
|
||||||
|
editor: () => HTMLDivElement | undefined
|
||||||
|
focusEditor?: () => void
|
||||||
|
addPart?: (part: ContentPart) => boolean
|
||||||
|
warn?: () => void
|
||||||
|
readClipboardImage?: () => Promise<File | null>
|
||||||
|
getPathForFile?: (file: File) => string
|
||||||
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
|
|
@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
|
||||||
getPathForFile?: (file: File) => string
|
getPathForFile?: (file: File) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||||
const prompt = input.prompt
|
const capture = (): AttachmentTarget | undefined => {
|
||||||
const language = useLanguage()
|
const prompt = input.capture()
|
||||||
|
const editor = input.editor()
|
||||||
const warn = () => {
|
if (!editor) return
|
||||||
showToast({
|
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
|
||||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = async (file: File, toast = true) => {
|
const add = async (file: File, toast = true, target = capture()) => {
|
||||||
|
if (!target) return false
|
||||||
const mime = await attachmentMime(file)
|
const mime = await attachmentMime(file)
|
||||||
if (!mime) {
|
if (!mime) {
|
||||||
if (toast) warn()
|
if (toast) input.warn?.()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return false
|
|
||||||
|
|
||||||
const url = await dataUrl(file, mime)
|
const url = await dataUrl(file, mime)
|
||||||
if (!url) return false
|
if (!url) return false
|
||||||
|
|
||||||
|
|
@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
mime,
|
mime,
|
||||||
dataUrl: url,
|
dataUrl: url,
|
||||||
}
|
}
|
||||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||||
prompt.set([...prompt.current(), attachment], cursor)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAttachment = (file: File) => add(file)
|
const addAttachment = (file: File) => add(file)
|
||||||
|
|
||||||
const addAttachments = async (files: File[], toast = true) => {
|
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||||
let found = false
|
let found = false
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ok = await add(file, false)
|
const ok = await add(file, false, target)
|
||||||
if (ok) found = true
|
if (ok) found = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!found && files.length > 0 && toast) warn()
|
if (!found && files.length > 0 && toast) input.warn?.()
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
|
||||||
|
const file = await pending
|
||||||
|
if (!file) return false
|
||||||
|
return add(file, true, target)
|
||||||
|
}
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
const removeAttachment = (id: string) => {
|
||||||
const current = prompt.current()
|
const target = input.capture()
|
||||||
|
const current = target.current()
|
||||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
prompt.set(next, prompt.cursor())
|
target.set(next, target.cursor())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
const target = capture()
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await addAttachments(files)
|
await addAttachments(files, true, target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
|
|
||||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||||
if (input.readClipboardImage && !plainText) {
|
if (input.readClipboardImage && !plainText) {
|
||||||
const file = await input.readClipboardImage()
|
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||||
if (file) {
|
|
||||||
await addAttachment(file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
|
@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const text = normalizePaste(plainText)
|
const text = normalizePaste(plainText)
|
||||||
|
|
||||||
const put = () => {
|
const put = () => {
|
||||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||||
input.focusEditor()
|
input.focusEditor?.()
|
||||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pasteMode(text) === "manual") {
|
if (pasteMode(text) === "manual") {
|
||||||
|
|
@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
put()
|
put()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addAttachment,
|
||||||
|
addAttachments,
|
||||||
|
addClipboardAttachment,
|
||||||
|
removeAttachment,
|
||||||
|
handlePaste,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const attachments = createPromptAttachmentsCore({
|
||||||
|
...input,
|
||||||
|
capture: input.prompt.capture,
|
||||||
|
warn: () => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
if (input.isDialogActive()) return
|
if (input.isDialogActive()) return
|
||||||
|
|
||||||
|
|
@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const dropped = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!dropped) return
|
if (!dropped) return
|
||||||
|
|
||||||
await addAttachments(Array.from(dropped))
|
await attachments.addAttachments(Array.from(dropped))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
makeEventListener(document, "drop", handleGlobalDrop)
|
makeEventListener(document, "drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return attachments
|
||||||
addAttachment,
|
|
||||||
addAttachments,
|
|
||||||
removeAttachment,
|
|
||||||
handlePaste,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
},
|
},
|
||||||
|
capture: () => prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
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 { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, type Accessor } from "solid-js"
|
import { batch, type Accessor } from "solid-js"
|
||||||
import type { FileSelection } from "@/context/file"
|
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
|
|
@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
|
|
@ -194,15 +194,6 @@ type PromptSubmitInput = {
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommentItem = {
|
|
||||||
path: string
|
|
||||||
selection?: FileSelection
|
|
||||||
comment?: string
|
|
||||||
commentID?: string
|
|
||||||
commentOrigin?: "review" | "file"
|
|
||||||
preview?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreCommentItems = (items: CommentItem[]) => {
|
const restoreCommentItems = (
|
||||||
|
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||||
|
items: (ContextItem & { key: string })[],
|
||||||
|
) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
prompt.context.add({
|
target.context.add({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: item.path,
|
path: item.path,
|
||||||
selection: item.selection,
|
selection: item.selection,
|
||||||
|
|
@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCommentItems = (items: { key: string }[]) => {
|
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||||
for (const item of items) {
|
for (const item of target.context.items()) {
|
||||||
prompt.context.remove(item.key)
|
target.context.remove(item.key)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearContext = () => {
|
|
||||||
for (const item of prompt.context.items()) {
|
|
||||||
prompt.context.remove(item.key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const currentPrompt = prompt.current()
|
const target = prompt.capture()
|
||||||
|
const submission = createPromptSubmissionState({
|
||||||
|
target,
|
||||||
|
prompt: target.current(),
|
||||||
|
context: target.context.items().slice(),
|
||||||
|
})
|
||||||
|
const currentPrompt = submission.prompt
|
||||||
|
const context = submission.context
|
||||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
const images = input.imageAttachments().slice()
|
const images = input.imageAttachments().slice()
|
||||||
const mode = input.mode()
|
const mode = input.mode()
|
||||||
|
|
@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const draftID = search.draftId
|
const draftID = search.draftId
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
providerID: currentModel.provider.id,
|
providerID: currentModel.provider.id,
|
||||||
}
|
}
|
||||||
const agent = currentAgent.name
|
const agent = currentAgent.name
|
||||||
const context = prompt.context.items().slice()
|
|
||||||
const draft: FollowupDraft = {
|
const draft: FollowupDraft = {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
sessionDirectory,
|
sessionDirectory,
|
||||||
|
|
@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
prompt.reset()
|
submission.clear()
|
||||||
input.setMode("normal")
|
input.setMode("normal")
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const restoreInput = () => {
|
||||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
const restored = submission.restore()
|
||||||
|
if (!restored) return false
|
||||||
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
|
if (!submission.current(prompt.capture())) return true
|
||||||
input.setMode(mode)
|
input.setMode(mode)
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
input.queueScroll()
|
input.queueScroll()
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
input.onQueue?.(draft)
|
input.onQueue?.(draft)
|
||||||
clearContext()
|
clearContext(submission.target())
|
||||||
clearInput()
|
clearInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCommentItems(commentItems)
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
clearInput()
|
clearInput()
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
const waitForWorktree = async () => {
|
||||||
|
|
@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
sync().set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
restoreCommentItems(commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
restoreInput()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||||
|
|
@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
restoreCommentItems(commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
restoreInput()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const sdk = useSDK()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
|
|
@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = []
|
||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const sdk = useSDK()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
|
|
@ -73,6 +73,10 @@ export const createDirSyncContext = (
|
||||||
if (match.found) return serverSync.data.project[match.index]
|
if (match.found) return serverSync.data.project[match.index]
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
remember(session: Session) {
|
||||||
|
serverSync.session.remember(session)
|
||||||
|
index(session.id)
|
||||||
|
},
|
||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
const session = serverSync.session.get(sessionID)
|
const session = serverSync.session.get(sessionID)
|
||||||
if (session?.directory === directory) return session
|
if (session?.directory === directory) return session
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders()
|
const providers = useProviders(() => sdk().directory)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
|
|
@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
|
||||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||||
name: "Models",
|
name: "Models",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("model", ["model.v1"]),
|
Persist.global("model", ["model.v1"]),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
import { useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
|
|
||||||
|
|
@ -169,6 +169,15 @@ type PromptStore = {
|
||||||
|
|
||||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||||
|
|
||||||
|
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
||||||
|
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||||
|
if (!scope.id) return
|
||||||
|
return (
|
||||||
|
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
|
||||||
|
({ type: "session", server, sessionId: scope.id } satisfies Tab)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function scopeKey(scope: Scope) {
|
function scopeKey(scope: Scope) {
|
||||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||||
|
|
@ -213,7 +222,7 @@ function promptStore(): PromptStore {
|
||||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||||
const actions = createPromptActions(setStore)
|
const actions = createPromptActions(setStore)
|
||||||
|
|
||||||
return {
|
const value = {
|
||||||
current: () => store.prompt,
|
current: () => store.prompt,
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
|
|
@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
|
||||||
},
|
},
|
||||||
set: actions.set,
|
set: actions.set,
|
||||||
reset: actions.reset,
|
reset: actions.reset,
|
||||||
|
capture: () => value,
|
||||||
}
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptState() {
|
export function createPromptState() {
|
||||||
|
|
@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const tab = createMemo<Tab | undefined>(() => {
|
const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
|
||||||
if (!settings.general.newLayoutDesigns()) return
|
const scope = () =>
|
||||||
if (search.draftId) {
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
|
|
||||||
}
|
|
||||||
if (!params.id) return
|
|
||||||
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
|
|
||||||
return (
|
|
||||||
tabs.store.find(
|
|
||||||
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
|
|
||||||
) ?? { type: "session", server: serverKey, sessionId: params.id }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const load = (scope: Scope) => {
|
const load = (scope: Scope) => {
|
||||||
const current = tab()
|
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||||
if (current) {
|
if (current) {
|
||||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||||
}
|
}
|
||||||
|
|
@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() =>
|
const session = createMemo(() => load(scope()))
|
||||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
|
||||||
)
|
|
||||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
|
capture: (scope?: Scope) => pick(scope).capture(),
|
||||||
current: () => session().current(),
|
current: () => session().current(),
|
||||||
cursor: () => session().cursor(),
|
cursor: () => session().cursor(),
|
||||||
dirty: () => session().dirty(),
|
dirty: () => session().dirty(),
|
||||||
|
|
|
||||||
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 { decode64 } from "@/utils/base64"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { createMemo } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
import { selectProviderCatalog } from "./provider-catalog"
|
||||||
|
|
||||||
export const popularProviders = [
|
export const popularProviders = [
|
||||||
"opencode",
|
"opencode",
|
||||||
|
|
@ -16,16 +17,25 @@ export const popularProviders = [
|
||||||
]
|
]
|
||||||
const popularProviderSet = new Set(popularProviders)
|
const popularProviderSet = new Set(popularProviders)
|
||||||
|
|
||||||
export function useProviders() {
|
export function useProviders(directory?: Accessor<string | undefined>) {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
if (dir()) {
|
const value = dir()
|
||||||
const [projectStore] = serverSync().child(dir())
|
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||||
if (projectStore.provider_ready) return projectStore.provider
|
if (directory)
|
||||||
}
|
return selectProviderCatalog({
|
||||||
return serverSync().data.provider
|
explicit: true,
|
||||||
|
directory: value,
|
||||||
|
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
||||||
|
})
|
||||||
|
return selectProviderCatalog({
|
||||||
|
explicit: false,
|
||||||
|
directory: value,
|
||||||
|
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
||||||
|
global: serverSync().data.provider,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,18 @@ export function DirectoryDataProvider(
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider
|
<Show when={directory()} keyed>
|
||||||
data={sync().data}
|
{(directory) => (
|
||||||
directory={directory()}
|
<DataProvider
|
||||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
data={sync().data}
|
||||||
onSessionHref={href}
|
directory={directory}
|
||||||
>
|
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
onSessionHref={href}
|
||||||
</DataProvider>
|
>
|
||||||
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
|
</DataProvider>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
|
import { createSessionOwnership } from "./session/session-ownership"
|
||||||
|
|
||||||
type FollowupItem = FollowupDraft & { id: string }
|
type FollowupItem = FollowupDraft & { id: string }
|
||||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||||
|
|
@ -75,6 +76,35 @@ const emptyFollowups: FollowupItem[] = []
|
||||||
type ChangeMode = "git" | "branch" | "turn"
|
type ChangeMode = "git" | "branch" | "turn"
|
||||||
type VcsMode = "git" | "branch"
|
type VcsMode = "git" | "branch"
|
||||||
|
|
||||||
|
const sessionViewState = () => ({
|
||||||
|
messageId: undefined as string | undefined,
|
||||||
|
mobileTab: "session" as "session" | "changes",
|
||||||
|
changes: "git" as ChangeMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runPromptRollbackMutation<T, R>(input: {
|
||||||
|
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||||
|
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||||
|
request: () => Promise<R>
|
||||||
|
complete: (result: R) => void
|
||||||
|
rollback: () => void
|
||||||
|
fail: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const prompt = input.capturePrompt()
|
||||||
|
const previous = prompt.current().slice()
|
||||||
|
batch(() => input.optimistic(prompt))
|
||||||
|
await input
|
||||||
|
.request()
|
||||||
|
.then(input.complete)
|
||||||
|
.catch((error) => {
|
||||||
|
batch(() => {
|
||||||
|
input.rollback()
|
||||||
|
prompt.set(previous)
|
||||||
|
})
|
||||||
|
input.fail(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -94,6 +124,7 @@ export default function Page() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||||
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -256,9 +287,7 @@ export default function Page() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
...sessionViewState(),
|
||||||
mobileTab: "session" as "session" | "changes",
|
|
||||||
changes: "git" as ChangeMode,
|
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
@ -282,8 +311,9 @@ export default function Page() {
|
||||||
const key = sessionKey()
|
const key = sessionKey()
|
||||||
if (key !== prev) {
|
if (key !== prev) {
|
||||||
setStore("deferRender", true)
|
setStore("deferRender", true)
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setTimeout(() => setStore("deferRender", false), 0)
|
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
|
|
@ -549,8 +579,7 @@ export default function Page() {
|
||||||
on(
|
on(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore(sessionViewState())
|
||||||
setStore("changes", "git")
|
|
||||||
setUi("pendingMessage", undefined)
|
setUi("pendingMessage", undefined)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
|
|
@ -1127,27 +1156,37 @@ export default function Page() {
|
||||||
|
|
||||||
let captureHistoryAnchor = () => {}
|
let captureHistoryAnchor = () => {}
|
||||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||||
let historyRequest = false
|
const historyRequests = new Set<string>()
|
||||||
let historyContinuationFrame: number | undefined
|
let historyContinuationFrame: number | undefined
|
||||||
const loadOlder = async () => {
|
const loadOlder = async () => {
|
||||||
if (historyRequest || historyLoading()) return
|
const owner = sessionOwnership.capture()
|
||||||
historyRequest = true
|
if (historyLoading() || historyRequests.has(owner.key)) return
|
||||||
|
historyRequests.add(owner.key)
|
||||||
const before = timeline.messages().length
|
const before = timeline.messages().length
|
||||||
try {
|
try {
|
||||||
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
await timeline.history.loadOlder({
|
||||||
|
before: () => owner.run(captureHistoryAnchor),
|
||||||
|
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
historyRequest = false
|
historyRequests.delete(owner.key)
|
||||||
}
|
}
|
||||||
if (timeline.messages().length <= before) return
|
if (!owner.current() || timeline.messages().length <= before) return
|
||||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
||||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||||
historyContinuationFrame = requestAnimationFrame(() => {
|
historyContinuationFrame = requestAnimationFrame(() => {
|
||||||
historyContinuationFrame = undefined
|
historyContinuationFrame = undefined
|
||||||
onHistoryScroll()
|
owner.run(onHistoryScroll)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const onHistoryScroll = () => {
|
const onHistoryScroll = () => {
|
||||||
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
if (
|
||||||
|
historyRequests.has(sessionOwnership.key()) ||
|
||||||
|
historyLoading() ||
|
||||||
|
!autoScroll.userScrolled() ||
|
||||||
|
!scroller ||
|
||||||
|
scroller.scrollTop >= 200
|
||||||
|
)
|
||||||
return
|
return
|
||||||
void loadOlder()
|
void loadOlder()
|
||||||
}
|
}
|
||||||
|
|
@ -1218,23 +1257,13 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
|
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
|
||||||
sync().set("session", (list) => {
|
|
||||||
const idx = list.findIndex((item) => item.id === next.id)
|
|
||||||
if (idx < 0) return list
|
|
||||||
const out = list.slice()
|
|
||||||
out[idx] = next
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
|
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
|
||||||
sync().set("session", (list) => {
|
const session = target.session.get(sessionID)
|
||||||
const idx = list.findIndex((item) => item.id === sessionID)
|
if (!session) return
|
||||||
if (idx < 0) return list
|
target.session.remember({ ...session, revert: next })
|
||||||
const out = list.slice()
|
}
|
||||||
out[idx] = { ...out[idx], revert: next }
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||||
|
|
||||||
|
|
@ -1252,6 +1281,7 @@ export default function Page() {
|
||||||
|
|
||||||
const followupMutation = useMutation(() => ({
|
const followupMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
|
||||||
|
|
@ -1272,7 +1302,7 @@ export default function Page() {
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||||
if (input.manual) resumeScroll()
|
if (input.manual) owner.run(resumeScroll)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -1361,25 +1391,23 @@ export default function Page() {
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
const revertMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
const prev = prompt.current().slice()
|
const client = sdk().client
|
||||||
const last = info()?.revert
|
const target = sync()
|
||||||
|
const last = target.session.get(input.sessionID)?.revert
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
batch(() => {
|
await runPromptRollbackMutation({
|
||||||
roll(input.sessionID, { messageID: input.messageID })
|
capturePrompt: prompt.capture,
|
||||||
prompt.set(value)
|
optimistic: (prompt) => {
|
||||||
|
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||||
|
prompt.set(value)
|
||||||
|
},
|
||||||
|
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||||
|
complete: (result) => {
|
||||||
|
if (result.data) merge(result.data, target)
|
||||||
|
},
|
||||||
|
rollback: () => roll(input.sessionID, last, target),
|
||||||
|
fail,
|
||||||
})
|
})
|
||||||
await halt(input.sessionID)
|
|
||||||
.then(() => sdk().client.session.revert(input))
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -1388,39 +1416,31 @@ export default function Page() {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const client = sdk().client
|
||||||
|
const target = sync()
|
||||||
const next = userMessages().find((item) => item.id > id)
|
const next = userMessages().find((item) => item.id > id)
|
||||||
const prev = prompt.current().slice()
|
const last = target.session.get(sessionID)?.revert
|
||||||
const last = info()?.revert
|
|
||||||
|
|
||||||
batch(() => {
|
await runPromptRollbackMutation({
|
||||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
capturePrompt: prompt.capture,
|
||||||
if (next) {
|
optimistic: (promptSession) => {
|
||||||
prompt.set(draft(next.id))
|
roll(sessionID, next ? { messageID: next.id } : undefined, target)
|
||||||
return
|
if (next) {
|
||||||
}
|
promptSession.set(draft(next.id))
|
||||||
prompt.reset()
|
return
|
||||||
|
}
|
||||||
|
promptSession.reset()
|
||||||
|
},
|
||||||
|
request: () =>
|
||||||
|
!next
|
||||||
|
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||||
|
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||||
|
complete: (result) => {
|
||||||
|
if (result.data) merge(result.data, target)
|
||||||
|
},
|
||||||
|
rollback: () => roll(sessionID, last, target),
|
||||||
|
fail,
|
||||||
})
|
})
|
||||||
|
|
||||||
const task = !next
|
|
||||||
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
|
|
||||||
: halt(sessionID).then(() =>
|
|
||||||
sdk().client.session.revert({
|
|
||||||
sessionID,
|
|
||||||
messageID: next.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await task
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function SessionComposerRegion(props: {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const queryOptions = useQueryOptions()
|
const queryOptions = useQueryOptions()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const providers = useProviders()
|
const providers = useProviders(() => sdk().directory)
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
|
||||||
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 { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
|
|
@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sessionTabs = useTabs()
|
const sessionTabs = useTabs()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
|
const value = await load()
|
||||||
|
owner.run(() => show(value))
|
||||||
|
}
|
||||||
|
const runCommand = async <T,>(input: {
|
||||||
|
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
|
||||||
|
prompt: T
|
||||||
|
request: () => Promise<unknown>
|
||||||
|
updatePrompt: (prompt: T) => void
|
||||||
|
updateViewport: () => void
|
||||||
|
}) => {
|
||||||
|
await input.request()
|
||||||
|
input.updatePrompt(input.prompt)
|
||||||
|
input.owner.run(input.updateViewport)
|
||||||
|
}
|
||||||
|
|
||||||
const info = () => {
|
const info = () => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
|
|
@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFile = () => {
|
const openFile = () => {
|
||||||
void import("@/components/dialog-select-file").then((x) => {
|
void openDialog(
|
||||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
() => import("@/components/dialog-select-file"),
|
||||||
})
|
(x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeTab = () => {
|
const closeTab = () => {
|
||||||
|
|
@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const chooseModel = () => {
|
const chooseModel = () => {
|
||||||
void import("@/components/dialog-select-model").then((x) => {
|
void openDialog(
|
||||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
() => import("@/components/dialog-select-model"),
|
||||||
})
|
(x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chooseMcp = () => {
|
const chooseMcp = () => {
|
||||||
void import("@/components/dialog-select-mcp").then((x) => {
|
void openDialog(
|
||||||
dialog.show(() => <x.DialogSelectMcp />)
|
() => import("@/components/dialog-select-mcp"),
|
||||||
})
|
(x) => dialog.show(() => <x.DialogSelectMcp />),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAutoAccept = () => {
|
const toggleAutoAccept = () => {
|
||||||
|
|
@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const undo = async () => {
|
const undo = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
if (sync().data.session_working(params.id ?? "")) {
|
const client = sdk().client
|
||||||
await sdk()
|
const directory = sdk().directory
|
||||||
.client.session.abort({ sessionID })
|
const promptSession = prompt.capture()
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const revert = info()?.revert?.messageID
|
const revert = info()?.revert?.messageID
|
||||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
const messages = userMessages()
|
||||||
|
const message = findLast(messages, (x) => !revert || x.id < revert)
|
||||||
if (!message) return
|
if (!message) return
|
||||||
|
|
||||||
await sdk().client.session.revert({ sessionID, messageID: message.id })
|
|
||||||
const parts = sync().data.part[message.id]
|
const parts = sync().data.part[message.id]
|
||||||
if (parts) {
|
|
||||||
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
|
if (sync().data.session_working(sessionID)) {
|
||||||
prompt.set(restored)
|
await client.session.abort({ sessionID }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
await runCommand({
|
||||||
setActiveMessage(prev)
|
owner,
|
||||||
|
prompt: promptSession,
|
||||||
|
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
||||||
|
updatePrompt: (promptSession) => {
|
||||||
|
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||||
|
},
|
||||||
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const redo = async () => {
|
const redo = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
const owner = sessionOwnership.capture()
|
||||||
|
const client = sdk().client
|
||||||
|
const messages = userMessages()
|
||||||
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
const revertMessageID = info()?.revert?.messageID
|
const revertMessageID = info()?.revert?.messageID
|
||||||
if (!revertMessageID) return
|
if (!revertMessageID) return
|
||||||
|
|
||||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
const next = messages.find((x) => x.id > revertMessageID)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
await sdk().client.session.unrevert({ sessionID })
|
await runCommand({
|
||||||
prompt.reset()
|
owner,
|
||||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
prompt: promptSession,
|
||||||
setActiveMessage(last)
|
request: () => client.session.unrevert({ sessionID }),
|
||||||
|
updatePrompt: (promptSession) => promptSession.reset(),
|
||||||
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk().client.session.revert({ sessionID, messageID: next.id })
|
await runCommand({
|
||||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
owner,
|
||||||
setActiveMessage(prev)
|
prompt: promptSession,
|
||||||
|
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
||||||
|
updatePrompt: () => undefined,
|
||||||
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const compact = async () => {
|
const compact = async () => {
|
||||||
|
|
@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fork = () => {
|
const fork = () => {
|
||||||
void import("@/components/dialog-fork").then((x) => {
|
void openDialog(
|
||||||
dialog.show(() => <x.DialogFork />)
|
() => import("@/components/dialog-fork"),
|
||||||
})
|
(x) => dialog.show(() => <x.DialogFork />),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareCmds = () => {
|
const shareCmds = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
|
import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route"
|
||||||
|
|
||||||
describe("session routes", () => {
|
describe("session routes", () => {
|
||||||
test("builds and decodes a server-keyed session route", () => {
|
test("builds and decodes a server-keyed session route", () => {
|
||||||
|
|
@ -45,4 +45,10 @@ describe("session routes", () => {
|
||||||
|
|
||||||
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
|
expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ignores a resolved lineage retained from the previous route", () => {
|
||||||
|
const previous = { session: { id: "A" }, root: { id: "A" } }
|
||||||
|
|
||||||
|
expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ export function requireServerKey(segment: string | undefined) {
|
||||||
|
|
||||||
type SessionParent = { id: string; parentID?: string }
|
type SessionParent = { id: string; parentID?: string }
|
||||||
|
|
||||||
|
export function selectSessionLineage<T extends { session: { id: string } }>(
|
||||||
|
sessionID: string,
|
||||||
|
cached: T | undefined,
|
||||||
|
resolved: T | undefined,
|
||||||
|
) {
|
||||||
|
if (cached?.session.id === sessionID) return cached
|
||||||
|
if (resolved?.session.id === sessionID) return resolved
|
||||||
|
}
|
||||||
|
|
||||||
export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
|
export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
|
||||||
const seen = new Set([session.id])
|
const seen = new Set([session.id])
|
||||||
let current = session
|
let current = session
|
||||||
|
|
|
||||||
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