diff --git a/packages/app/package.json b/packages/app/package.json index 1c5d3a80bf..2fcba573d8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,9 +17,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "bun run test:unit && bun run test:virtualizer", + "test": "bun run test:unit && bun run test:browser", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", - "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", + "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 181f7c1594..19c8d93361 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -51,7 +51,7 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route" +import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route" import Session from "@/pages/session" import { NewHome, LegacyHome } from "@/pages/home" @@ -95,7 +95,7 @@ const TargetSessionRoute = () => { }) return ( - + @@ -119,7 +119,7 @@ function ResolvedTargetSessionRoute() { }, ({ id, sync }) => sync.session.lineage.resolve(id), ) - const current = createMemo(() => cached() ?? resolved()) + const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) const directory = createMemo(() => current()?.session.directory) const targetDirectory = () => directory()! @@ -134,7 +134,7 @@ function ResolvedTargetSessionRoute() { return ( params.id}> - }> + }> - {props.children} + {props.children} @@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - {props.children} + {props.children} ) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 76da7d5b42..0fb89d29f9 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -export function DialogConnectProvider(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() - const providers = useProviders() + const providers = useProviders(props.directory) const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index f2b0432dde..647e5002a2 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" +import { type Accessor, batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" @@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + directory?: Accessor } export function DialogCustomProvider(props: Props) { @@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) { dialog.close() return } - dialog.show(() => ) + dialog.show(() => ) } const addModel = () => { diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad..a0821ef1a3 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "./dialog-select-provider" +import { decode64 } from "@/utils/base64" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() + const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81..bcb4a2fbcb 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,24 +10,27 @@ import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" type ModelState = ReturnType["model"] export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { - const model = props.model ?? useLocal().model + const local = useLocal() + const model = props.model ?? local.model const dialog = useDialog() - const providers = useProviders() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) const language = useLanguage() const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5..e16d9a6657 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const local = useLocal() + const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const language = useLanguage() @@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() + const local = useLocal() + const directory = () => decode64(local.slug()) const provider = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 89310286da..ff0ab5d2bf 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -1,4 +1,4 @@ -import { Component, Show } from "solid-js" +import { type Accessor, Component, Show } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" @@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" -export const DialogSelectProvider: Component = () => { +export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") @@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => { onSelect={(x) => { if (!x) return if (x.id === CUSTOM_ID) { - dialog.show(() => ) + dialog.show(() => ) return } - dialog.show(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 98e3931372..302bf35c35 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" +import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { pathKey } from "@/utils/path-key" @@ -346,25 +347,10 @@ export const PromptInput: Component = (props) => { prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) const [picker, setPicker] = createStore({ projectOpen: false, projectSearch: "", diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 3087c774b7..82b2d906a8 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) { }) } +type PromptTarget = Pick["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 + getPathForFile?: (file: File) => string +} + type PromptAttachmentsInput = { prompt: ReturnType editor: () => HTMLDivElement | undefined @@ -36,27 +49,22 @@ type PromptAttachmentsInput = { getPathForFile?: (file: File) => string } -export function createPromptAttachments(input: PromptAttachmentsInput) { - const prompt = input.prompt - const language = useLanguage() - - const warn = () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) +export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { + const capture = (): AttachmentTarget | undefined => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) } } - const add = async (file: File, toast = true) => { + const add = async (file: File, toast = true, target = capture()) => { + if (!target) return false const mime = await attachmentMime(file) if (!mime) { - if (toast) warn() + if (toast) input.warn?.() return false } - const editor = input.editor() - if (!editor) return false - const url = await dataUrl(file, mime) if (!url) return false @@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { mime, dataUrl: url, } - const cursor = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursor) + target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true } const addAttachment = (file: File) => add(file) - const addAttachments = async (files: File[], toast = true) => { + const addAttachments = async (files: File[], toast = true, target = capture()) => { let found = false for (const file of files) { - const ok = await add(file, false) + const ok = await add(file, false, target) if (ok) found = true } - if (!found && files.length > 0 && toast) warn() + if (!found && files.length > 0 && toast) input.warn?.() return found } + const addClipboardAttachment = async (pending: Promise, target = capture()) => { + const file = await pending + if (!file) return false + return add(file, true, target) + } + const removeAttachment = (id: string) => { - const current = prompt.current() + const target = input.capture() + const current = target.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) + target.set(next, target.cursor()) } const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return + const target = capture() + if (!target) return event.preventDefault() event.stopPropagation() @@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { }) if (files.length > 0) { - await addAttachments(files) + await addAttachments(files, true, target) return } @@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file) { - await addAttachment(file) - return - } + if (await addClipboardAttachment(input.readClipboardImage(), target)) return } if (!plainText) return @@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const text = normalizePaste(plainText) const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor?.() + return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false } if (pasteMode(text) === "manual") { @@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { put() } + return { + addAttachment, + addAttachments, + addClipboardAttachment, + removeAttachment, + handlePaste, + } +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const language = useLanguage() + const attachments = createPromptAttachmentsCore({ + ...input, + capture: input.prompt.capture, + warn: () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + }, + }) + const handleGlobalDragOver = (event: DragEvent) => { if (input.isDialogActive()) return @@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return - await addAttachments(Array.from(dropped)) + await attachments.addAttachments(Array.from(dropped)) } onMount(() => { @@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { makeEventListener(document, "drop", handleGlobalDrop) }) - return { - addAttachment, - addAttachments, - removeAttachment, - handlePaste, - } + return attachments } diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts new file mode 100644 index 0000000000..ebc4c50715 --- /dev/null +++ b/packages/app/src/components/prompt-input/submission-state.ts @@ -0,0 +1,31 @@ +import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt" + +type PromptTarget = ReturnType["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 } + }, + } +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 43bdafefa4..6e940040c7 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -41,6 +41,7 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, + capture: () => prompt, } const clientFor = (directory: string) => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 814c7c8409..2a23a560a0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" -import type { FileSelection } from "@/context/file" import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" @@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" +import { createPromptSubmissionState } from "./submission-state" type PendingPrompt = { abort: AbortController @@ -194,15 +194,6 @@ type PromptSubmitInput = { onSubmit?: () => void } -type CommentItem = { - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - export function createPromptSubmit(input: PromptSubmitInput) { const navigate = useNavigate() const sdk = useSDK() @@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch(() => {}) } - const restoreCommentItems = (items: CommentItem[]) => { + const restoreCommentItems = ( + target: ReturnType["capture"]>, + items: (ContextItem & { key: string })[], + ) => { for (const item of items) { - prompt.context.add({ + target.context.add({ type: "file", path: item.path, selection: item.selection, @@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const removeCommentItems = (items: { key: string }[]) => { - for (const item of items) { - prompt.context.remove(item.key) - } - } - - const clearContext = () => { - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) + const clearContext = (target: ReturnType["capture"]>) => { + for (const item of target.context.items()) { + target.context.remove(item.key) } } @@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() - const currentPrompt = prompt.current() + const target = prompt.capture() + const submission = createPromptSubmissionState({ + target, + prompt: target.current(), + context: target.context.items().slice(), + }) + const currentPrompt = submission.prompt + const context = submission.context const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() @@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const draftID = search.draftId if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) } } if (!session) { @@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { providerID: currentModel.provider.id, } const agent = currentAgent.name - const context = prompt.context.items().slice() const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, @@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const clearInput = () => { - prompt.reset() + submission.clear() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { - prompt.set(currentPrompt, input.promptLength(currentPrompt)) + const restored = submission.restore() + if (!restored) return false + restored.target.set(restored.prompt, input.promptLength(restored.prompt)) + if (!submission.current(prompt.capture())) return true input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { @@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { setCursorPosition(editor, input.promptLength(currentPrompt)) input.queueScroll() }) + return true } if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { input.onQueue?.(draft) - clearContext() + clearContext(submission.target()) clearInput() return } @@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - removeCommentItems(commentItems) + for (const item of commentItems) submission.target().context.remove(item.key) clearInput() const waitForWorktree = async () => { @@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) } pending.set(pendingKey(session.id), { abort: controller, cleanup }) @@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: errorMessage(err), }) removeOptimisticMessage() - restoreCommentItems(commentItems) - restoreInput() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts new file mode 100644 index 0000000000..c8abf9a766 --- /dev/null +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -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) { + setStore({ + popover: null, + historyIndex: -1, + savedPrompt: null, + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) +} + +export function createPromptInputTransientState(identity: Accessor, placeholder: number) { + const [store, setStore] = createStore({ + 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 +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c61afb262c..95a76d6e84 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const file = useFile() const layout = useLayout() const language = useLanguage() - const providers = useProviders() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index a44bfa2ec7..e5fa48a914 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" @@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = [] export function SessionContextTab() { const sync = useSync() const language = useLanguage() - const providers = useProviders() + const sdk = useSDK() + const providers = useProviders(() => sdk().directory) const { params, view } = useSessionLayout() const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 45cdb611f4..68e6b19cef 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -1,5 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" import { createMemo } from "solid-js" import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" @@ -73,6 +73,10 @@ export const createDirSyncContext = ( if (match.found) return serverSync.data.project[match.index] }, session: { + remember(session: Session) { + serverSync.session.remember(session) + index(session.id) + }, get(sessionID: string) { const session = serverSync.session.get(sessionID) if (session?.directory === directory) return session diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 6b76845723..9a16b4edf6 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const sync = useSync() const serverSDK = useServerSDK() - const providers = useProviders() + const providers = useProviders(() => sdk().directory) const models = useModels() const id = createMemo(() => params.id || undefined) diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 21816ab12d..322a514a78 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -1,4 +1,4 @@ -import { createMemo } from "solid-js" +import { type Accessor, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { DateTime } from "luxon" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" @@ -25,8 +25,8 @@ function modelKey(model: ModelKey) { export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ name: "Models", gate: false, - init: () => { - const providers = useProviders() + init: (props: { directory?: Accessor } = {}) => { + const providers = useProviders(props.directory) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 682439e925..104d4f839a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { useServer } from "./server" +import { ServerConnection, useServer } from "./server" import { requireServerKey } from "@/utils/session-route" import { useSettings } from "./settings" @@ -169,6 +169,15 @@ type PromptStore = { type Scope = { draftID: string } | { dir: string; id?: string } +export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) { + if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) + if (!scope.id) return + return ( + tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ?? + ({ type: "session", server, sessionId: scope.id } satisfies Tab) + ) +} + function scopeKey(scope: Scope) { if ("draftID" in scope) return `draft:${scope.draftID}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` @@ -213,7 +222,7 @@ function promptStore(): PromptStore { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { const actions = createPromptActions(setStore) - return { + const value = { current: () => store.prompt, cursor: createMemo(() => store.cursor), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), @@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction

value, } + return value } export function createPromptState() { @@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const owner = getOwner() - const tab = createMemo(() => { - if (!settings.general.newLayoutDesigns()) return - if (search.draftId) { - return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId) - } - if (!params.id) return - const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key - return ( - tabs.store.find( - (item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id, - ) ?? { type: "session", server: serverKey, sessionId: params.id } - ) - }) + const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key) + const scope = () => + search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } const load = (scope: Scope) => { - const current = tab() + const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined if (current) { return createTabPromptState(tabs, current, serverSDK().scope, scope) } @@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return entry.value } - const session = createMemo(() => - load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), - ) + const session = createMemo(() => load(scope())) const pick = (scope?: Scope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) return { ready, + capture: (scope?: Scope) => pick(scope).capture(), current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), diff --git a/packages/app/src/hooks/provider-catalog.test.ts b/packages/app/src/hooks/provider-catalog.test.ts new file mode 100644 index 0000000000..04fbe5ab66 --- /dev/null +++ b/packages/app/src/hooks/provider-catalog.test.ts @@ -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) +}) diff --git a/packages/app/src/hooks/provider-catalog.ts b/packages/app/src/hooks/provider-catalog.ts new file mode 100644 index 0000000000..01b966eda6 --- /dev/null +++ b/packages/app/src/hooks/provider-catalog.ts @@ -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 +} diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index fcd567b913..982c26ca97 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -2,7 +2,8 @@ import { useServerSync } from "@/context/server-sync" import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" -import { createMemo } from "solid-js" +import type { Accessor } from "solid-js" +import { selectProviderCatalog } from "./provider-catalog" export const popularProviders = [ "opencode", @@ -16,16 +17,25 @@ export const popularProviders = [ ] const popularProviderSet = new Set(popularProviders) -export function useProviders() { +export function useProviders(directory?: Accessor) { const serverSync = useServerSync() const params = useParams() - const dir = createMemo(() => decode64(params.dir) ?? "") + const dir = () => (directory ? directory() : decode64(params.dir)) const providers = () => { - if (dir()) { - const [projectStore] = serverSync().child(dir()) - if (projectStore.provider_ready) return projectStore.provider - } - return serverSync().data.provider + const value = dir() + const projectStore = value ? serverSync().child(value)[0] : undefined + if (directory) + return selectProviderCatalog({ + explicit: true, + directory: value, + catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider }, + }) + return selectProviderCatalog({ + explicit: false, + directory: value, + catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider }, + global: serverSync().data.provider, + }) } return { all: () => providers().all, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 58b127a057..77c120e57b 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -58,14 +58,18 @@ export function DirectoryDataProvider( }) return ( - navigate(href(sessionID))} - onSessionHref={href} - > - {props.children} - + + {(directory) => ( + navigate(href(sessionID))} + onSessionHref={href} + > + {props.children} + + )} + ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 12960d436d..b7188aff81 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError } from "@/utils/server-errors" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" +import { createSessionOwnership } from "./session/session-ownership" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -75,6 +76,35 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" +const sessionViewState = () => ({ + messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, +}) + +async function runPromptRollbackMutation(input: { + capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } + optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void + request: () => Promise + complete: (result: R) => void + rollback: () => void + fail: (error: unknown) => void +}) { + const prompt = input.capturePrompt() + const previous = prompt.current().slice() + batch(() => input.optimistic(prompt)) + await input + .request() + .then(input.complete) + .catch((error) => { + batch(() => { + input.rollback() + prompt.set(previous) + }) + input.fail(error) + }) +} + export default function Page() { const serverSync = useServerSync() const layout = useLayout() @@ -94,6 +124,7 @@ export default function Page() { const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => { @@ -256,9 +287,7 @@ export default function Page() { ) const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, + ...sessionViewState(), newSessionWorktree: "main", deferRender: false, }) @@ -282,8 +311,9 @@ export default function Page() { const key = sessionKey() if (key !== prev) { setStore("deferRender", true) + const owner = sessionOwnership.capture() requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) + setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) }) } return key @@ -549,8 +579,7 @@ export default function Page() { on( sessionKey, () => { - setStore("messageId", undefined) - setStore("changes", "git") + setStore(sessionViewState()) setUi("pendingMessage", undefined) }, { defer: true }, @@ -1127,27 +1156,37 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = (_done: boolean) => {} - let historyRequest = false + const historyRequests = new Set() let historyContinuationFrame: number | undefined const loadOlder = async () => { - if (historyRequest || historyLoading()) return - historyRequest = true + const owner = sessionOwnership.capture() + if (historyLoading() || historyRequests.has(owner.key)) return + historyRequests.add(owner.key) const before = timeline.messages().length try { - await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + await timeline.history.loadOlder({ + before: () => owner.run(captureHistoryAnchor), + after: (done) => owner.run(() => restoreHistoryAnchor(done)), + }) } finally { - historyRequest = false + historyRequests.delete(owner.key) } - if (timeline.messages().length <= before) return + if (!owner.current() || timeline.messages().length <= before) return if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) historyContinuationFrame = requestAnimationFrame(() => { historyContinuationFrame = undefined - onHistoryScroll() + owner.run(onHistoryScroll) }) } const onHistoryScroll = () => { - if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) + if ( + historyRequests.has(sessionOwnership.key()) || + historyLoading() || + !autoScroll.userScrolled() || + !scroller || + scroller.scrollTop >= 200 + ) return void loadOlder() } @@ -1218,23 +1257,13 @@ export default function Page() { }) } - const merge = (next: NonNullable>) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) + const merge = (next: NonNullable>, target = sync()) => target.session.remember(next) - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync().set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) + const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { + const session = target.session.get(sessionID) + if (!session) return + target.session.remember({ ...session, revert: next }) + } const busy = (sessionID: string) => sync().data.session_working(sessionID) @@ -1252,6 +1281,7 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1272,7 +1302,7 @@ export default function Page() { if (!ok) return setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() + if (input.manual) owner.run(resumeScroll) }, })) @@ -1361,25 +1391,23 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert + const client = sdk().client + const target = sync() + const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (prompt) => { + roll(input.sessionID, { messageID: input.messageID }, target) + prompt.set(value) + }, + request: () => halt(input.sessionID).then(() => client.session.revert(input)), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(input.sessionID, last, target), + fail, }) - await halt(input.sessionID) - .then(() => sdk().client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) @@ -1388,39 +1416,31 @@ export default function Page() { const sessionID = params.id if (!sessionID) return + const client = sdk().client + const target = sync() const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert + const last = target.session.get(sessionID)?.revert - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() + await runPromptRollbackMutation({ + capturePrompt: prompt.capture, + optimistic: (promptSession) => { + roll(sessionID, next ? { messageID: next.id } : undefined, target) + if (next) { + promptSession.set(draft(next.id)) + return + } + promptSession.reset() + }, + request: () => + !next + ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), + complete: (result) => { + if (result.data) merge(result.data, target) + }, + rollback: () => roll(sessionID, last, target), + fail, }) - - const task = !next - ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk().client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) }, })) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index ca4fee8902..a2b32ae2db 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -70,7 +70,7 @@ export function SessionComposerRegion(props: { const sdk = useSDK() const queryOptions = useQueryOptions() const local = useLocal() - const providers = useProviders() + const providers = useProviders(() => sdk().directory) const settings = useSettings() const server = useServer() const tabs = useTabs() diff --git a/packages/app/src/pages/session/session-ownership.ts b/packages/app/src/pages/session/session-ownership.ts new file mode 100644 index 0000000000..831b5faaf0 --- /dev/null +++ b/packages/app/src/pages/session/session-ownership.ts @@ -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(action: () => T) { + transition() + if (generation !== captured) return + return action() + }, + } + }, + } +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index fa35204596..f302643284 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { useTabs } from "@/context/tabs" import { requireServerKey } from "@/utils/session-route" +import { createSessionOwnership } from "./session-ownership" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionTabs = useTabs() const layout = useLayout() const navigate = useNavigate() - const { params, tabs, view } = useSessionLayout() + const { params, sessionKey, tabs, view } = useSessionLayout() + const sessionOwnership = createSessionOwnership(sessionKey) + const openDialog = async (load: () => Promise, show: (value: T) => void) => { + const owner = sessionOwnership.capture() + const value = await load() + owner.run(() => show(value)) + } + const runCommand = async (input: { + owner: ReturnType["capture"]> + prompt: T + request: () => Promise + updatePrompt: (prompt: T) => void + updateViewport: () => void + }) => { + await input.request() + input.updatePrompt(input.prompt) + input.owner.run(input.updateViewport) + } const info = () => { const id = params.id @@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const openFile = () => { - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-file"), + (x) => dialog.show(() => ), + ) } const closeTab = () => { @@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const chooseModel = () => { - void import("@/components/dialog-select-model").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-model"), + (x) => dialog.show(() => ), + ) } const chooseMcp = () => { - void import("@/components/dialog-select-mcp").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-select-mcp"), + (x) => dialog.show(() => ), + ) } const toggleAutoAccept = () => { @@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const undo = async () => { const sessionID = params.id if (!sessionID) return - - if (sync().data.session_working(params.id ?? "")) { - await sdk() - .client.session.abort({ sessionID }) - .catch(() => {}) - } - + const owner = sessionOwnership.capture() + const client = sdk().client + const directory = sdk().directory + const promptSession = prompt.capture() const revert = info()?.revert?.messageID - const message = findLast(userMessages(), (x) => !revert || x.id < revert) + const messages = userMessages() + const message = findLast(messages, (x) => !revert || x.id < revert) if (!message) return - - await sdk().client.session.revert({ sessionID, messageID: message.id }) const parts = sync().data.part[message.id] - if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk().directory }) - prompt.set(restored) + + if (sync().data.session_working(sessionID)) { + await client.session.abort({ sessionID }).catch(() => {}) } - const prev = findLast(userMessages(), (x) => x.id < message.id) - setActiveMessage(prev) + await runCommand({ + owner, + prompt: promptSession, + request: () => client.session.revert({ sessionID, messageID: message.id }), + updatePrompt: (promptSession) => { + if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) + }, + updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)), + }) } const redo = async () => { const sessionID = params.id if (!sessionID) return + const owner = sessionOwnership.capture() + const client = sdk().client + const messages = userMessages() + const promptSession = prompt.capture() const revertMessageID = info()?.revert?.messageID if (!revertMessageID) return - const next = userMessages().find((x) => x.id > revertMessageID) + const next = messages.find((x) => x.id > revertMessageID) if (!next) { - await sdk().client.session.unrevert({ sessionID }) - prompt.reset() - const last = findLast(userMessages(), (x) => x.id >= revertMessageID) - setActiveMessage(last) + await runCommand({ + owner, + prompt: promptSession, + request: () => client.session.unrevert({ sessionID }), + updatePrompt: (promptSession) => promptSession.reset(), + updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), + }) return } - await sdk().client.session.revert({ sessionID, messageID: next.id }) - const prev = findLast(userMessages(), (x) => x.id < next.id) - setActiveMessage(prev) + await runCommand({ + owner, + prompt: promptSession, + request: () => client.session.revert({ sessionID, messageID: next.id }), + updatePrompt: () => undefined, + updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), + }) } const compact = async () => { @@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const fork = () => { - void import("@/components/dialog-fork").then((x) => { - dialog.show(() => ) - }) + void openDialog( + () => import("@/components/dialog-fork"), + (x) => dialog.show(() => ), + ) } const shareCmds = () => { diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index b7230b8f5a..271f2cc703 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/packages/app/src/utils/session-route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { ServerConnection } from "@/context/server" -import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route" +import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route" describe("session routes", () => { test("builds and decodes a server-keyed session route", () => { @@ -45,4 +45,10 @@ describe("session routes", () => { expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child") }) + + test("ignores a resolved lineage retained from the previous route", () => { + const previous = { session: { id: "A" }, root: { id: "A" } } + + expect(selectSessionLineage("B", undefined, previous)).toBeUndefined() + }) }) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts index d4b2759e39..8f040a35f3 100644 --- a/packages/app/src/utils/session-route.ts +++ b/packages/app/src/utils/session-route.ts @@ -18,6 +18,15 @@ export function requireServerKey(segment: string | undefined) { type SessionParent = { id: string; parentID?: string } +export function selectSessionLineage( + 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(session: T, get: (sessionID: string) => Promise) { const seen = new Set([session.id]) let current = session diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts new file mode 100644 index 0000000000..ca7686598e --- /dev/null +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -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() + 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) { + return prompt.current().filter((part) => part.type === "image") +} diff --git a/packages/app/test-browser/prompt-scope.test.ts b/packages/app/test-browser/prompt-scope.test.ts new file mode 100644 index 0000000000..fad19dcff7 --- /dev/null +++ b/packages/app/test-browser/prompt-scope.test.ts @@ -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]) +}) diff --git a/packages/app/test-browser/prompt-submission-state.test.ts b/packages/app/test-browser/prompt-submission-state.test.ts new file mode 100644 index 0000000000..da90c04516 --- /dev/null +++ b/packages/app/test-browser/prompt-submission-state.test.ts @@ -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" }) + }) +}) diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts new file mode 100644 index 0000000000..55ef8d3922 --- /dev/null +++ b/packages/app/test-browser/prompt-transient-state.test.ts @@ -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() + }) +}) diff --git a/packages/app/test-browser/session-ownership.test.ts b/packages/app/test-browser/session-ownership.test.ts new file mode 100644 index 0000000000..2f81c7d595 --- /dev/null +++ b/packages/app/test-browser/session-ownership.test.ts @@ -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() + }) + }) +}) diff --git a/packages/client/src/generated-effect/.httpapi-codegen.json b/packages/client/src/generated-effect/.httpapi-codegen.json index e73015e341..958eb566db 100644 --- a/packages/client/src/generated-effect/.httpapi-codegen.json +++ b/packages/client/src/generated-effect/.httpapi-codegen.json @@ -1 +1,5 @@ -["client-error.ts", "client.ts", "index.ts"] +[ + "client-error.ts", + "client.ts", + "index.ts" +] diff --git a/packages/client/src/generated/.httpapi-codegen.json b/packages/client/src/generated/.httpapi-codegen.json index f09b399f07..25700fc72d 100644 --- a/packages/client/src/generated/.httpapi-codegen.json +++ b/packages/client/src/generated/.httpapi-codegen.json @@ -1 +1,6 @@ -["client-error.ts", "client.ts", "index.ts", "types.ts"] +[ + "client-error.ts", + "client.ts", + "index.ts", + "types.ts" +]