mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 00:46:26 +00:00
Compare commits
1
Commits
beta
...
web-onboarding
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f636f53c30 |
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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?.()
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user