Compare commits

...
1 Commits
Author SHA1 Message Date
Hona f636f53c30 fix(app): guide hosted web users to add a server 2026-09-05 00:03:37 +00:00
18 changed files with 544 additions and 67 deletions
@@ -0,0 +1,321 @@
import { expect, test, type Page } from "@playwright/test"
import { createMockServerHandler, type MockServerConfig } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const initial = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const remote = "http://127.0.0.1:4097"
const session = {
id: "ses_web_onboarding",
projectID: "project_web_onboarding",
directory: "/home/remote/project",
title: "Session from the newly connected server",
time: { created: 1, updated: 1 },
}
test("failed initial connection offers server setup instead of an indefinite loading screen", async ({ page }) => {
await fixture(page, initial, "failed")
await page.goto("/")
await expectOnboarding(page)
const onboarding = page.getByRole("region", { name: "Connect a server", exact: true })
await expect(onboarding.getByText(/OpenCode web interface/)).toBeVisible()
await expect(onboarding.getByText(/Run OpenCode on the machine with your code/)).toBeVisible()
await expect(onboarding.getByText(/Check that this server is running and reachable/)).toBeVisible()
await onboarding.getByRole("button", { name: "Manage servers", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeEnabled()
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expectOnboarding(page)
})
test("a configured server with health and event requests still pending leaves setup usable", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "pending")
await configure(page, remote, "Pending remote")
const health = page.waitForRequest(`${remote}/api/health`)
const events = page.waitForRequest(`${remote}/api/event`)
await page.goto("/")
await Promise.all([health, events])
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(editor).toBeHidden()
await expectOnboarding(page)
})
test("the setup dialog supports keyboard entry, Escape, cancel, and clean reopening", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "failed")
await page.goto("/")
await expectOnboarding(page)
const add = page.getByRole("button", { name: "Add remote server", exact: true })
await add.focus()
await add.press("Enter")
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
const url = editor.getByPlaceholder("http://localhost:4096", { exact: true })
const name = editor.getByPlaceholder("Localhost", { exact: true })
const password = editor.getByPlaceholder("password", { exact: true })
await expect(url).toBeFocused()
await url.fill(remote)
await page.keyboard.press("Tab")
await expect(name).toBeFocused()
await name.fill("Cancelled remote")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("fixture-password")
await page.keyboard.press("Escape")
await expect(editor).toBeHidden()
await expect(add).toBeFocused()
await add.press("Space")
await expect(url).toBeFocused()
await expect(url).toHaveValue("")
await expect(name).toHaveValue("")
await expect(password).toHaveValue("")
await editor.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(editor).toBeHidden()
await expect(add).toBeFocused()
await expectOnboarding(page)
await expect(page.getByText("Cancelled remote", { exact: true })).toHaveCount(0)
})
test("adding a healthy remote focuses it and loads its sessions without another server selection", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "connected", [session])
await page.goto("/")
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByPlaceholder("http://localhost:4096", { exact: true }).fill(remote)
await editor.getByPlaceholder("Localhost", { exact: true }).fill("Working remote")
await editor.getByPlaceholder("Localhost", { exact: true }).press("Enter")
await expect(editor).toBeHidden()
await expectSessions(page, "Working remote")
await page.reload()
await expectSessions(page, "Working remote")
})
test("a returning configured remote shows its sessions even when the initial server is unavailable", async ({
page,
}) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "connected", [session])
await configure(page, remote, "Saved remote")
await page.goto("/")
await expectSessions(page, "Saved remote")
})
test("a healthy initial server retains the normal session search", async ({ page }) => {
await fixture(page, initial, "connected", [session])
await page.goto("/")
await expectSessions(page)
})
for (const origin of ["https://opencode.ai", "https://beta.opencode.ai"]) {
test(`${origin} starts with zero servers and no localhost connection attempts`, async ({ page, baseURL }) => {
const requests = await proxyHosted(page, baseURL, origin)
await page.goto(origin)
await expectOnboarding(page)
await expect(page.getByText(/Could not connect/)).toHaveCount(0)
await page.getByRole("button", { name: "Manage servers", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("No servers yet", { exact: true })).toBeVisible()
expect(requests).toEqual([])
})
}
test("hosted web preserves saved remote servers without inserting a local server", async ({ page, baseURL }) => {
const origin = "https://beta.opencode.ai"
const server = "https://remote.example.test"
const requests = await proxyHosted(page, baseURL, origin)
await fixture(page, server, "connected", [session])
await configure(page, server, "Saved remote")
await page.goto(origin)
await expectSessions(page)
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByText("Saved remote", { exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "More options", exact: true })).toHaveCount(1)
expect(requests.length).toBeGreaterThan(0)
expect(requests.every((url) => new URL(url).origin === server)).toBe(true)
})
test("a server added on hosted web can be removed again", async ({ page, baseURL }) => {
const origin = "https://beta.opencode.ai"
const server = "https://remote.example.test"
const requests = await proxyHosted(page, baseURL, origin)
await fixture(page, server, "connected", [session])
await page.goto(origin)
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByPlaceholder("http://localhost:4096", { exact: true }).fill(server)
await editor.getByPlaceholder("Localhost", { exact: true }).fill("Removable remote")
await editor.getByRole("button", { name: "Add server", exact: true }).click()
await expect(editor).toBeHidden()
await expectSessions(page)
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByText("Removable remote", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Delete", exact: true }).click()
await expect(settings.getByText("No servers yet", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expectOnboarding(page)
await expect(page.getByText(/Could not connect/)).toHaveCount(0)
await page.reload()
await expectOnboarding(page)
await expect(page.getByText("Removable remote", { exact: true })).toHaveCount(0)
expect(requests.every((url) => new URL(url).origin === server)).toBe(true)
})
test("reconnecting a loaded server preserves an in-progress session rename", async ({ page }) => {
const stream = await fixture(page, initial, "connected", [session])
if (!stream) throw new Error("Connected fixture must provide an event stream")
await page.goto("/")
await expectSessions(page)
const connected = await stream.waitForConnection()
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: session.title })
await row.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const editor = page.getByRole("textbox", { name: "Rename", exact: true })
await expect(editor).toBeFocused()
await editor.fill("Keep this unfinished rename")
await stream.disconnect()
await stream.waitForConnection({ after: connected.id })
await expect(editor).toBeFocused()
await expect(editor).toHaveValue("Keep this unfinished rename")
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toHaveCount(0)
await editor.press("Escape")
await expectSessions(page)
})
for (const displayName of ["RemoteDevelopmentServer".repeat(10), undefined]) {
test(`disconnected ${displayName ? "server name" : "server URL"} wraps at a narrow viewport`, async ({ page }) => {
const url = `https://${"development-".repeat(4)}server.${"remote-".repeat(7)}example.test`
await page.setViewportSize({ width: 360, height: 740 })
await fixture(page, initial, "failed")
await fixture(page, url, "failed")
await configure(page, url, displayName)
await page.goto("/")
await expectOnboarding(page)
const onboarding = page.getByRole("region", { name: "Connect a server", exact: true })
const identity = onboarding.getByText(displayName ?? url.replace(/^https?:\/\//, ""), { exact: true })
await expect(identity).toBeVisible()
await expect(onboarding.getByRole("button", { name: "Add remote server", exact: true })).toBeInViewport()
await expect(onboarding.getByRole("button", { name: "Manage servers", exact: true })).toBeInViewport()
await expect
.poll(() => identity.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await expect
.poll(() => onboarding.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1)
})
}
async function expectOnboarding(page: Page) {
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Add remote server", exact: true })).toBeEnabled()
await expect(page.getByRole("textbox", { name: /^Search sessions/ })).toHaveCount(0)
await expect(page.getByText("Loading", { exact: true })).toHaveCount(0)
}
async function expectSessions(page: Page, server?: string) {
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
await expect(sessions.getByText(session.title, { exact: true })).toBeVisible()
await expect(
page.getByRole("textbox", { name: server ? `Search sessions in ${server}` : "Search sessions", exact: true }),
).toBeEditable()
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Add remote server", exact: true })).toHaveCount(0)
await expect(page.getByText("Loading", { exact: true })).toHaveCount(0)
}
async function configure(page: Page, url: string, displayName?: string) {
await page.addInitScript(
({ url, displayName }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url }, displayName }] }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ home: { selection: { server: url } } }))
},
{ url, displayName },
)
}
async function proxyHosted(page: Page, source: string | undefined, origin: string) {
if (!source) throw new Error("Hosted-origin tests require the local app baseURL")
const requests: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/")) requests.push(request.url())
})
// Keep the real hostname-dependent app code, but serve every asset locally.
await page.routeWebSocket("**/*", (socket) => socket.close())
await page.route(`${origin}/**`, async (route) => {
const url = new URL(route.request().url())
if (url.pathname.startsWith("/api/")) return route.abort("connectionrefused")
const response = await route.fetch({ url: new URL(url.pathname + url.search, source).href })
await route.fulfill({ response })
})
return requests
}
async function fixture(
page: Page,
server: string,
state: "connected" | "failed" | "pending",
sessions: MockServerConfig["sessions"] = [],
) {
const stream = state === "connected" ? await installSseTransport(page, { server }) : undefined
const transport = createMockServerHandler({
provider: [],
directory: session.directory,
project: {
id: session.projectID,
canonical: session.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions,
pageMessages: () => ({ items: [] }),
})
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
if (new URL(route.request().url()).origin !== server) return route.fallback()
if (state === "failed") return route.abort("connectionrefused")
// Deliberately leave both health and SSE unanswered until the isolated page closes.
if (state === "pending") return
const headers = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
}
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
const response = await transport.handler(
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
)
await route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...headers },
body: Buffer.from(await response.arrayBuffer()),
})
})
return stream
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
+15 -11
View File
@@ -75,22 +75,26 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={
server ? ServerConnection.key(server) : ServerConnection.Key.make("http://localhost:49374")
}
servers={server ? [server] : []}
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
+79
View File
@@ -0,0 +1,79 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { useSettingsDialog } from "@/settings/command"
import type { HomeController } from "./model"
export function HomeConnect(props: { home: HomeController }) {
const language = useLanguage()
const dialog = useDialog()
const settings = useSettingsDialog("servers")
const server = props.home.server.focused
let button: HTMLButtonElement | undefined
const disconnected = () => {
const conn = server()
return (
conn &&
(props.home.server.health(conn)?.healthy === false || props.home.server.focusedContext()?.sdk.connection.error())
)
}
const add = async () => {
const { DialogServer } = await import("@/servers/connect/dialog")
void dialog.show(() => (
<DialogServer
mode="add"
onAdd={props.home.selection.focusServer}
onCloseAutoFocus={(event) => {
event.preventDefault()
if (button?.isConnected) button.focus()
}}
/>
))
}
return (
<section aria-labelledby="home-connect-title" class="min-w-0 px-3 py-8 md:px-6 lg:py-12">
<div class="mx-auto flex w-full max-w-[440px] flex-col items-start gap-6 lg:mt-12">
<div class="flex size-12 items-center justify-center rounded-xl bg-v2-background-bg-layer-02 text-v2-icon-icon-base">
<Icon name="server" size="large" />
</div>
<div class="flex min-w-0 flex-col gap-3">
<h1 id="home-connect-title" class="text-20-medium text-v2-text-text-base">
{language.t("home.connect.title")}
</h1>
<p class="text-14-regular leading-[var(--line-height-base)] text-v2-text-text-muted">
{language.t("home.connect.description")}
</p>
<p class="text-[13px] leading-[var(--line-height-base)] text-v2-text-text-muted">
{language.t("home.connect.instructions")}
</p>
</div>
<div class="flex w-full flex-wrap items-center gap-2">
<Button ref={button} variant="contrast" size="large" icon="plus" onClick={add}>
{language.t("home.connect.add")}
</Button>
<Button variant="ghost-muted" size="large" onClick={settings}>
{language.t("home.connect.manage")}
</Button>
</div>
<Show when={disconnected()}>
<div class="flex w-full min-w-0 flex-col gap-1 rounded-lg bg-v2-background-bg-layer-01 p-3 text-12-regular leading-[var(--line-height-base)]">
<span class="text-v2-text-text-muted">{language.t("home.connect.unavailable")}</span>
<bdi class="[overflow-wrap:anywhere] text-v2-text-text-base">{serverName(server())}</bdi>
</div>
</Show>
<a
href="https://opencode.ai/v2/docs/troubleshooting/#check-the-background-service"
target="_blank"
rel="noopener noreferrer"
class="text-12-regular text-v2-text-text-muted underline underline-offset-4 hover:text-v2-text-text-base"
>
{language.t("home.connect.help")}
</a>
</div>
</section>
)
}
+5 -1
View File
@@ -201,7 +201,11 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
fallback={
<Show when={props.servers[0]}>
{(server) => <HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
</Show>
}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
+15 -2
View File
@@ -1,6 +1,8 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
import { HomeConnect } from "./connect"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
@@ -11,6 +13,7 @@ import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const platform = usePlatform()
const mobile = createMediaQuery("(max-width: 767px)")
const home = createHomeController()
const projects = createHomeProjectsController(home)
@@ -24,7 +27,7 @@ export function Home() {
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<Show when={mobile()}>
<Show when={mobile() && home.server.list().length > 0}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
</div>
@@ -46,7 +49,17 @@ export function Home() {
<Show when={!mobile()}>
<HomeProjects projects={projects} scroll={scroll} />
</Show>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
{/* A disabled session query is pending, not loading. Keep loaded sessions mounted during reconnects. */}
<Show
when={
platform.platform !== "web" ||
!sessions.data.loading() ||
home.server.focusedContext()?.sdk.connection.status() === "connected"
}
fallback={<HomeConnect home={home} />}
>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
</Show>
</div>
</ScrollView>
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
@@ -318,8 +318,10 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
},
}
}
+43 -38
View File
@@ -2,6 +2,7 @@ import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
import { Show } from "solid-js"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
<Show when={props.sessions.session.server()}>
{(server) => (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)}
</Show>
)
}
+9
View File
@@ -655,6 +655,15 @@ export const dict = {
"home.sessions.search.noResults": "No sessions found for {{query}}",
"home.sessions.empty": "Nothing here yet",
"home.sessions.empty.description": "Create a session to get started",
"home.connect.title": "Connect a server",
"home.connect.description":
"This is the OpenCode web interface. Connect your own server to access your projects and sessions.",
"home.connect.instructions":
"Run OpenCode on the machine with your code, then add its server address and password here.",
"home.connect.add": "Add remote server",
"home.connect.manage": "Manage servers",
"home.connect.unavailable": "Could not connect. Check that this server is running and reachable from your browser.",
"home.connect.help": "Learn about connecting to a server",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (location.hostname === "opencode.ai" || location.hostname.endsWith(".opencode.ai")) return undefined
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+4 -1
View File
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
name: "Server",
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
@@ -250,6 +250,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
}
return {
get canonicalLocalServer() {
return props.canonicalLocalServer
},
get list() {
return allServers()
},
+9 -3
View File
@@ -23,11 +23,14 @@ type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
onAdd?: (server: ServerConnection.Http) => void
onCloseAutoFocus?: (event: Event) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
onSelect: () => dialog.close(),
onAdd: (server) => props.onAdd?.(server),
})
const [opened, setOpened] = createSignal(false)
@@ -63,7 +66,7 @@ export const DialogServer: Component<{
}
return (
<Dialog fit class="settings-server-dialog">
<Dialog fit class="settings-server-dialog" onCloseAutoFocus={props.onCloseAutoFocus}>
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
@@ -128,7 +131,9 @@ export const DialogServer: Component<{
)
}
function createFormController(options: { onSelect?: () => void } = {}) {
function createFormController(
options: { onSelect?: () => void; onAdd?: (server: ServerConnection.Http) => void } = {},
) {
const server = useServers()
const tabs = useTabs()
const global = useGlobal()
@@ -212,7 +217,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
}
reset()
add(connection)
const added = add(connection)
if (added) options.onAdd?.(added)
options.onSelect?.()
},
}))
+3 -3
View File
@@ -1,13 +1,13 @@
import { type ParentProps, Show } from "solid-js"
import { type JSX, type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { ModelsProvider } from "@/providers/models/models"
import { ServerProvider } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
export function SettingsServerScope(props: ParentProps<{ directory?: string; fallback?: JSX.Element }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
<Show when={global.settings.server.selected()} keyed fallback={props.fallback}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
+18 -1
View File
@@ -206,7 +206,24 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects />
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<SettingsServerScope
directory={directory()}
fallback={
<For each={["workspaces", "providers", "models", "extensions"]}>
{(value) => (
<Tabs.Content value={value} class="settings-panel">
<div class="flex flex-col items-start gap-4 p-6">
<h2 class="settings-tab-title">{language.t("home.connect.title")}</h2>
<p class="text-v2-text-text-muted">{language.t("home.connect.description")}</p>
<Button variant="contrast" onClick={() => surface.open("servers")}>
{language.t("home.connect.manage")}
</Button>
</div>
</Tabs.Content>
)}
</For>
}
>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces activeDirectory={directory()} />
</Tabs.Content>
@@ -14,6 +14,15 @@ describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("starts without a server and preserves a saved selection", () => {
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initialLayout()))
expect(restore({}).home.selection.server).toBeUndefined()
expect(Schema.is(layoutSchema)(initialLayout())).toBe(true)
expect(restore({ home: { selection: { server: "https://remote.example" } } }).home.selection.server).toBe(
ServerConnection.Key.make("https://remote.example"),
)
})
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
+3 -3
View File
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: TabStorage.ServerKey,
server: Schema.optional(TabStorage.ServerKey),
directory: Schema.optional(Schema.String),
}),
}),
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
),
)
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(ServerConnection.key(servers.list[0])),
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
+3
View File
@@ -141,6 +141,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!ready() || !recentReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
// Hosted web no longer auto-connects to localhost; retain its tabs so re-adding it recovers them.
if (servers.canonicalLocalServer) serversSet.add(servers.canonicalLocalServer)
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
@@ -167,6 +169,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!closedReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
if (servers.canonicalLocalServer) serversSet.add(servers.canonicalLocalServer)
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
@@ -10,6 +10,7 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
}
export interface DialogHeaderProps extends ParentProps {
@@ -93,6 +94,7 @@ export function Dialog(props: DialogProps) {
>
<div data-slot="dialog-container" class={local.containerClass}>
<Content
onCloseAutoFocus={props.onCloseAutoFocus}
data-slot="dialog-content"
classList={{
...local.classList,