Skip to content

Commit 612987e

Browse files
authored
Merge branch 'dev' into issue-30252-vivid-dingo
2 parents fff14a8 + e04c5e7 commit 612987e

15 files changed

Lines changed: 190 additions & 57 deletions

File tree

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ blank_issues_enabled: false
22
contact_links:
33
- name: 💬 Discord Community
44
url: https://discord.gg/opencode
5-
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
5+
about: For support, troubleshooting, how-to questions, and real-time discussion.

.github/ISSUE_TEMPLATE/question.yml

Lines changed: 0 additions & 10 deletions
This file was deleted.

packages/app/src/app.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ function TargetDirectoryLayout(props: ParentProps) {
185185
if (!search.draftId) return undefined
186186
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
187187
})
188-
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
188+
const directory = createMemo<string | undefined>((prev) =>
189+
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
190+
)
189191
const home = () => !params.serverKey && !search.draftId
190192
const targetDirectory = () => directory()!
191193

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
13791379
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
13801380

13811381
const [promptReady] = createResource(
1382-
() => prompt.ready().promise,
1382+
() => prompt.ready.promise,
13831383
(p) => p,
13841384
)
13851385

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ let variant: string | undefined
2727

2828
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
2929
const prompt = {
30-
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
30+
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
3131
current: () => promptValue,
3232
cursor: () => 0,
3333
dirty: () => true,

packages/app/src/components/titlebar.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
2929
import { useSettings } from "@/context/settings"
3030
import { WindowsAppMenu } from "./windows-app-menu"
3131
import { applyPath, backPath, forwardPath } from "./titlebar-history"
32-
import { base64Encode } from "@opencode-ai/core/util/encode"
3332
import { projectForSession } from "@/pages/layout/helpers"
3433
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
3534
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -264,15 +263,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
264263
const serverSdk = useServerSDK()
265264
const navigate = useNavigate()
266265
const layout = useLayout()
267-
268-
const newSessionHref = () => {
269-
if (params.dir) return `/${params.dir}/session`
270-
271-
const project = layout.projects.list()[0]
272-
if (!project) return "/"
273-
274-
return `/${base64Encode(project.worktree)}/session`
275-
}
266+
const global = useGlobal()
276267

277268
const tabs = useTabs()
278269
const tabsStore = tabs.store
@@ -337,7 +328,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
337328
tabsStoreActions.removeSessions(detail)
338329
})
339330

340-
const openNewTab = () => navigate(newSessionHref())
331+
const openNewTab = () => {
332+
const route = layout.route()
333+
const activeSession = session()
334+
if (route.type === "session" && activeSession) {
335+
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
336+
return
337+
}
338+
339+
const current = layout.projects.list()[0]
340+
if (current) {
341+
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
342+
return
343+
}
344+
345+
const fallback = global.servers.list().flatMap((conn) => {
346+
const project = global.createServerCtx(conn).projects.list()[0]
347+
return project ? [{ server: ServerConnection.key(conn), project }] : []
348+
})[0]
349+
if (!fallback) return
350+
351+
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
352+
}
341353
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
342354

343355
command.register("titlebar-home", () => [
@@ -592,8 +604,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
592604
size="large"
593605
class="shrink-0"
594606
icon={<IconV2 name="plus" />}
595-
as="a"
596-
href={newSessionHref()}
607+
onClick={openNewTab}
597608
aria-label={language.t("command.session.new")}
598609
/>
599610
</Show>

packages/app/src/context/prompt.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createSimpleContext } from "@opencode-ai/ui/context"
22
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
33
import { useParams, useSearchParams } from "@solidjs/router"
4-
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
4+
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
55
import { createStore, type SetStoreFunction } from "solid-js/store"
66
import type { FileSelection } from "@/context/file"
77
import { Persist, persisted } from "@/utils/persist"
@@ -181,7 +181,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
181181
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
182182
}
183183

184-
function createPromptSession(serverScope: ServerScope, scope: Scope) {
184+
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
185185
const [store, setStore, _, ready] = persisted(
186186
promptTarget(serverScope, scope),
187187
createStore<PromptStore>(promptStore()),
@@ -190,6 +190,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
190190
return { ready, ...createPromptStateValue(store, setStore) }
191191
}
192192

193+
export function createPromptReady(session: Accessor<PromptSession>) {
194+
return Object.defineProperty(() => session().ready(), "promise", {
195+
get: () => session().ready.promise,
196+
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
197+
}
198+
193199
function promptStore(): PromptStore {
194200
return {
195201
prompt: clonePrompt(DEFAULT_PROMPT),
@@ -247,7 +253,7 @@ export function createPromptState() {
247253
const [store, setStore] = createStore<PromptStore>(promptStore())
248254
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
249255
return {
250-
ready: () => ready,
256+
ready,
251257
...createPromptStateValue(store, setStore),
252258
}
253259
}
@@ -308,9 +314,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
308314
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
309315
)
310316
const pick = (scope?: Scope) => (scope ? load(scope) : session())
317+
const ready = createPromptReady(session)
311318

312319
return {
313-
ready: () => session().ready,
320+
ready,
314321
current: () => session().current(),
315322
cursor: () => session().cursor(),
316323
dirty: () => session().dirty(),

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createEffect, type ParentProps } from "solid-js"
1+
import { createEffect, Suspense, type ParentProps } from "solid-js"
22
import { useNavigate } from "@solidjs/router"
33
import { DebugBar } from "@/components/debug-bar"
44
import { HelpButton } from "@/components/help-button"
@@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
2828
<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">
2929
<Titlebar update={update} />
3030
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
31-
{props.children}
31+
<Suspense>{props.children}</Suspense>
3232
</main>
3333
{import.meta.env.DEV && <DebugBar />}
3434
<HelpButton />

packages/app/src/pages/session/composer/session-composer-region.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
1+
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { useNavigate, useSearchParams } from "@solidjs/router"
44
import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key"
2525
import { useLocal } from "@/context/local"
2626
import { useProviders } from "@/hooks/use-providers"
2727
import { useSettings } from "@/context/settings"
28-
import { useServer } from "@/context/server"
29-
import { useTabs } from "@/context/tabs"
28+
import { ServerConnection, useServer } from "@/context/server"
29+
import { type DraftTab, useTabs } from "@/context/tabs"
3030
import { useDirectoryPicker } from "@/components/directory-picker"
3131
import { base64Encode } from "@opencode-ai/core/util/encode"
3232
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
33+
import { useGlobal } from "@/context/global"
3334

3435
export function SessionComposerRegion(props: {
3536
state: SessionComposerState
@@ -73,26 +74,52 @@ export function SessionComposerRegion(props: {
7374
const settings = useSettings()
7475
const server = useServer()
7576
const tabs = useTabs()
77+
const global = useGlobal()
7678
const pickDirectory = useDirectoryPicker()
7779
const [search] = useSearchParams<{ draftId?: string }>()
7880
const view = layout.view(route.sessionKey)
7981

82+
const draft = createMemo(() => {
83+
if (!search.draftId) return
84+
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
85+
})
86+
const projectServer = createMemo(() => {
87+
if (!search.draftId) return server.current
88+
const target = draft()?.server
89+
if (!target) return
90+
return server.list.find((conn) => ServerConnection.key(conn) === target)
91+
})
92+
const projectServerCtx = createMemo(() => {
93+
const conn = projectServer()
94+
if (conn) return global.createServerCtx(conn)
95+
})
96+
const projects = createMemo(() =>
97+
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
98+
)
99+
80100
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
81101
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
82102
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
83103
const selectProject = (worktree: string) => {
84-
layout.projects.open(worktree)
85-
server.projects.touch(worktree)
104+
const conn = projectServer()
105+
const target = projectServerCtx()
86106
if (search.draftId) {
87-
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
107+
if (!conn || !target) return
108+
target.projects.open(worktree)
109+
target.projects.touch(worktree)
110+
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
88111
return
89112
}
113+
114+
layout.projects.open(worktree)
115+
server.projects.touch(worktree)
90116
navigate(`/${base64Encode(worktree)}/session`)
91117
}
92118
const addProject = (title: string) => {
93-
if (!server.current) return
119+
const conn = projectServer()
120+
if (!conn) return
94121
pickDirectory({
95-
server: server.current,
122+
server: conn,
96123
title,
97124
onSelect: (result) => {
98125
const directory = Array.isArray(result) ? result[0] : result
@@ -115,7 +142,7 @@ export function SessionComposerRegion(props: {
115142
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
116143
},
117144
projects: {
118-
available: layout.projects.list(),
145+
available: projects(),
119146
directory: sdk().directory,
120147
select: selectProject,
121148
add: addProject,
@@ -216,6 +243,12 @@ export function SessionComposerRegion(props: {
216243
update()
217244
})
218245

246+
const ready = Promise.resolve()
247+
const [promptReadyResource] = createResource(
248+
() => prompt.ready.promise ?? ready,
249+
(promise) => promise.then(() => true),
250+
)
251+
219252
return (
220253
<div
221254
ref={props.setPromptDockRef}
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
258291

259292
<Show when={showComposer()}>
260293
<Show
261-
when={prompt.ready()}
294+
when={promptReadyResource()}
262295
fallback={
263296
<>
264297
<Show when={rolled()} keyed>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { beforeAll, describe, expect, mock, test } from "bun:test"
2+
import type { AsyncStorage } from "@solid-primitives/storage"
3+
import { createEffect, createRoot } from "solid-js"
4+
import { ServerScope } from "@/utils/server-scope"
5+
6+
let Prompt: typeof import("@/context/prompt")
7+
let read: ((value: string | null) => void) | undefined
8+
9+
const storage: AsyncStorage = {
10+
getItem: () => new Promise((resolve) => (read = resolve)),
11+
setItem: async () => undefined,
12+
removeItem: async () => undefined,
13+
clear: async () => undefined,
14+
key: async () => null,
15+
getLength: async () => 0,
16+
length: Promise.resolve(0),
17+
}
18+
19+
beforeAll(async () => {
20+
mock.module("@solidjs/router", () => ({
21+
useParams: () => ({}),
22+
useSearchParams: () => [{}],
23+
}))
24+
mock.module("@opencode-ai/ui/context", () => ({
25+
createSimpleContext: () => ({
26+
use: () => undefined,
27+
provider: () => undefined,
28+
}),
29+
}))
30+
mock.module("@/context/platform", () => ({
31+
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
32+
}))
33+
34+
Prompt = await import("@/context/prompt")
35+
})
36+
37+
describe("prompt persistence", () => {
38+
test("waits for an async draft to hydrate before reporting ready", async () => {
39+
await new Promise<void>((resolve, reject) => {
40+
createRoot((dispose) => {
41+
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
42+
const ready = Prompt.createPromptReady(() => session)
43+
44+
expect(ready()).toBe(false)
45+
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
46+
47+
read?.(
48+
JSON.stringify({
49+
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
50+
cursor: 15,
51+
context: { items: [] },
52+
}),
53+
)
54+
55+
createEffect(() => {
56+
if (!ready()) return
57+
try {
58+
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
59+
dispose()
60+
resolve()
61+
} catch (error) {
62+
dispose()
63+
reject(error)
64+
}
65+
})
66+
})
67+
})
68+
})
69+
})

0 commit comments

Comments
 (0)