mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-11-15 08:12:03 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e4b8eb99 | |||
| 6697cf3fd8 | |||
| 87aa62d1e2 | |||
| 3136b1ba97 | |||
| 246dea3a58 | |||
| 4b1c7f28a0 | |||
| 9410eda4e3 | |||
| f014686b07 | |||
| 8b68dc0d7f | |||
| 2f4d36d64e | |||
| 6f2ff35656 | |||
| cd00422298 | |||
| 34ee94232f | |||
| 4d91f0cf28 | |||
| bf18cc971f | |||
| 5e1a1ed2e2 | |||
| bd7f7ad98b | |||
| af72cec799 |
@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||
const high = page.getByRole("option", { name: "high" })
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByText("Select one answer")).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
|
||||
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
|
||||
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
|
||||
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
|
||||
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
+10
-109
@@ -31,20 +31,19 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
@@ -53,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -96,11 +88,7 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
return <SessionPage />
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
@@ -115,76 +103,13 @@ const TargetSessionRoute = () => {
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
},
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -232,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -240,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</DraftServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -365,38 +290,14 @@ function NewAppLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
|
||||
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import type { Component } from "solid-js"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||
import "./settings-v2/settings-v2.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogManageModelsV2: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||
providerList(providerID).forEach((x) => {
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||
})
|
||||
}
|
||||
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
||||
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
||||
}
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: () => local.model.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aRank = popularProviders.indexOf(a.category)
|
||||
const bRank = popularProviders.indexOf(b.category)
|
||||
const aPopular = aRank >= 0
|
||||
const bPopular = bRank >= 0
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
return aRank - bRank
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
||||
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
||||
<DialogTitleGroup
|
||||
title={language.t("dialog.model.manage")}
|
||||
description={language.t("dialog.model.manage.description")}
|
||||
/>
|
||||
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</ButtonV2>
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autofocus
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<SwitchV2
|
||||
class="mr-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{group.items[0].provider.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<SwitchV2
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
const manageKey = "action:manage"
|
||||
|
||||
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||
}
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
@@ -199,6 +219,271 @@ export function ModelSelectorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelectorPopoverV2(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
const groups = createMemo(() => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models()) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
})
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const current = () => {
|
||||
const value = model.current()
|
||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selected = current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
}
|
||||
const activeItem = () =>
|
||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const setOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
restoreTrigger = true
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() =>
|
||||
requestAnimationFrame(() => {
|
||||
searchRef?.focus()
|
||||
activeItem()?.scrollIntoView({ block: "nearest" })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
}
|
||||
const select = (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
props.onClose?.()
|
||||
}
|
||||
const selectModel = (item: ModelItem) => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => select(item))
|
||||
}
|
||||
const manage = () => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||
})
|
||||
})
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = models().find((item) => modelKey(item) === store.active)
|
||||
if (item) {
|
||||
selectModel(item)
|
||||
return
|
||||
}
|
||||
if (store.active === manageKey) manage()
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
const options = keys()
|
||||
if (options.length === 0) return
|
||||
const index = options.indexOf(store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
setStore("active", options[(start + delta + options.length) % options.length])
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => (searchRef = el)}
|
||||
value={store.search}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") return
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => props.onClose?.())
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={store.search.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
|
||||
<div class="flex flex-col p-0.5 pt-0">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
{language.t("dialog.model.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", modelKey(item))
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Group>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<MenuV2.Item
|
||||
data-option-key={manageKey}
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", manageKey)
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</MenuV2.Item>
|
||||
</div>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -1480,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
@@ -1646,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
@@ -1662,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-model-variant"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup
|
||||
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||
onChange={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
>
|
||||
{variants().map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value === "default" ? language.t("common.default") : value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -2057,6 +2084,7 @@ type ComposerModelControlState = {
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
@@ -2147,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} />
|
||||
</ModelSelectorPopover>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.state.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -18,7 +18,16 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
buttonAppearance?: "default" | "v2"
|
||||
placement?: TooltipProps["placement"]
|
||||
placement?: ComponentProps<typeof TooltipV2>["placement"]
|
||||
}
|
||||
|
||||
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
||||
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openSessionContext(args: {
|
||||
@@ -113,38 +122,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
<Show when={tokens()}>
|
||||
{(value) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">
|
||||
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
|
||||
</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={context()}>
|
||||
{(ctx) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
||||
</div>
|
||||
<div class="flex w-[120px] flex-col gap-2">
|
||||
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={params.id}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={buttonAppearance() === "v2"}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={buttonAppearance() === "v2"}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
@@ -153,10 +146,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
onClick={openContext}
|
||||
aria-label={language.t("context.usage.view")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -166,9 +157,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
>
|
||||
{circle()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
@@ -21,6 +22,7 @@ export function TabNavItem(props: {
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
@@ -56,6 +58,33 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.directory.replace(home, "~") : session.directory
|
||||
})
|
||||
const branch = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
|
||||
})
|
||||
// Only label the server when multiple servers are connected.
|
||||
const serverLabel = createMemo(() => {
|
||||
if (global.servers.list().length <= 1) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
return conn ? serverName(conn) : undefined
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -74,7 +103,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
title()
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
@@ -133,9 +162,9 @@ export function TabNavItem(props: {
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
const value = title()
|
||||
if (value === undefined) return
|
||||
titleEl.textContent = value
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
@@ -171,7 +200,7 @@ export function TabNavItem(props: {
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
const tab = (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
@@ -196,26 +225,26 @@ export function TabNavItem(props: {
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={title() !== undefined}>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => (
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
@@ -224,45 +253,45 @@ export function TabNavItem(props: {
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
)}
|
||||
</Show>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = title() ?? ""
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
@@ -280,6 +309,24 @@ export function TabNavItem(props: {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPreviewPopover
|
||||
trigger={tab}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
branch: branch(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
[data-component="session-tab-popover-trigger"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="session-tab-popover"] {
|
||||
z-index: 50;
|
||||
box-sizing: border-box;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
|
||||
background: var(--v2-background-bg-base);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
|
||||
color: var(--v2-text-text-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
|
||||
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
|
||||
&[data-closed] {
|
||||
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="project"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-slot="detail"] {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="server"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-tab-popover"],
|
||||
[data-component="session-tab-popover"][data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||
import { Show, type JSXElement } from "solid-js"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 200
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
|
||||
// tabs doesn't re-wait the full delay each time.
|
||||
const SKIP_WINDOW = 300
|
||||
let lastClosedAt = 0
|
||||
|
||||
export interface TabPreviewData {
|
||||
projectName?: string
|
||||
title?: string
|
||||
path?: string
|
||||
branch?: string
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
export function TabPreviewPopover(props: {
|
||||
trigger: JSXElement
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
data: TabPreviewData
|
||||
}) {
|
||||
let triggerEl: HTMLDivElement | undefined
|
||||
|
||||
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||
// resolves the skip window per-hover.
|
||||
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) lastClosedAt = Date.now()
|
||||
props.onOpenChange(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<Kobalte
|
||||
open={props.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
openDelay={resolveOpenDelay()}
|
||||
closeDelay={CLOSE_DELAY}
|
||||
// The preview is non-interactive (pointer-events: none), so there is no
|
||||
// safe area to traverse — leaving the tab hides it immediately.
|
||||
ignoreSafeArea
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
>
|
||||
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
|
||||
{props.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
ref={(el) => {
|
||||
// Portalled content lives outside the themed subtree, so mirror the
|
||||
// active theme like the v2 tooltip does.
|
||||
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
|
||||
if (theme) el.setAttribute("data-theme", theme)
|
||||
}}
|
||||
data-component="session-tab-popover"
|
||||
>
|
||||
<div data-slot="header">
|
||||
<Show when={props.data.projectName}>
|
||||
<span data-slot="project">{props.data.projectName}</span>
|
||||
</Show>
|
||||
<Show when={props.data.title}>
|
||||
<span data-slot="title">{props.data.title}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.path}>
|
||||
<div data-slot="row">
|
||||
<span data-slot="icon">
|
||||
<IconV2 name="folder" />
|
||||
</span>
|
||||
<span data-slot="detail">{props.data.path}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.branch}>
|
||||
<div data-slot="row">
|
||||
<span data-slot="icon">
|
||||
<IconV2 name="branch" />
|
||||
</span>
|
||||
<span data-slot="detail">{props.data.branch}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.serverName}>
|
||||
<div data-slot="server">{props.data.serverName}</div>
|
||||
</Show>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ function SessionTabSlot(props: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
@@ -50,6 +51,7 @@ function SessionTabSlot(props: {
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
@@ -87,7 +89,7 @@ function SessionTabSlot(props: {
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() }}
|
||||
classList={{ hidden: !session() && !missingSession() }}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -96,6 +98,7 @@ function SessionTabSlot(props: {
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
|
||||
@@ -713,13 +713,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel !== "beta" && channel !== "dev") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div
|
||||
class={`text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono ${channel === "beta" ? "bg-[#0488D1]" : "bg-icon-interactive-base"}`}
|
||||
>
|
||||
{channel === "beta" ? "β" : channel.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ type PlatformBase = {
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const fallback = server.key
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.global("tabs"),
|
||||
...Persist.window("tabs"),
|
||||
migrate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((tab) => {
|
||||
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -388,6 +388,20 @@ export const dict = {
|
||||
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
||||
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
||||
"wsl.onboarding.loading": "Loading...",
|
||||
"wsl.onboarding.installedDistros": "Installed distros",
|
||||
"wsl.onboarding.checkAgain": "Check again",
|
||||
"wsl.onboarding.distroStatus.ready": "Ready",
|
||||
"wsl.onboarding.distroStatus.checking": "Checking...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Need another distro?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
|
||||
"wsl.onboarding.installWsl": "Install WSL",
|
||||
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
||||
"wsl.onboarding.next": "Next",
|
||||
@@ -397,6 +411,7 @@ export const dict = {
|
||||
"wsl.onboarding.install": "Install",
|
||||
"wsl.onboarding.installing": "Installing...",
|
||||
"wsl.onboarding.installDistro": "Install distro",
|
||||
"wsl.onboarding.searchDistros": "Search distros",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
||||
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
||||
"wsl.onboarding.openTerminal": "Open terminal",
|
||||
@@ -604,7 +619,12 @@ export const dict = {
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
"session.error.serverConnection": "Can't connect to this server",
|
||||
"session.review.filesChanged": "{{count}} Files Changed",
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
@@ -637,6 +657,10 @@ export const dict = {
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
"session.question.pending.one": "{{count}} pending question",
|
||||
"session.question.pending.other": "{{count}} pending questions",
|
||||
"session.followupDock.summary.one": "{{count}} queued message",
|
||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||
"session.followupDock.sendNow": "Send now",
|
||||
|
||||
@@ -194,8 +194,19 @@
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"] {
|
||||
timeline-scope: --model-selector-scroll;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"] {
|
||||
timeline-scope: --manage-models-scroll;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::before,
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
[data-slot="home-projects-scroll"]::after,
|
||||
[data-slot="model-selector-scroll"]::before,
|
||||
[data-slot="model-selector-scroll"]::after,
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -211,11 +222,26 @@
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --home-projects-scroll y;
|
||||
@@ -233,4 +259,34 @@
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
|
||||
[data-slot="model-selector-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --model-selector-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
animation: home-projects-fade-bottom linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||
scroll-timeline: --manage-models-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --manage-models-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"
|
||||
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
|
||||
@@ -671,7 +671,7 @@ function HomeProjectColumn(props: {
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
@@ -697,7 +697,7 @@ function HomeProjectColumn(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1 pr-3">
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
ErrorBoundary,
|
||||
onCleanup,
|
||||
Show,
|
||||
Match,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
createMemo,
|
||||
createEffect,
|
||||
createComputed,
|
||||
createResource,
|
||||
on,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -19,29 +22,37 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
@@ -73,8 +84,8 @@ import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
|
||||
@@ -91,6 +102,15 @@ const sessionViewState = () => ({
|
||||
changes: "git" as ChangeMode,
|
||||
})
|
||||
|
||||
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
|
||||
return error instanceof Error && error.message === `Session not found: ${sessionID}`
|
||||
}
|
||||
|
||||
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
||||
if (!sessionID) return false
|
||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||
}
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
@@ -114,6 +134,204 @@ async function runPromptRollbackMutation<T, R>(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function SessionPage() {
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Page />
|
||||
</SessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) =>
|
||||
settings.general.newLayoutDesigns() ? (
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const displayServer = createMemo(() => {
|
||||
const key = props.serverKey ?? server.key
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return conn ? serverName(conn) : key
|
||||
})
|
||||
const closeTab = () => {
|
||||
if (!props.sessionID) return
|
||||
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
|
||||
}
|
||||
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
|
||||
<div class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("session.error.notFound.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
{(sessionID) => (
|
||||
<div class="max-w-full flex flex-col items-center gap-1">
|
||||
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
|
||||
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
|
||||
{sessionID()}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
|
||||
{language.t("session.error.notFound.closeTab")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ErrorPage error={props.error} />
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, sync: sync() }
|
||||
},
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionPage />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": props.newLayout,
|
||||
"bg-background-stronger": !props.newLayout,
|
||||
"rounded-[10px] overflow-hidden": props.newLayout,
|
||||
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -1695,9 +1913,88 @@ export default function Page() {
|
||||
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(sessionKey, reset, { defer: true }))
|
||||
return <SessionErrorFallback error={error} sessionID={params.id} />
|
||||
}
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
{sessionSync() ?? ""}
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<div
|
||||
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
||||
@@ -1717,85 +2014,13 @@ export default function Page() {
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
>
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</div>
|
||||
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
|
||||
{settings.general.newLayoutDesigns() ? (
|
||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||
) : (
|
||||
sessionPanelContent()
|
||||
)}
|
||||
</SessionPanelFrame>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
@@ -1832,6 +2057,6 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
minimized: false,
|
||||
optionsHeight: 180,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let optionsRef: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const optionsOff = createMemo(() => hidden() > 0.98)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = optionsRef
|
||||
if (!el) return
|
||||
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const minimize = () => {
|
||||
if (sending()) return
|
||||
setStore("editing", false)
|
||||
setStore("minimized", true)
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
if (sending()) return
|
||||
setStore("minimized", false)
|
||||
focus(pickFocus())
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-component="session-question-dock">
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-header-actions">
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
data-component="icon-button"
|
||||
data-icon="chevron-down"
|
||||
data-size="normal"
|
||||
data-variant="ghost"
|
||||
disabled={sending()}
|
||||
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||
onClick={store.minimized ? restore : minimize}
|
||||
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text" class="overflow-auto">
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
style={{
|
||||
display: store.minimized ? "-webkit-box" : undefined,
|
||||
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={!store.minimized}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => (optionsRef = el)}
|
||||
data-slot="question-options"
|
||||
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
||||
classList={{ "pointer-events-none": hidden() > 0.1 }}
|
||||
style={{
|
||||
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
|
||||
visibility: optionsOff() ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1375,14 +1375,14 @@ export function MessageTimeline(props: {
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="px-2 text-14-medium text-text-weak"
|
||||
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -1395,7 +1395,7 @@ export function MessageTimeline(props: {
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"text-14-medium text-text-strong truncate": true,
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
@@ -1414,7 +1414,7 @@ export function MessageTimeline(props: {
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
classList={{
|
||||
"text-14-medium text-text-strong block": true,
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
|
||||
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
|
||||
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
scope?: "window"
|
||||
legacyStorageNames?: string[]
|
||||
key: string
|
||||
legacy?: string[]
|
||||
@@ -24,6 +25,7 @@ type PersistTarget = {
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const WINDOW_STORAGE = "opencode.window"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = new Map<string, boolean>()
|
||||
|
||||
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
|
||||
return `opencode.draft.${head}.${sum}.dat`
|
||||
}
|
||||
|
||||
function windowStorage(windowID: string) {
|
||||
const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return `${WINDOW_STORAGE}.${safe}.dat`
|
||||
}
|
||||
|
||||
function legacyWorkspaceStorage(dir: string) {
|
||||
const storage = workspaceStorage(pathKey(dir))
|
||||
const result = new Set<string>()
|
||||
@@ -467,6 +474,8 @@ export const PersistTesting = {
|
||||
localStorageWithPrefix,
|
||||
migrateLegacy,
|
||||
normalize,
|
||||
resolveTarget,
|
||||
windowStorage,
|
||||
workspaceStorage,
|
||||
}
|
||||
|
||||
@@ -474,6 +483,9 @@ export const Persist = {
|
||||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
},
|
||||
window(key: string, legacy?: string[]): PersistTarget {
|
||||
return { scope: "window", key, legacy }
|
||||
},
|
||||
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
||||
},
|
||||
@@ -503,6 +515,16 @@ export const Persist = {
|
||||
},
|
||||
}
|
||||
|
||||
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
|
||||
if (target.scope !== "window") return target
|
||||
if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
|
||||
const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
|
||||
return {
|
||||
...target,
|
||||
storage: windowStorage(windowID),
|
||||
}
|
||||
}
|
||||
|
||||
export function removePersisted(
|
||||
target: { storage?: string; legacyStorageNames?: string[]; key: string },
|
||||
platform?: Platform,
|
||||
@@ -533,7 +555,7 @@ export function persisted<T>(
|
||||
store: [Store<T>, SetStoreFunction<T>],
|
||||
): PersistedWithReady<T> {
|
||||
const platform = usePlatform()
|
||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const defaults = snapshot(store[0])
|
||||
const legacy = config.legacy ?? []
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import {
|
||||
addServerProbePlan,
|
||||
createProbeFailureGate,
|
||||
runAddableProbePlan,
|
||||
type AddServerProbePlan,
|
||||
type WslAddServerView,
|
||||
} from "./settings-model"
|
||||
import type { WslInstalledDistro, WslServersPlatform, WslServersState } from "./types"
|
||||
|
||||
export function useWslAddServerProbes(input: {
|
||||
state: Accessor<WslServersState | undefined>
|
||||
api: WslServersPlatform
|
||||
view: Accessor<WslAddServerView>
|
||||
adding: Accessor<boolean>
|
||||
busy: Accessor<boolean>
|
||||
selectedDistro: Accessor<string | null>
|
||||
addableInstalledDistros: Accessor<WslInstalledDistro[]>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const gate = createProbeFailureGate()
|
||||
const probe = useMutation(() => ({
|
||||
mutationFn: async (command: AddServerProbePlan) => {
|
||||
if (command.kind === "addable") {
|
||||
await runAddableProbePlan({ plan: command.plan, api: input.api })
|
||||
return
|
||||
}
|
||||
if (command.plan.action === "probeRuntime") await input.api.probeRuntime()
|
||||
if (command.plan.action === "refreshDistros") await input.api.refreshDistros()
|
||||
},
|
||||
onError: input.onError,
|
||||
onSettled: (_result, error, command) => {
|
||||
if (command) gate.settle(command.key, error)
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (probe.isPending) return
|
||||
const command = addServerProbePlan({
|
||||
state: input.state(),
|
||||
view: input.view(),
|
||||
adding: input.adding(),
|
||||
busy: input.busy(),
|
||||
selectedDistro: input.selectedDistro(),
|
||||
addableInstalledDistros: input.addableInstalledDistros(),
|
||||
})
|
||||
if (!command || !gate.accepts(command.key)) return
|
||||
probe.mutate(command)
|
||||
})
|
||||
|
||||
return {
|
||||
probingAddable: () => probe.isPending && probe.variables?.kind === "addable",
|
||||
resetProbeFailure: () => gate.reset(),
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
|
||||
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslAddServerProbes } from "./add-server-probes"
|
||||
import { useWslServers } from "./context"
|
||||
import { enterWslOpencodeStep } from "./settings-model"
|
||||
import { addServerViewModel, type AddServerText } from "./settings-model"
|
||||
import "./dialog-add-wsl-server.css"
|
||||
|
||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
||||
function isWslRuntimeMissing(error: string | null | undefined) {
|
||||
if (!error) return true
|
||||
return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error)
|
||||
}
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
function translate(language: ReturnType<typeof useLanguage>, value: AddServerText) {
|
||||
if (value.params) return language.t(value.key, value.params)
|
||||
return language.t(value.key)
|
||||
}
|
||||
|
||||
interface DialogWslServerProps {
|
||||
@@ -22,210 +30,272 @@ interface DialogWslServerProps {
|
||||
}
|
||||
|
||||
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const controller = useWslAddServerController(props)
|
||||
const model = controller.model
|
||||
const primaryButton = () => model().primaryButton
|
||||
const primaryButtonStyle = () => {
|
||||
const width = primaryButton().width
|
||||
if (!width) return undefined
|
||||
return { width }
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!controller.wslServers.isPending && !controller.wslServers.isError}
|
||||
fallback={
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<Show
|
||||
when={!controller.wslServers.isError}
|
||||
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
|
||||
>
|
||||
<div class="settings-v2-wsl-loading">
|
||||
<LoaderV2 />
|
||||
</div>
|
||||
</Show>
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={model().runtimeState === "ready"}
|
||||
fallback={
|
||||
<Show
|
||||
when={model().runtimeState === "checking" || model().runtimeState === "loading"}
|
||||
fallback={
|
||||
<DialogWslSetup
|
||||
state={model().runtimeState}
|
||||
error={controller.runtimeError()}
|
||||
installable={isWslRuntimeMissing(controller.runtimeError())}
|
||||
busy={model().busy}
|
||||
onInstall={controller.installWsl}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<div class="settings-v2-wsl-loading">
|
||||
<LoaderV2 />
|
||||
</div>
|
||||
</Dialog>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Dialog fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>
|
||||
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<Show
|
||||
when={controller.view() === "main"}
|
||||
fallback={
|
||||
<>
|
||||
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
|
||||
<TextInputV2
|
||||
class="settings-v2-wsl-catalog-search"
|
||||
appearance="large"
|
||||
placeholder={language.t("wsl.onboarding.searchDistros")}
|
||||
value={controller.catalogSearch()}
|
||||
disabled={model().busy}
|
||||
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
|
||||
/>
|
||||
<div class="settings-v2-wsl-catalog-list">
|
||||
<RadioGroupV2
|
||||
hideLabel
|
||||
class="settings-v2-wsl-distro-group"
|
||||
label={language.t("wsl.onboarding.installDistro")}
|
||||
value={model().catalogTarget ?? undefined}
|
||||
onChange={controller.setCatalogTarget}
|
||||
disabled={model().busy}
|
||||
>
|
||||
<For each={model().filteredInstallableDistros}>
|
||||
{(item) => (
|
||||
<RadioItemV2
|
||||
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
|
||||
value={item.name}
|
||||
disabled={model().busy}
|
||||
label={<span class="settings-v2-wsl-distro-label">{item.label}</span>}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</RadioGroupV2>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
variant={model().installingCatalogDistro ? "loading" : "contrast"}
|
||||
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
|
||||
style={{ width: "99px" }}
|
||||
onClick={controller.installCatalogDistro}
|
||||
>
|
||||
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
|
||||
<LoaderV2 />
|
||||
</Show>
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DialogBody class="settings-v2-wsl-dialog-body">
|
||||
<div class="settings-v2-wsl-section-header">
|
||||
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
|
||||
<ButtonV2
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
disabled={model().busy}
|
||||
onClick={controller.refreshDistros}
|
||||
>
|
||||
{language.t("wsl.onboarding.checkAgain")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={model().addableInstalledDistros.length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-wsl-distro-list">
|
||||
<div class="settings-v2-wsl-distro-empty">
|
||||
{model().visibleInstalledDistros.length
|
||||
? language.t("wsl.onboarding.allDistrosAdded")
|
||||
: language.t("wsl.onboarding.noDistros")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="settings-v2-wsl-distro-list">
|
||||
<RadioGroupV2
|
||||
hideLabel
|
||||
class="settings-v2-wsl-distro-group"
|
||||
label={language.t("wsl.onboarding.installedDistros")}
|
||||
value={model().selectedDistro ?? undefined}
|
||||
onChange={controller.setSelectedDistro}
|
||||
disabled={model().busy}
|
||||
>
|
||||
<For each={model().addableInstalledDistros}>
|
||||
{(item) => {
|
||||
const status = () => model().distroStatuses[item.name] ?? null
|
||||
return (
|
||||
<RadioItemV2
|
||||
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
|
||||
value={item.name}
|
||||
disabled={item.version === 1 || model().busy}
|
||||
label={<span class="settings-v2-wsl-distro-label">{item.name}</span>}
|
||||
description={
|
||||
<Show when={status()}>
|
||||
{(value) => (
|
||||
<span class="settings-v2-wsl-distro-status" data-tone={value().tone}>
|
||||
{translate(language, value().label)}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</RadioGroupV2>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={model().installableDistros.length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-wsl-catalog-card"
|
||||
disabled={model().busy}
|
||||
onClick={controller.openCatalog}
|
||||
>
|
||||
<span class="settings-v2-wsl-catalog-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.5564 10.4443V13.5554H4.22309C3.24087 13.5554 2.44531 13.5554 2.44531 13.5554V10.4443M11.112 5.99989L8.00087 9.111L4.88976 5.99989M8.00087 9.111L8.00087 2.44434"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="settings-v2-wsl-catalog-copy">
|
||||
<span class="settings-v2-wsl-catalog-title">{language.t("wsl.onboarding.needAnotherDistro")}</span>
|
||||
<span class="settings-v2-wsl-catalog-description">
|
||||
{language.t("wsl.onboarding.needAnotherDistroHint")}
|
||||
</span>
|
||||
</span>
|
||||
<span class="settings-v2-wsl-catalog-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 12L10 8L6 4" stroke="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
variant={primaryButton().loading ? "loading" : primaryButton().variant}
|
||||
disabled={!primaryButton().loading && primaryButton().disabled}
|
||||
style={primaryButtonStyle()}
|
||||
onClick={controller.runPrimary}
|
||||
>
|
||||
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
|
||||
<LoaderV2 />
|
||||
</Show>
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Show>
|
||||
</Dialog>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function useWslAddServerController(props: DialogWslServerProps) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const api = platform.wslServers!
|
||||
const [store, setStore] = createStore({
|
||||
step: undefined as WslServerStep | undefined,
|
||||
view: "main" as "main" | "catalog",
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null as string | null,
|
||||
adding: false,
|
||||
})
|
||||
const current = () => wslServers.data
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
const viewModel = (probingAddable: boolean) =>
|
||||
addServerViewModel({
|
||||
state: current(),
|
||||
view: store.view,
|
||||
selectedDistro: store.selectedDistro,
|
||||
catalogSearch: store.catalogSearch,
|
||||
catalogTarget: store.catalogTarget,
|
||||
adding: store.adding,
|
||||
probingAddable,
|
||||
})
|
||||
const baseModel = createMemo(() => viewModel(false))
|
||||
const probes = useWslAddServerProbes({
|
||||
state: current,
|
||||
api,
|
||||
view: () => store.view,
|
||||
adding: () => store.adding,
|
||||
busy: () => baseModel().busy,
|
||||
selectedDistro: () => baseModel().selectedDistro,
|
||||
addableInstalledDistros: () => baseModel().addableInstalledDistros,
|
||||
onError: (error) => requestError(language, error),
|
||||
})
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const selectedDistro = createMemo(() => {
|
||||
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
|
||||
return store.selectedDistro
|
||||
}
|
||||
const distro = defaultInstalledDistro()
|
||||
if (distro && !existingServerDistros().has(distro.name)) return distro.name
|
||||
return null
|
||||
})
|
||||
const selectedProbe = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.distroProbes[distro] ?? null
|
||||
})
|
||||
const selectedInstalled = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !selectedDistro()) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
if (distroReady()) return null
|
||||
return probe
|
||||
})
|
||||
const distroUnavailableMessage = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
const distro = selectedDistro()
|
||||
if (!probe || probe.canExecute || !distro) return null
|
||||
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
|
||||
return language.t("wsl.onboarding.openDistroOnce", { distro })
|
||||
})
|
||||
const distroMissingTools = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
if (!probe?.canExecute) return null
|
||||
if (probe.hasBash && probe.hasCurl) return null
|
||||
return probe
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
const installTarget = createMemo(
|
||||
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
|
||||
)
|
||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
||||
const installingOpencode = createMemo(() => {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === selectedDistro()
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
if (!job) return store.adding
|
||||
return store.adding || job.kind !== "probe-opencode"
|
||||
})
|
||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
||||
if (!wslReady()) return "wsl"
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
const model = createMemo(() => viewModel(probes.probingAddable()))
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => api.refreshDistros() }
|
||||
}
|
||||
const distro = selectedDistro()
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
let lastAutoProbe: string | null = null
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
const wslMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
|
||||
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
|
||||
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
|
||||
return state.runtime?.error ?? language.t("wsl.onboarding.required")
|
||||
})
|
||||
|
||||
const distroMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingDistros")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-distro")
|
||||
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "probe-distro")
|
||||
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
|
||||
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
|
||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
||||
if (selectedProbe() && distroReady())
|
||||
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
|
||||
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
|
||||
return language.t("wsl.onboarding.pickDistro")
|
||||
})
|
||||
|
||||
const opencodeMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return language.t("wsl.onboarding.checkingOpencode")
|
||||
const distro = selectedDistro()
|
||||
if (state.job?.kind === "install-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updatingOpencode")
|
||||
}
|
||||
if (state.job?.kind === "probe-opencode") {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.checkingOpencode")
|
||||
}
|
||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
||||
if (opencodeCheck()?.matchesDesktop === false) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.updateOpencode")
|
||||
}
|
||||
if (opencodeReady()) {
|
||||
return distro
|
||||
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
|
||||
: language.t("wsl.onboarding.opencodeReady")
|
||||
}
|
||||
return distro
|
||||
? language.t("wsl.onboarding.installOpencodeIn", { distro })
|
||||
: language.t("wsl.onboarding.chooseDistroFirst")
|
||||
})
|
||||
const openCatalog = () => {
|
||||
const first = model().installableDistros[0]
|
||||
setStore({
|
||||
view: "catalog",
|
||||
catalogSearch: "",
|
||||
catalogTarget: first?.name ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
@@ -235,26 +305,43 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => action(distro))
|
||||
const refreshDistros = () => {
|
||||
void run(async () => {
|
||||
probes.resetProbeFailure()
|
||||
await api.refreshDistros()
|
||||
})
|
||||
}
|
||||
|
||||
const selectDistro = (name: string) => {
|
||||
setStore("selectedDistro", name)
|
||||
setStore("step", undefined)
|
||||
const installDistro = (name: string) => {
|
||||
void run(async () => {
|
||||
probes.resetProbeFailure()
|
||||
await api.installDistro(name)
|
||||
setStore("view", "main")
|
||||
})
|
||||
}
|
||||
|
||||
const openOpencodeStep = () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
|
||||
const installCatalogDistro = () => {
|
||||
if (model().installingCatalogDistro) return
|
||||
const name = model().catalogTarget
|
||||
if (!name) return
|
||||
installDistro(name)
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
const closeCatalog = () => {
|
||||
probes.resetProbeFailure()
|
||||
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
|
||||
}
|
||||
|
||||
const runPrimary = async () => {
|
||||
const button = model().primaryButton
|
||||
if (button.loading) return
|
||||
const distro = model().selectedDistro
|
||||
const action = button.action
|
||||
if (!distro || !action) return
|
||||
if (action === "install-opencode") {
|
||||
await run(() => api.installOpencode(distro))
|
||||
return
|
||||
}
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
@@ -270,346 +357,99 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createMemo(() => {
|
||||
const active = activeStep()
|
||||
const activeIndex = STEPS.indexOf(active)
|
||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
||||
return STEPS.map((step) => {
|
||||
const index = STEPS.indexOf(step)
|
||||
return {
|
||||
step,
|
||||
title:
|
||||
step === "wsl"
|
||||
? language.t("wsl.server.label")
|
||||
: step === "distro"
|
||||
? language.t("wsl.onboarding.step.distro")
|
||||
: language.t("wsl.onboarding.step.opencode"),
|
||||
state:
|
||||
active === step
|
||||
? "current"
|
||||
: step === "wsl"
|
||||
? wslReady()
|
||||
? "done"
|
||||
: "warning"
|
||||
: step === "distro"
|
||||
? distroReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning"
|
||||
: opencodeCheck()?.matchesDesktop === false
|
||||
? "warning"
|
||||
: opencodeReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning",
|
||||
locked: index > recommendedIndex,
|
||||
}
|
||||
})
|
||||
})
|
||||
const loadError = createMemo(() => {
|
||||
const loadError = () => {
|
||||
const error = wslServers.error
|
||||
if (!error) return language.t("wsl.onboarding.loadFailed")
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
wslServers,
|
||||
model,
|
||||
loadError,
|
||||
runtimeError: () => current()?.runtime?.error ?? null,
|
||||
view: () => store.view,
|
||||
catalogSearch: () => store.catalogSearch,
|
||||
adding: () => store.adding,
|
||||
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
|
||||
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
|
||||
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
|
||||
openCatalog,
|
||||
closeCatalog,
|
||||
refreshDistros,
|
||||
installCatalogDistro,
|
||||
installWsl: () => void run(() => api.installWsl()),
|
||||
runPrimary: () => void runPrimary(),
|
||||
close: () => dialog.close(),
|
||||
}
|
||||
}
|
||||
|
||||
function DialogWslSetup(props: {
|
||||
state: string
|
||||
error: string | null
|
||||
installable: boolean
|
||||
busy: boolean
|
||||
onInstall: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const title = () =>
|
||||
props.state === "pendingRestart"
|
||||
? language.t("wsl.onboarding.restartRequired")
|
||||
: props.installable
|
||||
? language.t("wsl.onboarding.wslNotInstalled.title")
|
||||
: language.t("wsl.onboarding.wslUnavailable.title")
|
||||
const description = () => {
|
||||
if (props.state === "pendingRestart") return language.t("wsl.onboarding.windowsRestartRequired")
|
||||
if (!props.installable) return language.t("wsl.onboarding.wslUnavailable.description")
|
||||
return language.t("wsl.onboarding.wslNotInstalled.description")
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show
|
||||
when={!wslServers.isPending}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
|
||||
>
|
||||
<Show
|
||||
when={!wslServers.isError}
|
||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
|
||||
>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
|
||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => api.installWsl())}
|
||||
>
|
||||
{language.t("wsl.onboarding.installWsl")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
||||
<Show when={current()?.pendingRestart}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3">
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.windowsRestartRequired")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !wslReady()}
|
||||
onClick={() => setStore("step", "distro")}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{visibleInstalledDistros().length
|
||||
? language.t("wsl.onboarding.allDistrosAdded")
|
||||
: current()?.runtime?.available
|
||||
? language.t("wsl.onboarding.noDistros")
|
||||
: language.t("wsl.onboarding.checkingDistros")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={addableInstalledDistros()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
||||
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
|
||||
onClick={() => selectDistro(item.name)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
||||
<Show when={item.isDefault}>
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={installableDistros().length > 0}>
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={installingDistro()}>
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro()
|
||||
? language.t("wsl.onboarding.installing")
|
||||
: language.t("wsl.onboarding.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={language.t("wsl.onboarding.installDistro")}
|
||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
||||
>
|
||||
<For each={installableDistros()}>
|
||||
{(item) => {
|
||||
const selected = () => installTarget()?.name === item.name
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected()}
|
||||
disabled={busy()}
|
||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
||||
classList={{
|
||||
"bg-surface-raised-base": selected(),
|
||||
"hover:bg-surface-base": !selected(),
|
||||
}}
|
||||
onClick={() => setStore("installTarget", item.name)}
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
||||
classList={{ "border-text-strong": selected() }}
|
||||
>
|
||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<Show when={selectedInstalled()?.version === 1}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.wsl2Required")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={distroUnavailableMessage()}>
|
||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
||||
</Show>
|
||||
<Show when={distroMissingTools()}>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.toolsRequired")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.openTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro() || !distroReady()}
|
||||
onClick={openOpencodeStep}
|
||||
>
|
||||
{language.t("wsl.onboarding.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "opencode"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
||||
>
|
||||
{language.t("wsl.onboarding.refresh")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
||||
>
|
||||
<Show when={installingOpencode()}>
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{opencodeCheck()?.resolvedPath
|
||||
? language.t("wsl.onboarding.updateOpencode")
|
||||
: language.t("wsl.onboarding.installOpencode")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
||||
{(check) => (
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.path", {
|
||||
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
|
||||
})}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{language.t("wsl.onboarding.version", {
|
||||
version: check().version ?? language.t("wsl.onboarding.unknown"),
|
||||
})}
|
||||
<Show when={check().expectedVersion}>
|
||||
{(expected) => (
|
||||
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
{language.t("wsl.onboarding.versionMismatch")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
||||
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog fit class="settings-v2-wsl-not-installed-dialog">
|
||||
<div class="settings-v2-wsl-not-installed-content">
|
||||
<div class="settings-v2-wsl-not-installed-message">
|
||||
<svg
|
||||
class="settings-v2-wsl-not-installed-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g clip-path="url(#settings-v2-wsl-warning-clip)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 -0.00244141L23.6926 20.2498H0.308594L12 -0.00244141ZM12.7954 6.32932C12.5844 6.11834 12.2982 5.99982 11.9999 5.99982C11.7015 5.99982 11.4154 6.11834 11.2044 6.32932C10.9934 6.5403 10.8749 6.82645 10.8749 7.12482V11.6248C10.8749 11.9232 10.9934 12.2093 11.2044 12.4203C11.4154 12.6313 11.7015 12.7498 11.9999 12.7498C12.2982 12.7498 12.5844 12.6313 12.7954 12.4203C13.0064 12.2093 13.1249 11.9232 13.1249 11.6248V7.12482C13.1249 6.82645 13.0064 6.5403 12.7954 6.32932ZM13.0605 17.5605C12.7792 17.8418 12.3977 17.9998 11.9999 17.9998C11.6021 17.9998 11.2205 17.8418 10.9392 17.5605C10.6579 17.2792 10.4999 16.8976 10.4999 16.4998C10.4999 16.102 10.6579 15.7205 10.9392 15.4392C11.2205 15.1579 11.6021 14.9998 11.9999 14.9998C12.3977 14.9998 12.7792 15.1579 13.0605 15.4392C13.3418 15.7205 13.4999 16.102 13.4999 16.4998C13.4999 16.8976 13.3418 17.2792 13.0605 17.5605Z"
|
||||
fill="#DBDBDB"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="settings-v2-wsl-warning-clip">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h2 class="settings-v2-wsl-not-installed-title">{title()}</h2>
|
||||
<p class="settings-v2-wsl-not-installed-description">{description()}</p>
|
||||
<Show when={!props.installable && props.error}>
|
||||
<p class="settings-v2-wsl-unavailable-error">{props.error}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.state === "unavailable" && props.installable}>
|
||||
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
|
||||
{language.t("wsl.onboarding.installWsl")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.state !== "unavailable"}>
|
||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.close")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-content"] {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-header"] {
|
||||
align-items: center;
|
||||
padding: 24px 16px 0;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-body"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 16px 1px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-footer"] {
|
||||
padding: 0 16px 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
min-height: 264px;
|
||||
height: auto;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-content"] {
|
||||
align-items: stretch;
|
||||
min-height: 264px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-body"] {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 56px 0;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-icon {
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-title {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-not-installed-description {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-unavailable-error {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 4px 0 0;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
height: 24px;
|
||||
padding: 0 0 0 9px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-section-title {
|
||||
flex: none;
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-group [data-slot="radio-v2-items"] {
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -14px -14px -14px -44px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-label {
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status {
|
||||
flex: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.05px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="success"] {
|
||||
color: var(--v2-state-fg-success);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="warning"] {
|
||||
color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-status[data-tone="muted"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-row:where([data-disabled]) .settings-v2-wsl-distro-status {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-distro-empty {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1.4;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 14px 14px 16px 16px;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-card:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-title {
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.04px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-description {
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.05px;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-chevron {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-picker {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-catalog-row {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.settings-v2-wsl-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
import {
|
||||
addableProbePlan,
|
||||
addServerProbePlan,
|
||||
addServerViewModel,
|
||||
autoProbePlan,
|
||||
createProbeFailureGate,
|
||||
runAddableProbePlan,
|
||||
wslOpencodeAction,
|
||||
wslRuntimeRetryable,
|
||||
} from "./settings-model"
|
||||
import type { WslServersState } from "./types"
|
||||
|
||||
const readyWslState = readyState()
|
||||
|
||||
function readyState(input: Partial<WslServersState> = {}): WslServersState {
|
||||
return {
|
||||
runtime: { available: true, version: "2.4.13.0", error: null },
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
describe("WSL server settings presentation", () => {
|
||||
test("retries only settled unsuccessful runtimes", () => {
|
||||
@@ -45,13 +71,161 @@ describe("WSL server settings presentation", () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("probes the selected distro before entering the OpenCode step", async () => {
|
||||
const calls: string[] = []
|
||||
await enterWslOpencodeStep(
|
||||
"Debian",
|
||||
async (distro) => calls.push(distro),
|
||||
(step) => calls.push(step),
|
||||
)
|
||||
expect(calls).toEqual(["Debian", "opencode"])
|
||||
test("plans addable distro probes with the selected distro first", () => {
|
||||
const plan = addableProbePlan({
|
||||
state: readyWslState,
|
||||
view: "main",
|
||||
adding: false,
|
||||
selectedDistro: "Ubuntu",
|
||||
addableInstalledDistros: [
|
||||
{ name: "Debian", version: 2, isDefault: true },
|
||||
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||
],
|
||||
})
|
||||
|
||||
expect(plan?.key).toBe("distro:Ubuntu|distro:Debian")
|
||||
expect(plan?.distros).toEqual(["Ubuntu", "Debian"])
|
||||
})
|
||||
|
||||
test("plans bootstrap probes for missing runtime and initial distro lists", () => {
|
||||
expect(autoProbePlan({ state: undefined, busy: false })).toBeUndefined()
|
||||
expect(autoProbePlan({ state: { ...readyWslState, runtime: null }, busy: false })).toEqual({
|
||||
key: "runtime",
|
||||
action: "probeRuntime",
|
||||
})
|
||||
expect(autoProbePlan({ state: readyWslState, busy: false })).toEqual({
|
||||
key: "distros",
|
||||
action: "refreshDistros",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses one command plan for bootstrap before addable distro probing", () => {
|
||||
expect(
|
||||
addServerProbePlan({
|
||||
state: { ...readyWslState, runtime: null },
|
||||
view: "main",
|
||||
adding: false,
|
||||
busy: false,
|
||||
selectedDistro: null,
|
||||
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
}),
|
||||
).toEqual({ kind: "auto", key: "auto:runtime", plan: { key: "runtime", action: "probeRuntime" } })
|
||||
|
||||
expect(
|
||||
addServerProbePlan({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
},
|
||||
view: "main",
|
||||
adding: false,
|
||||
busy: false,
|
||||
selectedDistro: "Debian",
|
||||
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
}),
|
||||
).toEqual({ kind: "addable", key: "addable:distro:Debian", plan: { key: "distro:Debian", distros: ["Debian"] } })
|
||||
})
|
||||
|
||||
test("does not accept the same failed probe command until reset", () => {
|
||||
const gate = createProbeFailureGate()
|
||||
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||
gate.settle("addable:distro:Debian", new Error("wsl failed"))
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(false)
|
||||
expect(gate.accepts("addable:distro:Ubuntu")).toBe(true)
|
||||
gate.reset()
|
||||
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps default distro selection stable while probes resolve", () => {
|
||||
const model = addServerViewModel({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [
|
||||
{ name: "Debian", version: 2, isDefault: true },
|
||||
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||
],
|
||||
online: [{ name: "Alpine", label: "Alpine Linux" }],
|
||||
distroProbes: {
|
||||
Ubuntu: { name: "Ubuntu", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||
},
|
||||
},
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: false,
|
||||
})
|
||||
|
||||
expect(model.selectedDistro).toBe("Debian")
|
||||
})
|
||||
|
||||
test("keeps the dialog busy across serial addable probe job gaps", () => {
|
||||
const model = addServerViewModel({
|
||||
state: readyState({
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
}),
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: true,
|
||||
})
|
||||
|
||||
expect(model.busy).toBe(true)
|
||||
})
|
||||
|
||||
test("does not report ready when OpenCode is present but cannot run", () => {
|
||||
const model = addServerViewModel({
|
||||
state: {
|
||||
...readyWslState,
|
||||
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||
distroProbes: {
|
||||
Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||
},
|
||||
opencodeChecks: {
|
||||
Debian: {
|
||||
distro: "Debian",
|
||||
resolvedPath: "/home/me/.opencode/bin/opencode",
|
||||
version: null,
|
||||
expectedVersion: "1.2.3",
|
||||
matchesDesktop: null,
|
||||
error: "opencode is installed but could not run",
|
||||
},
|
||||
},
|
||||
},
|
||||
view: "main",
|
||||
selectedDistro: null,
|
||||
catalogSearch: "",
|
||||
catalogTarget: null,
|
||||
adding: false,
|
||||
probingAddable: false,
|
||||
})
|
||||
|
||||
expect(model.distroStatuses.Debian).toEqual({
|
||||
label: { key: "wsl.onboarding.installOpencode" },
|
||||
tone: "warning",
|
||||
})
|
||||
expect(model.primaryButton.action).toBe("install-opencode")
|
||||
})
|
||||
|
||||
test("delegates addable probe plans to one batch command", async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await runAddableProbePlan({
|
||||
plan: { key: "distro:Debian|distro:Ubuntu", distros: ["Debian", "Ubuntu"] },
|
||||
api: {
|
||||
probeAddable: async (distros) => {
|
||||
calls.push(distros)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([["Debian", "Ubuntu"]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,341 @@
|
||||
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslServersPlatform,
|
||||
WslServerRuntime,
|
||||
WslServersState,
|
||||
} from "./types"
|
||||
|
||||
export type AddServerText = {
|
||||
key: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export type DistroStatusTone = "success" | "warning" | "muted"
|
||||
|
||||
export type DistroStatus = {
|
||||
label: AddServerText
|
||||
tone: DistroStatusTone
|
||||
}
|
||||
|
||||
export type AddServerPrimaryButton = {
|
||||
variant: "neutral" | "contrast"
|
||||
label: AddServerText
|
||||
disabled: boolean
|
||||
action: "install-opencode" | "add" | null
|
||||
loading: boolean
|
||||
width: string | null
|
||||
}
|
||||
|
||||
export type AddServerRuntimeState = "loading" | "pendingRestart" | "checking" | "unavailable" | "ready"
|
||||
|
||||
export type AddableProbePlan = {
|
||||
key: string
|
||||
distros: string[]
|
||||
}
|
||||
|
||||
export type AutoProbePlan = { key: "runtime"; action: "probeRuntime" } | { key: "distros"; action: "refreshDistros" }
|
||||
|
||||
export type AddServerProbePlan =
|
||||
| { kind: "auto"; key: string; plan: AutoProbePlan }
|
||||
| { kind: "addable"; key: string; plan: AddableProbePlan }
|
||||
|
||||
export type WslAddServerView = "main" | "catalog"
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
}
|
||||
|
||||
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
|
||||
runtime.kind === "failed" || runtime.kind === "stopped"
|
||||
|
||||
export async function enterWslOpencodeStep(
|
||||
distro: string,
|
||||
probe: (distro: string) => Promise<unknown>,
|
||||
select: (step: "opencode") => void,
|
||||
) {
|
||||
await probe(distro)
|
||||
select("opencode")
|
||||
}
|
||||
|
||||
export function wslOpencodeAction(check?: WslOpencodeCheck) {
|
||||
if (!check) return
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||
}
|
||||
|
||||
export function wslDistroReady(state: WslServersState | undefined, name: string) {
|
||||
const installed = state?.installed.find((item) => item.name === name)
|
||||
const probe = state?.distroProbes[name]
|
||||
if (!probe || !installed) return false
|
||||
if (installed.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
}
|
||||
|
||||
export function addServerViewModel(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
selectedDistro: string | null
|
||||
catalogSearch: string
|
||||
catalogTarget: string | null
|
||||
adding: boolean
|
||||
probingAddable: boolean
|
||||
}) {
|
||||
const state = input.state
|
||||
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
|
||||
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
|
||||
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
|
||||
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
|
||||
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
|
||||
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
|
||||
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
|
||||
const busy = !!state?.job || input.adding || input.probingAddable
|
||||
|
||||
return {
|
||||
busy,
|
||||
runtimeState: addServerRuntimeState(state),
|
||||
visibleInstalledDistros,
|
||||
visibleOnlineDistros,
|
||||
addableInstalledDistros,
|
||||
selectedDistro,
|
||||
opencodeCheck,
|
||||
wslReady: !!state?.runtime?.available && !state?.pendingRestart,
|
||||
distroStatuses: Object.fromEntries(
|
||||
addableInstalledDistros.flatMap((item) => {
|
||||
const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable })
|
||||
if (!status) return []
|
||||
return [[item.name, status]]
|
||||
}),
|
||||
) as Record<string, DistroStatus | undefined>,
|
||||
primaryButton: addServerPrimaryButton({
|
||||
state,
|
||||
selectedDistro,
|
||||
opencodeCheck,
|
||||
adding: input.adding,
|
||||
probingAddable: input.probingAddable,
|
||||
}),
|
||||
installableDistros,
|
||||
filteredInstallableDistros,
|
||||
catalogTarget,
|
||||
installingCatalogDistro: state?.job?.kind === "install-distro",
|
||||
}
|
||||
}
|
||||
|
||||
function addServerSelectedDistro(
|
||||
selected: string | null,
|
||||
visibleInstalledDistros: WslInstalledDistro[],
|
||||
addableInstalledDistros: WslInstalledDistro[],
|
||||
) {
|
||||
if (selected && addableInstalledDistros.some((item) => item.name === selected && item.version !== 1)) return selected
|
||||
const defaultDistro = visibleInstalledDistros.find((item) => item.isDefault)
|
||||
if (
|
||||
defaultDistro &&
|
||||
defaultDistro.version !== 1 &&
|
||||
addableInstalledDistros.some((item) => item.name === defaultDistro.name)
|
||||
) {
|
||||
return defaultDistro.name
|
||||
}
|
||||
return addableInstalledDistros.find((item) => item.version !== 1)?.name ?? null
|
||||
}
|
||||
|
||||
function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState {
|
||||
if (!state) return "loading"
|
||||
if (state.pendingRestart) return "pendingRestart"
|
||||
if (!state.runtime) return "checking"
|
||||
if (!state.runtime.available) return "unavailable"
|
||||
return "ready"
|
||||
}
|
||||
|
||||
function addServerDistroStatus(input: {
|
||||
state: WslServersState | undefined
|
||||
name: string
|
||||
probingAddable: boolean
|
||||
}): DistroStatus | undefined {
|
||||
const installed = input.state?.installed.find((item) => item.name === input.name)
|
||||
if (installed?.version === 1) return { label: { key: "wsl.onboarding.distroStatus.unsupported" }, tone: "muted" }
|
||||
const job = input.state?.job
|
||||
const probe = input.state?.distroProbes[input.name]
|
||||
if (!probe) {
|
||||
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||
return checkingStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!probe.canExecute) {
|
||||
if (!installed) {
|
||||
return { label: { key: "wsl.onboarding.distroNotInstalled", params: { distro: input.name } }, tone: "warning" }
|
||||
}
|
||||
return { label: { key: "wsl.onboarding.openDistroOnce", params: { distro: input.name } }, tone: "warning" }
|
||||
}
|
||||
if (!probe.hasBash || !probe.hasCurl) {
|
||||
return { label: { key: "wsl.onboarding.distroStatus.missingTools" }, tone: "warning" }
|
||||
}
|
||||
const check = input.state?.opencodeChecks[input.name]
|
||||
if (!check) {
|
||||
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||
return checkingStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (check.matchesDesktop === false) return { label: { key: "wsl.onboarding.updateOpencode" }, tone: "warning" }
|
||||
if (!check.resolvedPath) return { label: { key: "wsl.onboarding.distroStatus.opencodeMissing" }, tone: "warning" }
|
||||
if (check.error) return { label: { key: "wsl.onboarding.installOpencode" }, tone: "warning" }
|
||||
return { label: { key: "wsl.onboarding.distroStatus.ready" }, tone: "success" }
|
||||
}
|
||||
|
||||
function checkingStatus(): DistroStatus {
|
||||
return { label: { key: "wsl.onboarding.distroStatus.checking" }, tone: "muted" }
|
||||
}
|
||||
|
||||
function addServerPrimaryButton(input: {
|
||||
state: WslServersState | undefined
|
||||
selectedDistro: string | null
|
||||
opencodeCheck: WslOpencodeCheck | null
|
||||
adding: boolean
|
||||
probingAddable: boolean
|
||||
}): AddServerPrimaryButton {
|
||||
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
|
||||
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
|
||||
const probingOpencode =
|
||||
probingSelected ||
|
||||
(ready &&
|
||||
(!input.opencodeCheck ||
|
||||
(!!input.selectedDistro &&
|
||||
input.state?.job?.kind === "probe-addable" &&
|
||||
input.state.job.distros.includes(input.selectedDistro))))
|
||||
const installingOpencode =
|
||||
input.state?.job?.kind === "install-opencode" && input.state.job.distro === input.selectedDistro
|
||||
if (!ready || probingOpencode) {
|
||||
return {
|
||||
variant: "contrast",
|
||||
label: probingSelected ? { key: "wsl.onboarding.distroStatus.checking" } : { key: "wsl.server.add" },
|
||||
disabled: true,
|
||||
action: null,
|
||||
loading: probingSelected,
|
||||
width: null,
|
||||
}
|
||||
}
|
||||
if (!addServerOpencodeReady(input.opencodeCheck)) {
|
||||
const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false
|
||||
return {
|
||||
variant: "neutral",
|
||||
label: installingOpencode
|
||||
? { key: "wsl.onboarding.updatingOpencode" }
|
||||
: update
|
||||
? { key: "wsl.onboarding.updateOpencode" }
|
||||
: { key: "wsl.onboarding.installOpencode" },
|
||||
disabled: !!input.state?.job || input.adding,
|
||||
action: "install-opencode",
|
||||
loading: installingOpencode,
|
||||
width: update ? "138px" : "129px",
|
||||
}
|
||||
}
|
||||
return {
|
||||
variant: "contrast",
|
||||
label: input.adding ? { key: "wsl.onboarding.adding" } : { key: "wsl.server.add" },
|
||||
disabled: input.adding || !!input.state?.job,
|
||||
action: "add",
|
||||
loading: input.adding,
|
||||
width: null,
|
||||
}
|
||||
}
|
||||
|
||||
function addServerOpencodeReady(check: WslOpencodeCheck | null) {
|
||||
return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error
|
||||
}
|
||||
|
||||
function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) {
|
||||
if (!selectedDistro) return false
|
||||
const installed = state?.installed.find((item) => item.name === selectedDistro)
|
||||
if (installed?.version === 1) return false
|
||||
if (!state?.distroProbes[selectedDistro]) return false
|
||||
if (!wslDistroReady(state, selectedDistro)) return true
|
||||
return !!state.opencodeChecks[selectedDistro]
|
||||
}
|
||||
|
||||
function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) {
|
||||
const installed = new Set(installedDistros.map((item) => item.name))
|
||||
const hasVersionedUbuntu = onlineDistros.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return onlineDistros
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => item.name !== "Ubuntu" || !hasVersionedUbuntu)
|
||||
}
|
||||
|
||||
function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) {
|
||||
const query = search.trim()
|
||||
if (!query) return installableDistros
|
||||
return fuzzysort.go(query, installableDistros, { keys: ["label", "name"] }).map((item) => item.obj)
|
||||
}
|
||||
|
||||
function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) {
|
||||
if (target && distros.some((item) => item.name === target)) return target
|
||||
return distros[0]?.name ?? null
|
||||
}
|
||||
|
||||
export function addableProbePlan(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
adding: boolean
|
||||
selectedDistro: string | null
|
||||
addableInstalledDistros: WslInstalledDistro[]
|
||||
}) {
|
||||
const state = input.state
|
||||
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
|
||||
if (state.job) return
|
||||
const ordered = input.selectedDistro
|
||||
? [
|
||||
...input.addableInstalledDistros.filter((item) => item.name === input.selectedDistro),
|
||||
...input.addableInstalledDistros.filter((item) => item.name !== input.selectedDistro),
|
||||
]
|
||||
: input.addableInstalledDistros
|
||||
const pending = ordered.flatMap((item) => {
|
||||
if (item.version === 1) return []
|
||||
if (!state.distroProbes[item.name]) return [`distro:${item.name}`]
|
||||
if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`]
|
||||
return []
|
||||
})
|
||||
if (!pending.length) return
|
||||
return {
|
||||
key: pending.join("|"),
|
||||
distros: ordered.filter((item) => item.version !== 1).map((item) => item.name),
|
||||
} satisfies AddableProbePlan
|
||||
}
|
||||
|
||||
export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) {
|
||||
if (!input.state || input.busy || input.state.pendingRestart) return
|
||||
if (!input.state.runtime) return { key: "runtime", action: "probeRuntime" } satisfies AutoProbePlan
|
||||
if (!input.state.runtime.available) return
|
||||
if (input.state.installed.length || input.state.online.length) return
|
||||
return { key: "distros", action: "refreshDistros" } satisfies AutoProbePlan
|
||||
}
|
||||
|
||||
export function addServerProbePlan(input: {
|
||||
state: WslServersState | undefined
|
||||
view: WslAddServerView
|
||||
adding: boolean
|
||||
busy: boolean
|
||||
selectedDistro: string | null
|
||||
addableInstalledDistros: WslInstalledDistro[]
|
||||
}) {
|
||||
const auto = autoProbePlan({ state: input.state, busy: input.busy })
|
||||
if (auto) return { kind: "auto", key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan
|
||||
const addable = addableProbePlan(input)
|
||||
if (addable) return { kind: "addable", key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan
|
||||
}
|
||||
|
||||
export function createProbeFailureGate() {
|
||||
let failed: string | undefined
|
||||
return {
|
||||
accepts(key: string) {
|
||||
return key !== failed
|
||||
},
|
||||
settle(key: string, error?: unknown) {
|
||||
if (error) failed = key
|
||||
},
|
||||
reset() {
|
||||
failed = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAddableProbePlan(input: {
|
||||
plan: AddableProbePlan
|
||||
api: Pick<WslServersPlatform, "probeAddable">
|
||||
}) {
|
||||
await input.api.probeAddable(input.plan.distros)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
@@ -30,17 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody>
|
||||
<DialogAddWslServer />
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
))
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
|
||||
@@ -53,8 +53,7 @@ export type WslJob =
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "probe-addable"; distros: string[]; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
@@ -77,8 +76,7 @@ export type WslServersPlatform = {
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(name: string): Promise<void>
|
||||
probeDistro(name: string): Promise<void>
|
||||
probeOpencode(name: string): Promise<void>
|
||||
probeAddable(distros: string[]): Promise<void>
|
||||
installOpencode(name: string): Promise<void>
|
||||
openTerminal(name: string): Promise<void>
|
||||
addServer(distro: string): Promise<WslServerConfig>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { app } from "electron"
|
||||
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
@@ -28,16 +28,19 @@ import {
|
||||
} from "./server"
|
||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import {
|
||||
createMainWindow,
|
||||
getLastFocusedWindow,
|
||||
registerRendererProtocol,
|
||||
setRelaunchHandler,
|
||||
setAppQuitting,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
restoreMainWindows,
|
||||
} from "./windows"
|
||||
import { createWslServersController } from "./wsl/servers"
|
||||
import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||
import { migrate } from "./migrate"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
@@ -53,7 +56,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: SidecarListener | null = null
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
@@ -70,7 +72,8 @@ function useEnvProxy() {
|
||||
function emitDeepLinks(urls: string[]) {
|
||||
if (urls.length === 0) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendDeepLinks(win, urls)
|
||||
}
|
||||
|
||||
async function killSidecar() {
|
||||
@@ -194,9 +197,10 @@ const main = Effect.gen(function* () {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) {
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -207,10 +211,12 @@ const main = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
@@ -237,6 +243,19 @@ const main = Effect.gen(function* () {
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
if (result.deleted.length === 0) return
|
||||
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logger.warn("failed to clean scoped store files", error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
@@ -347,11 +366,11 @@ const main = Effect.gen(function* () {
|
||||
|
||||
yield* Fiber.await(loadingTask)
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
if (mainWindow) {
|
||||
const windows = restoreMainWindows()
|
||||
if (windows.length) {
|
||||
createMenu({
|
||||
trigger: (id) => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { getStore } from "./store"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
@@ -91,9 +91,11 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
getStore(name).delete(key)
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
getStore(name).clear()
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
@@ -190,6 +192,14 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
|
||||
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
||||
|
||||
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
if (!id) throw new Error("Window ID not found")
|
||||
return id
|
||||
})
|
||||
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function tempRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeStore(root: string, name: string, value: string, modified: Date) {
|
||||
await writeFile(join(root, name), value)
|
||||
await utimes(join(root, name), modified, modified)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe("store cleanup", () => {
|
||||
test("removes empty scoped stores and leaves global stores alone", async () => {
|
||||
const root = await tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
await writeStore(root, "opencode.draft.empty.dat", "{}", now)
|
||||
await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
|
||||
await writeStore(root, "opencode.global.dat", "{}", now)
|
||||
await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
|
||||
|
||||
const result = await cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
|
||||
expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"])
|
||||
})
|
||||
|
||||
test("removes stale drafts by age without removing non-empty workspace stores", async () => {
|
||||
const root = await tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
await writeStore(root, "opencode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z"))
|
||||
await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
|
||||
await writeStore(
|
||||
root,
|
||||
"opencode.workspace.old.dat",
|
||||
'{"workspace:layout":"wide"}',
|
||||
new Date("2025-01-01T00:00:00.000Z"),
|
||||
)
|
||||
await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
|
||||
|
||||
const result = await cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
"opencode.draft.recent.dat",
|
||||
"opencode.workspace.old.dat",
|
||||
"opencode.workspace.recent.dat",
|
||||
])
|
||||
})
|
||||
|
||||
test("caps scoped stores by recency", async () => {
|
||||
const root = await tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
await Promise.all(
|
||||
Array.from({ length: 102 }, (_, index) =>
|
||||
writeStore(
|
||||
root,
|
||||
`opencode.draft.${index}.dat`,
|
||||
'{"draft:prompt":"hello"}',
|
||||
new Date(now.getTime() - index * 1000),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
const remaining = await readdir(root)
|
||||
|
||||
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
|
||||
expect(remaining).toHaveLength(100)
|
||||
})
|
||||
|
||||
test("removes a scoped store immediately when it becomes empty", async () => {
|
||||
const root = await tempRoot()
|
||||
await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
|
||||
await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
|
||||
|
||||
expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
|
||||
expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
|
||||
expect(await readdir(root)).toEqual(["opencode.global.dat"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readdir, readFile, rm, stat } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
|
||||
const EMPTY_STORE_MAX_BYTES = 128
|
||||
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const DRAFT_KEEP_RECENT = 100
|
||||
|
||||
type StoreKind = "draft" | "workspace"
|
||||
type StoreCandidate = {
|
||||
name: string
|
||||
path: string
|
||||
kind: StoreKind
|
||||
modified: number
|
||||
empty: boolean
|
||||
}
|
||||
|
||||
export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
|
||||
const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
|
||||
const candidates = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map(async (entry) => {
|
||||
const kind = storeKind(entry.name)
|
||||
if (!kind) return
|
||||
|
||||
const file = join(userDataPath, entry.name)
|
||||
const stats = await stat(file).catch(() => undefined)
|
||||
if (!stats?.isFile()) return
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: file,
|
||||
kind,
|
||||
modified: stats.mtimeMs,
|
||||
empty: await isEmptyStore(file, stats.size),
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((candidate) => !!candidate)
|
||||
|
||||
const stale = new Set<StoreCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.empty) stale.add(candidate)
|
||||
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
|
||||
}
|
||||
|
||||
candidates
|
||||
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
|
||||
.sort((a, b) => b.modified - a.modified)
|
||||
.slice(DRAFT_KEEP_RECENT)
|
||||
.forEach((candidate) => stale.add(candidate))
|
||||
|
||||
const deleted = await Promise.all(
|
||||
[...stale].map(async (candidate) => {
|
||||
await rm(candidate.path, { force: true })
|
||||
return candidate.name
|
||||
}),
|
||||
)
|
||||
|
||||
return { scanned: candidates.length, deleted }
|
||||
}
|
||||
|
||||
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
|
||||
if (!storeKind(name)) return false
|
||||
|
||||
const file = join(userDataPath, name)
|
||||
const stats = await stat(file).catch(() => undefined)
|
||||
if (!stats?.isFile()) return false
|
||||
if (!(await isEmptyStore(file, stats.size))) return false
|
||||
|
||||
await rm(file, { force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
function storeKind(name: string): StoreKind | undefined {
|
||||
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
|
||||
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
|
||||
}
|
||||
|
||||
async function isEmptyStore(file: string, size: number) {
|
||||
if (size > EMPTY_STORE_MAX_BYTES) return false
|
||||
|
||||
const raw = await readFile(file, "utf8").catch(() => undefined)
|
||||
if (raw === undefined) return false
|
||||
if (raw.trim() === "") return true
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
|
||||
@@ -2,6 +2,7 @@ import Store from "electron-store"
|
||||
import electron from "electron"
|
||||
|
||||
import { SETTINGS_STORE } from "./store-keys"
|
||||
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
@@ -21,3 +22,7 @@ export function getStore(name = SETTINGS_STORE) {
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export async function removeStoreFileIfEmpty(name: string) {
|
||||
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@ import windowState from "electron-window-state"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { rmSync } from "node:fs"
|
||||
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
|
||||
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -42,8 +44,12 @@ let relaunchHandler = () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}
|
||||
let appQuitting = false
|
||||
let lastFocusedWindowID: string | undefined
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
const windowIDs = new WeakMap<BrowserWindow, string>()
|
||||
const windowsByID = new Map<string, BrowserWindow>()
|
||||
const titlebarHeight = 40
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
@@ -52,6 +58,10 @@ export function setRelaunchHandler(handler: () => void) {
|
||||
relaunchHandler = handler
|
||||
}
|
||||
|
||||
export function setAppQuitting() {
|
||||
appQuitting = true
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
|
||||
@@ -111,14 +121,33 @@ export function getPinchZoomEnabled() {
|
||||
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
|
||||
}
|
||||
|
||||
export function getWindowID(win: BrowserWindow) {
|
||||
return windowIDs.get(win)
|
||||
}
|
||||
|
||||
export function getLastFocusedWindow() {
|
||||
const focused = BrowserWindow.getFocusedWindow()
|
||||
if (focused) return focused
|
||||
if (!lastFocusedWindowID) return null
|
||||
const win = windowsByID.get(lastFocusedWindowID)
|
||||
if (!win || win.isDestroyed()) return null
|
||||
return win
|
||||
}
|
||||
|
||||
export function restoreMainWindows() {
|
||||
const ids = readWindowIDs()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
|
||||
}
|
||||
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
||||
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
||||
}
|
||||
|
||||
export function createMainWindow() {
|
||||
export function createMainWindow(id: string = randomUUID()) {
|
||||
const state = windowState({
|
||||
file: windowStateFile(id),
|
||||
defaultWidth: 1280,
|
||||
defaultHeight: 800,
|
||||
})
|
||||
@@ -156,7 +185,7 @@ export function createMainWindow() {
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, "main")
|
||||
wireWindowRecovery(win, id)
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders } = details
|
||||
@@ -171,6 +200,7 @@ export function createMainWindow() {
|
||||
})
|
||||
|
||||
state.manage(win)
|
||||
registerWindow(win, id)
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
|
||||
@@ -181,6 +211,46 @@ export function createMainWindow() {
|
||||
return win
|
||||
}
|
||||
|
||||
function registerWindow(win: BrowserWindow, id: string) {
|
||||
windowIDs.set(win, id)
|
||||
windowsByID.set(id, win)
|
||||
persistWindowID(id)
|
||||
|
||||
win.on("focus", () => {
|
||||
lastFocusedWindowID = id
|
||||
})
|
||||
win.on("closed", () => {
|
||||
windowsByID.delete(id)
|
||||
if (lastFocusedWindowID === id) lastFocusedWindowID = windowsByID.keys().next().value
|
||||
if (!appQuitting) removeWindowID(id)
|
||||
})
|
||||
}
|
||||
|
||||
function readWindowIDs() {
|
||||
const value = getStore().get(WINDOW_IDS_KEY)
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
}
|
||||
|
||||
function writeWindowIDs(ids: string[]) {
|
||||
getStore().set(WINDOW_IDS_KEY, [...new Set(ids)])
|
||||
}
|
||||
|
||||
function persistWindowID(id: string) {
|
||||
const ids = readWindowIDs()
|
||||
if (ids.includes(id)) return
|
||||
writeWindowIDs([...ids, id])
|
||||
}
|
||||
|
||||
function removeWindowID(id: string) {
|
||||
writeWindowIDs(readWindowIDs().filter((item) => item !== id))
|
||||
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
||||
}
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function registerRendererProtocol() {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { requireWslIpcString } from "./policy"
|
||||
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
|
||||
export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
@@ -46,11 +46,8 @@ export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installDistro(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.probeDistro(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.probeOpencode(requireWslIpcString("distro", name)),
|
||||
ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) =>
|
||||
controller.probeAddable(requireWslIpcStrings("distro", distros)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installOpencode(requireWslIpcString("distro", name)),
|
||||
@@ -97,8 +94,7 @@ function registerUnavailableWslIpcHandlers() {
|
||||
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-wsl", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-addable", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-open-terminal", unavailable)
|
||||
ipcMain.handle("wsl-servers-add", unavailable)
|
||||
|
||||
@@ -24,3 +24,10 @@ export function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
export function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length > 0) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { clearWslDistroState, requireWslIpcString, wslServerIdToRestart, wslTerminalArgs } from "./policy"
|
||||
import {
|
||||
clearWslDistroState,
|
||||
requireWslIpcString,
|
||||
requireWslIpcStrings,
|
||||
wslServerIdToRestart,
|
||||
wslTerminalArgs,
|
||||
} from "./policy"
|
||||
import {
|
||||
expectOpencodeVersion,
|
||||
pendingRestartAfterWslInstall,
|
||||
@@ -87,8 +93,10 @@ test("stops health polling when sidecar startup settles", async () => {
|
||||
|
||||
test("validates WSL IPC identifiers at the module boundary", () => {
|
||||
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
|
||||
expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"])
|
||||
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
|
||||
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
|
||||
expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro")
|
||||
})
|
||||
|
||||
test("derives a required Windows restart from the post-install runtime probe", () => {
|
||||
@@ -142,6 +150,62 @@ test("ignores stale startup OpenCode checks after removing a WSL server", async
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
test("probes addable distros in parallel before checking OpenCode", async () => {
|
||||
persistedServers = []
|
||||
const started: string[] = []
|
||||
const release = new Map<string, () => void>()
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => {
|
||||
started.push(distro)
|
||||
await new Promise<void>((resolve) => release.set(distro, resolve))
|
||||
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
|
||||
},
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
|
||||
const task = controller.probeAddable(["Debian", "Ubuntu"])
|
||||
await waitFor(() => started.length === 2)
|
||||
expect(started).toEqual(["Debian", "Ubuntu"])
|
||||
expect(opencode).toEqual([])
|
||||
release.get("Debian")?.()
|
||||
release.get("Ubuntu")?.()
|
||||
await task
|
||||
|
||||
expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"])
|
||||
expect(opencode).toEqual(["Debian", "Ubuntu"])
|
||||
expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian", "Ubuntu"])
|
||||
})
|
||||
|
||||
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
|
||||
persistedServers = []
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => ({
|
||||
name: distro,
|
||||
canExecute: distro === "Debian",
|
||||
hasBash: distro === "Debian",
|
||||
hasCurl: distro === "Debian",
|
||||
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
|
||||
}),
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
|
||||
await controller.probeAddable(["Debian", "Ubuntu"])
|
||||
|
||||
expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"])
|
||||
expect(opencode).toEqual(["Debian"])
|
||||
expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian"])
|
||||
})
|
||||
|
||||
async function waitFor(check: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (check()) return
|
||||
|
||||
@@ -47,6 +47,7 @@ type WslServersControllerOptions = {
|
||||
logger?: ControllerLogger
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
probeDistro?: typeof probeWslDistro
|
||||
resolveOpencode?: typeof resolveWslOpencode
|
||||
readCommandVersion?: typeof readWslCommandVersion
|
||||
}
|
||||
@@ -70,6 +71,7 @@ export function createWslServersController(
|
||||
const logger = options?.logger
|
||||
const readServers = options?.readServers ?? readPersistedServers
|
||||
const writeServers = options?.writeServers ?? writePersistedServers
|
||||
const probeDistro = options?.probeDistro ?? probeWslDistro
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
@@ -140,6 +142,28 @@ export function createWslServersController(
|
||||
setOpencodeCheck(distro, await checkOpencode(distro, opts))
|
||||
}
|
||||
|
||||
const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
|
||||
const unique = [...new Set(distros)]
|
||||
const distroProbes = await Promise.all(
|
||||
unique
|
||||
.filter((distro) => !state.distroProbes[distro])
|
||||
.map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
|
||||
)
|
||||
if (distroProbes.length) {
|
||||
setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
|
||||
}
|
||||
|
||||
const opencodeChecks = await Promise.all(
|
||||
unique
|
||||
.filter((distro) => distroProbeReady(state.distroProbes[distro]))
|
||||
.filter((distro) => !state.opencodeChecks[distro])
|
||||
.map(async (distro) => [distro, await checkOpencode(distro, opts)] as const),
|
||||
)
|
||||
if (opencodeChecks.length) {
|
||||
setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } })
|
||||
}
|
||||
}
|
||||
|
||||
const hasServer = (id: string, distro: string) => {
|
||||
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
|
||||
}
|
||||
@@ -319,7 +343,7 @@ export function createWslServersController(
|
||||
throw new Error(message)
|
||||
}
|
||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
const probe = await probeDistro(name, { signal: abort.signal })
|
||||
setState({
|
||||
...distros,
|
||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
||||
@@ -327,16 +351,10 @@ export function createWslServersController(
|
||||
})
|
||||
},
|
||||
|
||||
async probeDistro(name: string) {
|
||||
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
|
||||
})
|
||||
},
|
||||
|
||||
async probeOpencode(name: string) {
|
||||
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
async probeAddable(distros: string[]) {
|
||||
if (!distros.length) return
|
||||
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
|
||||
await probeAddableDistros(distros, { signal: abort.signal })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -480,6 +498,10 @@ function opencodeCheck(
|
||||
}
|
||||
}
|
||||
|
||||
function distroProbeReady(probe: WslDistroProbe | undefined) {
|
||||
return !!probe?.canExecute && probe.hasBash && probe.hasCurl
|
||||
}
|
||||
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
||||
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ const api: ElectronAPI = {
|
||||
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
|
||||
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
|
||||
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
|
||||
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
|
||||
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
|
||||
probeAddable: (distros) => ipcRenderer.invoke("wsl-servers-probe-addable", distros),
|
||||
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
|
||||
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
|
||||
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
|
||||
@@ -73,6 +72,7 @@ const api: ElectronAPI = {
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
|
||||
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
|
||||
getWindowID: () => ipcRenderer.invoke("get-window-id"),
|
||||
onMenuCommand: (cb) => {
|
||||
const handler = (_: unknown, id: string) => cb(id)
|
||||
ipcRenderer.on("menu-command", handler)
|
||||
|
||||
@@ -62,6 +62,7 @@ export type ElectronAPI = {
|
||||
storeLength: (name: string) => Promise<number>
|
||||
|
||||
getWindowCount: () => Promise<number>
|
||||
getWindowID: () => Promise<string>
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@ const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "di
|
||||
void window.api.updater.subscribe(setUpdaterState)
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
const lastActiveUrlKey = "opencode.desktop.last-active-url"
|
||||
|
||||
type DesktopWindowState = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
const emitDeepLinks = (urls: string[]) => {
|
||||
if (urls.length === 0) return
|
||||
@@ -78,31 +81,35 @@ const listenForDeepLinks = () => {
|
||||
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
|
||||
}
|
||||
|
||||
function getLastActiveUrl() {
|
||||
function windowLastActiveUrlKey(windowID: string) {
|
||||
return `opencode.desktop.window.${windowID}.last-active-url`
|
||||
}
|
||||
|
||||
function getLastActiveUrl(windowID: string) {
|
||||
if (typeof localStorage !== "object") return "/"
|
||||
try {
|
||||
const value = localStorage.getItem(lastActiveUrlKey)
|
||||
const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
|
||||
if (value?.startsWith("/") && !value.startsWith("//")) return value
|
||||
} catch {}
|
||||
return "/"
|
||||
}
|
||||
|
||||
function setLastActiveUrl(value: string) {
|
||||
function setLastActiveUrl(windowID: string, value: string) {
|
||||
if (typeof localStorage !== "object") return
|
||||
try {
|
||||
localStorage.setItem(lastActiveUrlKey, value)
|
||||
localStorage.setItem(windowLastActiveUrlKey(windowID), value)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function DesktopMemoryRouter(props: BaseRouterProps) {
|
||||
function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
|
||||
const history = createMemoryHistory()
|
||||
const initialUrl = getLastActiveUrl()
|
||||
const initialUrl = getLastActiveUrl(props.windowID)
|
||||
if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
|
||||
onCleanup(history.listen(setLastActiveUrl))
|
||||
onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
|
||||
return <MemoryRouter {...props} history={history} />
|
||||
}
|
||||
|
||||
const createPlatform = (): Platform => {
|
||||
const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
const attachmentPaths = new WeakMap<File, string>()
|
||||
const os = (() => {
|
||||
const ua = navigator.userAgent
|
||||
@@ -161,6 +168,7 @@ const createPlatform = (): Platform => {
|
||||
platform: "desktop",
|
||||
os,
|
||||
version: pkg.version,
|
||||
windowID: windowState.id,
|
||||
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
return window.api.openDirectoryPicker({
|
||||
@@ -307,8 +315,16 @@ window.api.onMenuCommand((id) => {
|
||||
})
|
||||
listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
function LoadingSplash() {
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
||||
const platform = createPlatform(props.windowState)
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
@@ -328,6 +344,9 @@ render(() => {
|
||||
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(loadLocale)
|
||||
const router = (props: BaseRouterProps) => (
|
||||
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
|
||||
)
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
@@ -357,12 +376,6 @@ render(() => {
|
||||
|
||||
function App() {
|
||||
const wslServers = useWslServers()
|
||||
const splash = (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ready = createMemo(
|
||||
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
|
||||
)
|
||||
@@ -389,10 +402,10 @@ render(() => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={ready()} fallback={splash}>
|
||||
<Show when={ready()} fallback={<LoadingSplash />}>
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={DesktopMemoryRouter}>
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)}
|
||||
@@ -415,4 +428,19 @@ render(() => {
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => {
|
||||
const [windowState] = createResource(async () => {
|
||||
const api = window.api as typeof window.api & {
|
||||
getWindowID?: () => Promise<string>
|
||||
}
|
||||
return { id: await api.getWindowID?.() }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
|
||||
{(state) => <DesktopRoot windowState={state} />}
|
||||
</Show>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
font-size: 17px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 600;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
font-size: 15px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 600;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 10px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
font-size: 13px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
font-size: 13px;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
border-left: 0.5px solid var(--v2-border-border-base);
|
||||
margin: 1.5rem 0;
|
||||
margin: 1rem 0;
|
||||
padding-left: 0.5rem;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-style: normal;
|
||||
@@ -155,7 +155,7 @@
|
||||
hr {
|
||||
border: none;
|
||||
height: 0;
|
||||
margin: 40px 0;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.shiki {
|
||||
@@ -192,71 +192,28 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-float-base);
|
||||
color: var(--text-invert-strong);
|
||||
padding: 2px 8px;
|
||||
border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"]:hover::after,
|
||||
[data-slot="markdown-copy-button"]:focus-visible::after {
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"],
|
||||
[data-slot="markdown-copy-button"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||
box-shadow: none;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"] [data-slot="check-icon"] {
|
||||
[data-slot="markdown-copy-button"] [data-check-icon] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-copy-icon] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-check-icon] {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 24px;
|
||||
overflow: auto;
|
||||
|
||||
scrollbar-width: none;
|
||||
@@ -296,7 +253,7 @@
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
margin: 16px 0;
|
||||
font-size: var(--font-size-base);
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
@@ -389,18 +346,6 @@ body:not([data-new-layout]) [data-component="markdown"] {
|
||||
background: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"]::after {
|
||||
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--border-weaker-base);
|
||||
|
||||
@@ -3,16 +3,21 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
ComponentProps,
|
||||
type Accessor,
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onCleanup,
|
||||
type Setter,
|
||||
splitProps,
|
||||
} from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { isServer, render } from "solid-js/web"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { bundledLanguages } from "shiki"
|
||||
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
||||
import {
|
||||
@@ -48,11 +53,6 @@ type RenderResult = {
|
||||
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
|
||||
const iconPaths = {
|
||||
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
||||
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
@@ -87,6 +87,14 @@ type CopyLabels = {
|
||||
copied: string
|
||||
}
|
||||
|
||||
type CopyButtonState = {
|
||||
setLabels: Setter<CopyLabels>
|
||||
setCopied: Setter<boolean>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const copyButtonState = new WeakMap<HTMLElement, CopyButtonState>()
|
||||
|
||||
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
||||
|
||||
function codeUrl(text: string) {
|
||||
@@ -100,45 +108,67 @@ function codeUrl(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function createIcon(path: string, slot: string) {
|
||||
const icon = document.createElement("div")
|
||||
icon.setAttribute("data-component", "icon")
|
||||
icon.setAttribute("data-size", "small")
|
||||
icon.setAttribute("data-slot", slot)
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
||||
svg.setAttribute("data-slot", "icon-svg")
|
||||
svg.setAttribute("fill", "none")
|
||||
svg.setAttribute("viewBox", "0 0 20 20")
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.innerHTML = path
|
||||
icon.appendChild(svg)
|
||||
return icon
|
||||
}
|
||||
|
||||
function createCopyButton(labels: CopyLabels) {
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.setAttribute("data-component", "icon-button")
|
||||
button.setAttribute("data-variant", "secondary")
|
||||
button.setAttribute("data-size", "small")
|
||||
button.setAttribute("data-slot", "markdown-copy-button")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
||||
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
||||
return button
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-slot", "markdown-copy-button")
|
||||
|
||||
const state: Partial<CopyButtonState> = {}
|
||||
const dispose = render(() => {
|
||||
const [labelState, setLabels] = createSignal(labels, { equals: false })
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
state.setLabels = setLabels
|
||||
state.setCopied = setCopied
|
||||
return <MarkdownCopyButton labels={labelState} copied={copied} />
|
||||
}, host)
|
||||
state.dispose = dispose
|
||||
copyButtonState.set(host, state as CopyButtonState)
|
||||
return host
|
||||
}
|
||||
|
||||
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
||||
function MarkdownCopyButton(props: { labels: Accessor<CopyLabels>; copied: Accessor<boolean> }) {
|
||||
const label = () => (props.copied() ? props.labels().copied : props.labels().copy)
|
||||
return (
|
||||
<TooltipV2 placement="top" value={label()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
aria-label={label()}
|
||||
icon={
|
||||
<>
|
||||
<IconV2 name="outline-copy" data-copy-icon />
|
||||
<IconV2 name="check" data-check-icon />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function setCopyState(host: HTMLElement, labels: CopyLabels, copied: boolean) {
|
||||
const state = copyButtonState.get(host)
|
||||
state?.setLabels(labels)
|
||||
state?.setCopied(copied)
|
||||
if (copied) {
|
||||
button.setAttribute("data-copied", "true")
|
||||
button.setAttribute("aria-label", labels.copied)
|
||||
button.setAttribute("data-tooltip", labels.copied)
|
||||
host.setAttribute("data-copied", "true")
|
||||
return
|
||||
}
|
||||
button.removeAttribute("data-copied")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
host.removeAttribute("data-copied")
|
||||
}
|
||||
|
||||
function disposeCopyButton(host: HTMLElement) {
|
||||
copyButtonState.get(host)?.dispose()
|
||||
copyButtonState.delete(host)
|
||||
}
|
||||
|
||||
function disposeCopyButtons(root: Element) {
|
||||
const hosts = [
|
||||
...(root instanceof HTMLElement && root.getAttribute("data-slot") === "markdown-copy-button" ? [root] : []),
|
||||
...Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLElement => el instanceof HTMLElement,
|
||||
),
|
||||
]
|
||||
hosts.forEach(disposeCopyButton)
|
||||
}
|
||||
|
||||
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
||||
@@ -196,6 +226,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
}
|
||||
|
||||
for (const button of buttons.slice(1)) {
|
||||
disposeCopyButton(button)
|
||||
button.remove()
|
||||
}
|
||||
}
|
||||
@@ -250,9 +281,9 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
||||
}
|
||||
|
||||
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
||||
const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const updateLabel = (button: HTMLButtonElement) => {
|
||||
const updateLabel = (button: HTMLElement) => {
|
||||
const labels = getLabels()
|
||||
const copied = button.getAttribute("data-copied") === "true"
|
||||
setCopyState(button, labels, copied)
|
||||
@@ -263,7 +294,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
if (!(target instanceof Element)) return
|
||||
|
||||
const button = target.closest('[data-slot="markdown-copy-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
if (!(button instanceof HTMLElement)) return
|
||||
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
||||
const content = code?.textContent ?? ""
|
||||
if (!content) return
|
||||
@@ -280,7 +311,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
|
||||
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
||||
for (const button of buttons) {
|
||||
if (button instanceof HTMLButtonElement) updateLabel(button)
|
||||
if (button instanceof HTMLElement) updateLabel(button)
|
||||
}
|
||||
|
||||
root.addEventListener("click", handleClick)
|
||||
@@ -290,6 +321,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
for (const timeout of timeouts.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
disposeCopyButtons(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +463,7 @@ export function Markdown(
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
if (content.length === 0) {
|
||||
disposeCopyButtons(container)
|
||||
container.innerHTML = ""
|
||||
return
|
||||
}
|
||||
@@ -446,9 +479,14 @@ export function Markdown(
|
||||
activeCodeKeys.clear()
|
||||
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
||||
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
||||
while (container.children.length > content.length) container.lastElementChild?.remove()
|
||||
while (container.children.length > content.length) {
|
||||
const child = container.lastElementChild
|
||||
if (!child) break
|
||||
disposeCopyButtons(child)
|
||||
child.remove()
|
||||
}
|
||||
container
|
||||
.querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]')
|
||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
@@ -538,8 +576,8 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
||||
morphdom(current, next, {
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLButtonElement &&
|
||||
toEl instanceof HTMLButtonElement &&
|
||||
fromEl instanceof HTMLElement &&
|
||||
toEl instanceof HTMLElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
||||
) {
|
||||
@@ -548,6 +586,10 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
onBeforeNodeDiscarded: (node) => {
|
||||
if (node instanceof Element) disposeCopyButtons(node)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -616,8 +658,12 @@ function updateCodeBlock(
|
||||
unstable: block.unstable,
|
||||
raw: block.raw,
|
||||
})
|
||||
if (current) current.replaceWith(next)
|
||||
else container.appendChild(next)
|
||||
if (current) {
|
||||
disposeCopyButtons(current)
|
||||
current.replaceWith(next)
|
||||
return
|
||||
}
|
||||
container.appendChild(next)
|
||||
}
|
||||
|
||||
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|
||||
|
||||
@@ -378,15 +378,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
||||
box-shadow: none;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-slot="bash-scroll"] {
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
@@ -860,6 +851,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="question-header-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="question-progress"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -918,6 +916,7 @@
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--v2-text-text-base);
|
||||
padding: 0 10px;
|
||||
flex-shrink: 0;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
@@ -929,6 +928,7 @@
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--v2-text-text-muted);
|
||||
padding: 0 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="question-options"] {
|
||||
@@ -936,8 +936,8 @@
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 1px 1px 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 1px 1px 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
@@ -1126,6 +1126,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="question-minimized-dock"] {
|
||||
margin-bottom: 8px;
|
||||
|
||||
[data-slot="question-minimized-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-base);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-component="icon"] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="question-minimized-summary"] {
|
||||
flex: 1;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="question-answers"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1319,10 +1355,6 @@ body:not([data-new-layout]) {
|
||||
|
||||
[data-component="bash-output"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
|
||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
|
||||
@@ -50,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/ut
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -196,6 +197,7 @@ function MessageActionButton(
|
||||
useV2?: boolean
|
||||
},
|
||||
) {
|
||||
const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon)
|
||||
return (
|
||||
<Show
|
||||
when={props.useV2}
|
||||
@@ -215,7 +217,7 @@ function MessageActionButton(
|
||||
>
|
||||
<TooltipV2 value={props.label} placement="top" gutter={4}>
|
||||
<IconButtonV2
|
||||
icon={<Icon name={props.icon} size="small" />}
|
||||
icon={<IconV2 name={icon()} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
disabled={props.disabled}
|
||||
@@ -1966,20 +1968,16 @@ ToolRegistry.register({
|
||||
>
|
||||
<div data-component="bash-output">
|
||||
<div data-slot="bash-copy">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<div data-slot="bash-scroll" data-scrollable>
|
||||
<pre data-slot="bash-pre">
|
||||
|
||||
@@ -1221,7 +1221,11 @@ function Playground() {
|
||||
message: { [session().id]: state.messages },
|
||||
part: state.parts,
|
||||
provider: {
|
||||
all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
|
||||
all: new Map([
|
||||
["anthropic", { id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
|
||||
]),
|
||||
connected: ["anthropic"],
|
||||
default: {},
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Rec
|
||||
const Trigger = local.triggerAs
|
||||
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
|
||||
}
|
||||
|
||||
export const ModelSelectorPopoverV2 = ModelSelectorPopover
|
||||
|
||||
@@ -44,6 +44,17 @@ const Scheme = (props: { value?: unknown }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const NewLayout = () => {
|
||||
// Mirror app.tsx BodyDesignClass so stories render with v2 (new-layout) styles
|
||||
// instead of the legacy `body:not([data-new-layout])` branch.
|
||||
onMount(() => {
|
||||
document.body.toggleAttribute("data-new-layout", true)
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
document.body.classList.remove("text-12-regular")
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const frame = createJSXDecorator((Story, context) => {
|
||||
const override = context.parameters?.themes?.themeOverride
|
||||
const selected = context.globals?.theme
|
||||
@@ -54,6 +65,7 @@ const frame = createJSXDecorator((Story, context) => {
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<Scheme value={scheme} />
|
||||
<NewLayout />
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<div
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
|
||||
[data-component="button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled):not([data-variant="loading"]) {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2.5px;
|
||||
}
|
||||
@@ -170,6 +170,15 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
[data-component="button-v2"][data-variant="loading"] {
|
||||
background: #f2f2f2;
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ghost */
|
||||
[data-component="button-v2"][data-variant="ghost"] {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ButtonV2 } from "./button-v2"
|
||||
|
||||
const docs = `### Overview
|
||||
Button v2 with three visual variants and two sizes.
|
||||
Button v2 with visual variants and three sizes.
|
||||
|
||||
### API
|
||||
- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted".
|
||||
- \`size\`: "normal" | "large".
|
||||
- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted" | "loading".
|
||||
- \`size\`: "small" | "normal" | "large".
|
||||
- \`icon\`: Optional icon name.
|
||||
- Inherits Kobalte Button props and native button attributes.
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["neutral", "danger", "contrast", "ghost", "ghost-muted"],
|
||||
options: ["neutral", "danger", "contrast", "ghost", "ghost-muted", "loading"],
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
@@ -67,6 +67,7 @@ export const Variants = {
|
||||
<ButtonV2 variant="ghost-muted" icon="edit">
|
||||
Ghost muted
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="loading">Loading</ButtonV2>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export const Icon = {
|
||||
|
||||
export const AllStates = {
|
||||
render: () => {
|
||||
const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted"] as const
|
||||
const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted", "loading"] as const
|
||||
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
|
||||
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ButtonV2Props
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted" | "outline"
|
||||
variant?: "neutral" | "danger" | "outline" | "contrast" | "ghost" | "ghost-muted" | "loading"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M7.5 13.3333H1.5V2H6.83333L8.83333 4H14.8333V6M10.1667 11.3333H15.5M12.8333 8.66667V14" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
},
|
||||
folder: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2.545 3.364V12.636H13.455V5H8.545L6.909 3.364H2.545Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
},
|
||||
branch: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M5.118 5.686V10.314M5.118 5.686C5.97 5.686 6.661 4.995 6.661 4.143C6.661 3.291 5.97 2.6 5.118 2.6C4.266 2.6 3.575 3.291 3.575 4.143C3.575 4.995 4.266 5.686 5.118 5.686ZM5.118 10.314C4.266 10.314 3.575 11.005 3.575 11.857C3.575 12.709 4.266 13.4 5.118 13.4C5.97 13.4 6.661 12.709 6.661 11.857M5.118 10.314C5.97 10.314 6.661 11.005 6.661 11.857M10.882 5.686C11.734 5.686 12.425 4.995 12.425 4.143C12.425 3.291 11.734 2.6 10.882 2.6C10.03 2.6 9.339 3.291 9.339 4.143C9.339 4.995 10.03 5.686 10.882 5.686ZM10.882 5.686V9.457C10.882 10.783 9.807 11.857 8.482 11.857H6.661" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
},
|
||||
"grid-plus": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
@@ -85,6 +93,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-sliders": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M11.7779 4.66675H14.4446M11.7779 4.66675C11.7779 5.77132 10.8825 6.66675 9.77789 6.66675C8.67332 6.66675 7.77789 5.77132 7.77789 4.66675M11.7779 4.66675C11.7779 3.56218 10.8825 2.66675 9.77789 2.66675C8.67332 2.66675 7.77789 3.56218 7.77789 4.66675M1.55566 4.66675H7.77789M4.22233 11.3334H1.55566M4.22233 11.3334C4.22233 12.438 5.11776 13.3334 6.22233 13.3334C7.3269 13.3334 8.22233 12.438 8.22233 11.3334M4.22233 11.3334C4.22233 10.2288 5.11776 9.33341 6.22233 9.33341C7.3269 9.33341 8.22233 10.2288 8.22233 11.3334M14.4446 11.3334H8.22233" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-copy": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
|
||||
@@ -93,6 +105,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
|
||||
},
|
||||
reset: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
archive: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[data-component="loader-v2"] {
|
||||
--_duration: 900ms;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted, #808080);
|
||||
animation: loader-v2-spin var(--_duration) linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
[data-component="loader-v2"] [data-slot="loader-v2-background"] {
|
||||
stroke: currentColor;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
[data-component="loader-v2"] [data-slot="loader-v2-progress"] {
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes loader-v2-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="loader-v2"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LoaderV2 } from "./loader-v2"
|
||||
|
||||
const docs = `### Overview
|
||||
Circular v2 loader for compact loading states.
|
||||
|
||||
### API
|
||||
- Accepts standard SVG props.
|
||||
|
||||
### Behavior
|
||||
- The foreground ring covers 33% of the circumference and rotates continuously.
|
||||
|
||||
### Accessibility
|
||||
- Sets \`aria-hidden="true"\` by default.
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI V2/Loader",
|
||||
id: "components-loader-v2",
|
||||
component: LoaderV2,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => <LoaderV2 />,
|
||||
}
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<LoaderV2 width={12} height={12} />
|
||||
<LoaderV2 />
|
||||
<LoaderV2 width={24} height={24} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
import "./loader-v2.css"
|
||||
|
||||
export function LoaderV2(props: ComponentProps<"svg">) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "width", "height"])
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
width={local.width ?? 16}
|
||||
height={local.height ?? 16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
data-component="loader-v2"
|
||||
aria-hidden={rest["aria-hidden"] ?? "true"}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" data-slot="loader-v2-background" stroke-width="2" />
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
data-slot="loader-v2-progress"
|
||||
pathLength="100"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="33 67"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
}
|
||||
|
||||
[data-slot="radio-v2-item"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
@@ -131,10 +132,6 @@
|
||||
linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-accent);
|
||||
}
|
||||
|
||||
&:where([data-checked][data-disabled]) [data-slot="radio-v2-item-control"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:where([data-invalid]):where(:not([data-checked])) [data-slot="radio-v2-item-control"] {
|
||||
background: var(--v2-state-bg-danger);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-state-border-danger);
|
||||
@@ -195,8 +192,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:where([data-disabled]) [data-slot="radio-v2-item-label"],
|
||||
&:where([data-disabled]) [data-slot="radio-v2-item-label-text"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
&:where([data-disabled]) [data-slot="radio-v2-item-description"] {
|
||||
opacity: 0.5;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user