Prechádzať zdrojové kódy

fix(app): prompt persistence and draft sessions (#33528)

Brendan Allan 1 mesiac pred
rodič
commit
e04c5e72f7

+ 3 - 1
packages/app/src/app.tsx

@@ -185,7 +185,9 @@ function TargetDirectoryLayout(props: ParentProps) {
     if (!search.draftId) return undefined
     if (!search.draftId) return undefined
     return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
     return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
   })
   })
-  const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
+  const directory = createMemo<string | undefined>((prev) =>
+    search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
+  )
   const home = () => !params.serverKey && !search.draftId
   const home = () => !params.serverKey && !search.draftId
   const targetDirectory = () => directory()!
   const targetDirectory = () => directory()!
 
 

+ 1 - 1
packages/app/src/components/prompt-input.tsx

@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
   const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
 
 
   const [promptReady] = createResource(
   const [promptReady] = createResource(
-    () => prompt.ready().promise,
+    () => prompt.ready.promise,
     (p) => p,
     (p) => p,
   )
   )
 
 

+ 1 - 1
packages/app/src/components/prompt-input/submit.test.ts

@@ -27,7 +27,7 @@ let variant: string | undefined
 
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
 const prompt = {
 const prompt = {
-  ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
+  ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
   current: () => promptValue,
   current: () => promptValue,
   cursor: () => 0,
   cursor: () => 0,
   dirty: () => true,
   dirty: () => true,

+ 24 - 13
packages/app/src/components/titlebar.tsx

@@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { useSettings } from "@/context/settings"
 import { WindowsAppMenu } from "./windows-app-menu"
 import { WindowsAppMenu } from "./windows-app-menu"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
-import { base64Encode } from "@opencode-ai/core/util/encode"
 import { projectForSession } from "@/pages/layout/helpers"
 import { projectForSession } from "@/pages/layout/helpers"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { makeEventListener } from "@solid-primitives/event-listener"
@@ -264,15 +263,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
             const serverSdk = useServerSDK()
             const serverSdk = useServerSDK()
             const navigate = useNavigate()
             const navigate = useNavigate()
             const layout = useLayout()
             const layout = useLayout()
-
-            const newSessionHref = () => {
-              if (params.dir) return `/${params.dir}/session`
-
-              const project = layout.projects.list()[0]
-              if (!project) return "/"
-
-              return `/${base64Encode(project.worktree)}/session`
-            }
+            const global = useGlobal()
 
 
             const tabs = useTabs()
             const tabs = useTabs()
             const tabsStore = tabs.store
             const tabsStore = tabs.store
@@ -337,7 +328,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               tabsStoreActions.removeSessions(detail)
               tabsStoreActions.removeSessions(detail)
             })
             })
 
 
-            const openNewTab = () => navigate(newSessionHref())
+            const openNewTab = () => {
+              const route = layout.route()
+              const activeSession = session()
+              if (route.type === "session" && activeSession) {
+                tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
+                return
+              }
+
+              const current = layout.projects.list()[0]
+              if (current) {
+                tabs.newDraft({ server: server.key, directory: current.worktree }, "")
+                return
+              }
+
+              const fallback = global.servers.list().flatMap((conn) => {
+                const project = global.createServerCtx(conn).projects.list()[0]
+                return project ? [{ server: ServerConnection.key(conn), project }] : []
+              })[0]
+              if (!fallback) return
+
+              tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
+            }
             const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
             const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
 
 
             command.register("titlebar-home", () => [
             command.register("titlebar-home", () => [
@@ -592,8 +604,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     size="large"
                     size="large"
                     class="shrink-0"
                     class="shrink-0"
                     icon={<IconV2 name="plus" />}
                     icon={<IconV2 name="plus" />}
-                    as="a"
-                    href={newSessionHref()}
+                    onClick={openNewTab}
                     aria-label={language.t("command.session.new")}
                     aria-label={language.t("command.session.new")}
                   />
                   />
                 </Show>
                 </Show>

+ 11 - 4
packages/app/src/context/prompt.tsx

@@ -1,7 +1,7 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
 import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
 import { useParams, useSearchParams } from "@solidjs/router"
 import { useParams, useSearchParams } from "@solidjs/router"
-import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
+import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
 import { createStore, type SetStoreFunction } from "solid-js/store"
 import { createStore, type SetStoreFunction } from "solid-js/store"
 import type { FileSelection } from "@/context/file"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
@@ -181,7 +181,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
   return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
   return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
 }
 }
 
 
-function createPromptSession(serverScope: ServerScope, scope: Scope) {
+export function createPromptSession(serverScope: ServerScope, scope: Scope) {
   const [store, setStore, _, ready] = persisted(
   const [store, setStore, _, ready] = persisted(
     promptTarget(serverScope, scope),
     promptTarget(serverScope, scope),
     createStore<PromptStore>(promptStore()),
     createStore<PromptStore>(promptStore()),
@@ -190,6 +190,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
   return { ready, ...createPromptStateValue(store, setStore) }
   return { ready, ...createPromptStateValue(store, setStore) }
 }
 }
 
 
+export function createPromptReady(session: Accessor<PromptSession>) {
+  return Object.defineProperty(() => session().ready(), "promise", {
+    get: () => session().ready.promise,
+  }) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
+}
+
 function promptStore(): PromptStore {
 function promptStore(): PromptStore {
   return {
   return {
     prompt: clonePrompt(DEFAULT_PROMPT),
     prompt: clonePrompt(DEFAULT_PROMPT),
@@ -247,7 +253,7 @@ export function createPromptState() {
   const [store, setStore] = createStore<PromptStore>(promptStore())
   const [store, setStore] = createStore<PromptStore>(promptStore())
   const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
   const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
   return {
   return {
-    ready: () => ready,
+    ready,
     ...createPromptStateValue(store, setStore),
     ...createPromptStateValue(store, setStore),
   }
   }
 }
 }
@@ -308,9 +314,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
       load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
       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)
 
 
     return {
     return {
-      ready: () => session().ready,
+      ready,
       current: () => session().current(),
       current: () => session().current(),
       cursor: () => session().cursor(),
       cursor: () => session().cursor(),
       dirty: () => session().dirty(),
       dirty: () => session().dirty(),

+ 2 - 2
packages/app/src/pages/layout-new.tsx

@@ -1,4 +1,4 @@
-import { createEffect, type ParentProps } from "solid-js"
+import { createEffect, Suspense, type ParentProps } from "solid-js"
 import { useNavigate } from "@solidjs/router"
 import { useNavigate } from "@solidjs/router"
 import { DebugBar } from "@/components/debug-bar"
 import { DebugBar } from "@/components/debug-bar"
 import { HelpButton } from "@/components/help-button"
 import { HelpButton } from "@/components/help-button"
@@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
     <div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
     <div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
       <Titlebar update={update} />
       <Titlebar update={update} />
       <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
       <main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
-        {props.children}
+        <Suspense>{props.children}</Suspense>
       </main>
       </main>
       {import.meta.env.DEV && <DebugBar />}
       {import.meta.env.DEV && <DebugBar />}
       <HelpButton />
       <HelpButton />

+ 43 - 10
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -1,4 +1,4 @@
-import { Show, createEffect, createMemo, onCleanup } from "solid-js"
+import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { useNavigate, useSearchParams } from "@solidjs/router"
 import { useNavigate, useSearchParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { useSettings } from "@/context/settings"
 import { useSettings } from "@/context/settings"
-import { useServer } from "@/context/server"
-import { useTabs } from "@/context/tabs"
+import { ServerConnection, useServer } from "@/context/server"
+import { type DraftTab, useTabs } from "@/context/tabs"
 import { useDirectoryPicker } from "@/components/directory-picker"
 import { useDirectoryPicker } from "@/components/directory-picker"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
 import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
+import { useGlobal } from "@/context/global"
 
 
 export function SessionComposerRegion(props: {
 export function SessionComposerRegion(props: {
   state: SessionComposerState
   state: SessionComposerState
@@ -73,26 +74,52 @@ export function SessionComposerRegion(props: {
   const settings = useSettings()
   const settings = useSettings()
   const server = useServer()
   const server = useServer()
   const tabs = useTabs()
   const tabs = useTabs()
+  const global = useGlobal()
   const pickDirectory = useDirectoryPicker()
   const pickDirectory = useDirectoryPicker()
   const [search] = useSearchParams<{ draftId?: string }>()
   const [search] = useSearchParams<{ draftId?: string }>()
   const view = layout.view(route.sessionKey)
   const view = layout.view(route.sessionKey)
 
 
+  const draft = createMemo(() => {
+    if (!search.draftId) return
+    return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
+  })
+  const projectServer = createMemo(() => {
+    if (!search.draftId) return server.current
+    const target = draft()?.server
+    if (!target) return
+    return server.list.find((conn) => ServerConnection.key(conn) === target)
+  })
+  const projectServerCtx = createMemo(() => {
+    const conn = projectServer()
+    if (conn) return global.createServerCtx(conn)
+  })
+  const projects = createMemo(() =>
+    search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
+  )
+
   const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
   const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
   const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
   const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
   const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
   const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
   const selectProject = (worktree: string) => {
   const selectProject = (worktree: string) => {
-    layout.projects.open(worktree)
-    server.projects.touch(worktree)
+    const conn = projectServer()
+    const target = projectServerCtx()
     if (search.draftId) {
     if (search.draftId) {
-      tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
+      if (!conn || !target) return
+      target.projects.open(worktree)
+      target.projects.touch(worktree)
+      tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
       return
       return
     }
     }
+
+    layout.projects.open(worktree)
+    server.projects.touch(worktree)
     navigate(`/${base64Encode(worktree)}/session`)
     navigate(`/${base64Encode(worktree)}/session`)
   }
   }
   const addProject = (title: string) => {
   const addProject = (title: string) => {
-    if (!server.current) return
+    const conn = projectServer()
+    if (!conn) return
     pickDirectory({
     pickDirectory({
-      server: server.current,
+      server: conn,
       title,
       title,
       onSelect: (result) => {
       onSelect: (result) => {
         const directory = Array.isArray(result) ? result[0] : result
         const directory = Array.isArray(result) ? result[0] : result
@@ -115,7 +142,7 @@ export function SessionComposerRegion(props: {
       loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
       loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
     },
     },
     projects: {
     projects: {
-      available: layout.projects.list(),
+      available: projects(),
       directory: sdk().directory,
       directory: sdk().directory,
       select: selectProject,
       select: selectProject,
       add: addProject,
       add: addProject,
@@ -216,6 +243,12 @@ export function SessionComposerRegion(props: {
     update()
     update()
   })
   })
 
 
+  const ready = Promise.resolve()
+  const [promptReadyResource] = createResource(
+    () => prompt.ready.promise ?? ready,
+    (promise) => promise.then(() => true),
+  )
+
   return (
   return (
     <div
     <div
       ref={props.setPromptDockRef}
       ref={props.setPromptDockRef}
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
 
 
         <Show when={showComposer()}>
         <Show when={showComposer()}>
           <Show
           <Show
-            when={prompt.ready()}
+            when={promptReadyResource()}
             fallback={
             fallback={
               <>
               <>
                 <Show when={rolled()} keyed>
                 <Show when={rolled()} keyed>

+ 69 - 0
packages/app/test-browser/prompt-persistence.test.ts

@@ -0,0 +1,69 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+import type { AsyncStorage } from "@solid-primitives/storage"
+import { createEffect, createRoot } from "solid-js"
+import { ServerScope } from "@/utils/server-scope"
+
+let Prompt: typeof import("@/context/prompt")
+let read: ((value: string | null) => void) | undefined
+
+const storage: AsyncStorage = {
+  getItem: () => new Promise((resolve) => (read = resolve)),
+  setItem: async () => undefined,
+  removeItem: async () => undefined,
+  clear: async () => undefined,
+  key: async () => null,
+  getLength: async () => 0,
+  length: Promise.resolve(0),
+}
+
+beforeAll(async () => {
+  mock.module("@solidjs/router", () => ({
+    useParams: () => ({}),
+    useSearchParams: () => [{}],
+  }))
+  mock.module("@opencode-ai/ui/context", () => ({
+    createSimpleContext: () => ({
+      use: () => undefined,
+      provider: () => undefined,
+    }),
+  }))
+  mock.module("@/context/platform", () => ({
+    usePlatform: () => ({ platform: "desktop", storage: () => storage }),
+  }))
+
+  Prompt = await import("@/context/prompt")
+})
+
+describe("prompt persistence", () => {
+  test("waits for an async draft to hydrate before reporting ready", async () => {
+    await new Promise<void>((resolve, reject) => {
+      createRoot((dispose) => {
+        const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
+        const ready = Prompt.createPromptReady(() => session)
+
+        expect(ready()).toBe(false)
+        expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
+
+        read?.(
+          JSON.stringify({
+            prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
+            cursor: 15,
+            context: { items: [] },
+          }),
+        )
+
+        createEffect(() => {
+          if (!ready()) return
+          try {
+            expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
+            dispose()
+            resolve()
+          } catch (error) {
+            dispose()
+            reject(error)
+          }
+        })
+      })
+    })
+  })
+})