Skip to content
Open
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
568 changes: 568 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>
55 changes: 55 additions & 0 deletions packages/app/e2e/desktop/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 local: ServerConnection.Any = {
type: "sidecar",
variant: "base",
http: { url: "http://127.0.0.1:4096" },
}
const server: ServerConnection.Any = remote
? { type: "http", displayName: "Production server", http: { url: remote } }
: local
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={params.has("multipleServers") ? [local, server] : [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,
},
})
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
2 changes: 1 addition & 1 deletion packages/app/src/composer/commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
}
const { DialogSelectModel } = await import("@/providers/models/select-dialog")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
void dialog.show(() => <DialogSelectModel model={model} onDone={restoreComposer} />, restoreComposer)
})
}

Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
providerID={props.model.model.selection.current()?.provider?.id}
modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.model.restoreFocus}
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
onUnpaidClick={() =>
dialog.show(() => (
<DialogSelectModelUnpaid model={props.model.model.selection} onDone={props.model.restoreFocus} />
))
}
/>
}
/>
Expand Down
42 changes: 34 additions & 8 deletions packages/app/src/new-session/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ import {
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 { SummaryPopover } from "@/session/summary/popover"
import type { DraftMcpControls } from "./mcp"
import { ProviderSetup } from "@/providers/connect/setup"
import { usePlatform } from "@/runtime/platform/platform"

const NewSessionSummary = lazy(async () => {
const { NewSessionSummary } = await import("./summary")
Expand All @@ -49,6 +52,10 @@ export function NewSessionView(props: {
mcp: DraftMcpControls
}) {
const [store, setStore] = createStore({ summary: false })
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 Down Expand Up @@ -88,11 +95,20 @@ export function NewSessionView(props: {
</Suspense>
</SummaryPopover>
</div>
<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 @@ -129,6 +145,7 @@ export function NewSessionView(props: {
</div>
</div>
<NewSessionTips
composer={props.composer}
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
Expand All @@ -142,13 +159,14 @@ export function NewSessionView(props: {
)
}

function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function NewSessionTips(props: { composer: ComposerModel; workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
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 @@ -167,7 +185,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 All @@ -180,17 +201,22 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
show: () => tip() !== undefined,
element: () => ref() ?? null,
})
const open = () => {
const open = async () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => (
<DialogConnectProvider
directory={sdk().directory}
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
/>
))
}
const dismiss = () => {
const current = tip()
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