Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
491 changes: 491 additions & 0 deletions packages/app/e2e/desktop/console-auth.spec.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/app/e2e/desktop/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="overflow-hidden bg-v2-background-bg-deep">
<div id="root" class="flex h-dvh flex-col p-px"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
46 changes: 46 additions & 0 deletions packages/app/e2e/desktop/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import { AppBaseProviders, AppInterface } from "@/app"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
import { ServerConnection } from "@/runtime/server/registry"

// Exercise the real Desktop renderer with local browser/clipboard adapters and
// an HTTP fixture. No Electron service or account credentials are touched.
const params = new URLSearchParams(window.location.search)
const remote = params.get("server")
const server: ServerConnection.Any = remote
? { type: "http", displayName: "Production server", http: { url: remote } }
: { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
render(
() => (
<PlatformProvider
value={{
platform: "desktop",
windowID: "console-auth-fixture",
os: "linux",
draftStore: createBrowserDraftStore(),
openExternal: () => {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
writeClipboardText: (text) => navigator.clipboard.writeText(text),
openBrowser: async (url) => {
if (params.has("browserFailed")) return false
const browser = window.open("about:blank", "_blank")
if (!browser) return false
browser.opener = null
browser.location.replace(url)
return true
},
}}
>
<AppBaseProviders locale="en">
<AppInterface servers={[server]} defaultServer={ServerConnection.key(server)} router={MemoryRouter} />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
19 changes: 19 additions & 0 deletions packages/app/e2e/desktop/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test"

const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454)
export default defineConfig({
testDir: ".",
outputDir: "../test-results/desktop",
timeout: 60000,
expect: { timeout: 10000 },
workers: 1,
retries: 0,
use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: true,
timeout: 120000,
},
})
5 changes: 4 additions & 1 deletion packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"

export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
Expand Down Expand Up @@ -47,7 +48,9 @@ type MockStreamWindow = Window & {
}

export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`

await page.addInitScript(
({ server, retry }) => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0
export default defineConfig({
testDir: "./e2e",
testIgnore: [
"desktop/**",
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
Expand Down
26 changes: 23 additions & 3 deletions packages/app/src/new-session/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { ProviderSetup } from "@/providers/connect/setup"
import { usePlatform } from "@/runtime/platform/platform"

const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000

Expand All @@ -41,6 +44,10 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const setup = () => platform.platform === "desktop" && providers.ready() && !providers.usable()
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
Expand All @@ -61,11 +68,20 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div
class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"
classList={{ "bottom-12 overflow-y-auto": setup() }}
>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<ProviderSetup
visible={setup()}
directory={sdk().directory}
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
/>
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
Expand Down Expand Up @@ -133,8 +149,9 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const integrations = useIntegrations(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip"),
Persist.global("new-session.provider-tip-v3"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
Expand All @@ -153,7 +170,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
() =>
providers.ready() &&
providerReady() &&
providers.paid().length === 0 &&
!integrations.list().some((integration) => integration.connections.length > 0) &&
!providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
Expand Down
19 changes: 10 additions & 9 deletions packages/app/src/providers/catalog/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
all: () => providers().all,
default: () => providers().default,
usable: () =>
(data.location.model.list(location()) ?? []).some(
(model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID),
),
// V2 servers list only available providers, so the connectable catalog
// comes from the integration list, with the provider catalog as fallback.
popular: () => {
Expand Down Expand Up @@ -72,15 +76,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
const paid = new Set(
(data.location.model.list(location()) ?? [])
.filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0))
.map((model) => model.providerID),
)
return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))]
},
}
}
68 changes: 68 additions & 0 deletions packages/app/src/providers/connect/console.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { For, Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { useLanguage } from "@/runtime/i18n/language"

export function ConsoleAuthorization(props: {
code: string
browserFailed: boolean
copied: boolean
copyFailed: boolean
onOpen: () => void
onCopy: () => void
}) {
const language = useLanguage()
return (
<div
data-component="console-authorization"
class="flex flex-col gap-5 text-[13px] leading-5 text-v2-text-text-muted"
>
<p>
{language.t(
props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions",
)}
</p>
<div class="flex flex-col gap-2">
<div class="font-medium text-v2-text-text-base">{language.t("provider.connect.console.deviceCode")}</div>
<div
dir="ltr"
role="group"
aria-label={`${language.t("provider.connect.console.deviceCode")}: ${props.code}`}
class="flex max-w-full gap-1 self-start font-mono text-xl font-[530] text-v2-text-text-base tabular-nums"
>
<For each={props.code.split("")}>
{(character) => (
<span
aria-hidden="true"
class={
character === "-"
? "mx-1 flex h-12 items-center text-v2-text-text-muted"
: "flex h-12 w-8 items-center justify-center rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02"
}
>
{character}
</span>
)}
</For>
</div>
<p role="status">
<TextShimmer text={language.t("provider.connect.console.waiting")} active />
</p>
</div>
<div data-component="console-browser-fallback" class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.browserHint")}</span>
<Button variant="ghost-muted" onClick={props.onCopy}>
{language.t(props.copied ? "provider.connect.console.linkCopied" : "provider.connect.console.copyLink")}
</Button>
<Show when={props.browserFailed || props.copyFailed}>
<Button variant="ghost" onClick={props.onOpen}>
{language.t("provider.connect.console.openAgain")}
</Button>
</Show>
</div>
<Show when={props.copyFailed}>
<p role="alert">{language.t("provider.connect.console.copyFailed")}</p>
</Show>
</div>
)
}
Loading
Loading