diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e2f6108fd..97a112755 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -185,7 +185,9 @@ function TargetDirectoryLayout(props: ParentProps) { if (!search.draftId) return undefined return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory }) - const directory = createMemo((prev) => prev ?? resolvedDirectory()) + const directory = createMemo((prev) => + search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()), + ) const home = () => !params.serverKey && !search.draftId const targetDirectory = () => directory()! diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d40953ff..98e393137 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1379,7 +1379,7 @@ export const PromptInput: Component = (props) => { const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( - () => prompt.ready().promise, + () => prompt.ready.promise, (p) => p, ) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 7b412cd24..94a248804 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/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 prompt = { - ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }), + ready: Object.assign(() => true, { promise: Promise.resolve(true) }), current: () => promptValue, cursor: () => 0, dirty: () => true, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 580f6caec..f019e839b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" -import { base64Encode } from "@opencode-ai/core/util/encode" import { projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { makeEventListener } from "@solid-primitives/event-listener" @@ -264,15 +263,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const serverSdk = useServerSDK() const navigate = useNavigate() 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 tabsStore = tabs.store @@ -337,7 +328,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { 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() }) command.register("titlebar-home", () => [ @@ -592,8 +604,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { size="large" class="shrink-0" icon={} - as="a" - href={newSessionHref()} + onClick={openNewTab} aria-label={language.t("command.session.new")} /> diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 62818550d..49206583a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,7 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" 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 type { FileSelection } from "@/context/file" 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]) } -function createPromptSession(serverScope: ServerScope, scope: Scope) { +export function createPromptSession(serverScope: ServerScope, scope: Scope) { const [store, setStore, _, ready] = persisted( promptTarget(serverScope, scope), createStore(promptStore()), @@ -190,6 +190,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) { return { ready, ...createPromptStateValue(store, setStore) } } +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + function promptStore(): PromptStore { return { prompt: clonePrompt(DEFAULT_PROMPT), @@ -247,7 +253,7 @@ export function createPromptState() { const [store, setStore] = createStore(promptStore()) const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) return { - ready: () => ready, + ready, ...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 }), ) const pick = (scope?: Scope) => (scope ? load(scope) : session()) + const ready = createPromptReady(session) return { - ready: () => session().ready, + ready, current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 2f8793d8d..91bf7ea0e 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/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 { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" @@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
- {props.children} + {props.children}
{import.meta.env.DEV && } 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 3fbde9ef3..023ddfff9 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/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 { useNavigate, useSearchParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" @@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key" import { useLocal } from "@/context/local" import { useProviders } from "@/hooks/use-providers" 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 { base64Encode } from "@opencode-ai/core/util/encode" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { useGlobal } from "@/context/global" export function SessionComposerRegion(props: { state: SessionComposerState @@ -73,26 +74,52 @@ export function SessionComposerRegion(props: { const settings = useSettings() const server = useServer() const tabs = useTabs() + const global = useGlobal() const pickDirectory = useDirectoryPicker() const [search] = useSearchParams<{ draftId?: string }>() 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 globalProvidersQuery = createQuery(() => queryOptions().providers(null)) const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory))) const selectProject = (worktree: string) => { - layout.projects.open(worktree) - server.projects.touch(worktree) + const conn = projectServer() + const target = projectServerCtx() 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 } + + layout.projects.open(worktree) + server.projects.touch(worktree) navigate(`/${base64Encode(worktree)}/session`) } const addProject = (title: string) => { - if (!server.current) return + const conn = projectServer() + if (!conn) return pickDirectory({ - server: server.current, + server: conn, title, onSelect: (result) => { const directory = Array.isArray(result) ? result[0] : result @@ -115,7 +142,7 @@ export function SessionComposerRegion(props: { loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, }, projects: { - available: layout.projects.list(), + available: projects(), directory: sdk().directory, select: selectProject, add: addProject, @@ -216,6 +243,12 @@ export function SessionComposerRegion(props: { update() }) + const ready = Promise.resolve() + const [promptReadyResource] = createResource( + () => prompt.ready.promise ?? ready, + (promise) => promise.then(() => true), + ) + return (
diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts new file mode 100644 index 000000000..9ca170f1d --- /dev/null +++ b/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((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) + } + }) + }) + }) + }) +})