mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e13f39e7c | |||
| 60cd82aec8 | |||
| c29968bf08 | |||
| b5b87deaa2 | |||
| 5053c1bfdd | |||
| d97aca139f |
@@ -1,4 +1,3 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -13,34 +12,20 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
|
||||
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
|
||||
type StringForm = Extract<IntegrationForm, { type: "string" }>
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
@@ -385,120 +370,29 @@ function ProviderConnection(props: {
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
onCleanup(() => {
|
||||
alive.value = false
|
||||
if (timer.current === undefined) return
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => props.provider,
|
||||
directory,
|
||||
onComplete: () => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
},
|
||||
])
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: props.provider, directory: directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID: input.provider,
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const loading = createMemo(() => integration.loading)
|
||||
const methods = createMemo<ConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
return values?.length ? values : fallback()
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "form",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.form" }
|
||||
| { type: "auth.answer"; answer: FormAnswer | undefined }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.form") {
|
||||
draft.state = "form"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.answer") {
|
||||
draft.formAnswer = action.answer
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
@@ -520,65 +414,6 @@ function ProviderConnection(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
if (typeof data?.message === "string" && data.message) return data.message
|
||||
}
|
||||
if (value && typeof value === "object" && "error" in value) {
|
||||
const nested = formatError((value as { error?: unknown }).error, "")
|
||||
if (nested) return nested
|
||||
}
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = (value as { message?: unknown }).message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (value instanceof Error && value.message) return value.message
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number, answer?: FormAnswer) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
}
|
||||
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.form?.length && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
if (method.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
return
|
||||
}
|
||||
if (method.type === "oauth") {
|
||||
if (method.form?.some((field) => field.type !== "string")) {
|
||||
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
await serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function AuthFormView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
@@ -586,7 +421,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
const fields = createMemo<StringForm[]>(() => {
|
||||
const value = method()
|
||||
const value = controller.currentMethod()
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
|
||||
})
|
||||
const matches = (field: StringForm, value: Record<string, string>) => {
|
||||
@@ -599,7 +434,7 @@ function ProviderConnection(props: {
|
||||
const current = createMemo(() => {
|
||||
const all = fields()
|
||||
const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
|
||||
if (index === -1) return
|
||||
if (index === -1) return undefined
|
||||
return {
|
||||
index,
|
||||
field: all[index],
|
||||
@@ -613,13 +448,14 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function next(index: number, value: Record<string, string>) {
|
||||
if (store.methodIndex === undefined) return
|
||||
const selected = controller.methodIndex()
|
||||
if (selected === undefined) return
|
||||
const next = fields().findIndex((field, i) => i > index && matches(field, value))
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
await controller.auth.select(selected, value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
@@ -633,12 +469,12 @@ function ProviderConnection(props: {
|
||||
const item = () => current()
|
||||
const text = createMemo(() => {
|
||||
const field = item()?.field
|
||||
if (!field || field.options) return
|
||||
if (!field || field.options) return undefined
|
||||
return field
|
||||
})
|
||||
const select = createMemo(() => {
|
||||
const field = item()?.field
|
||||
if (!field?.options) return
|
||||
if (!field?.options) return undefined
|
||||
return field
|
||||
})
|
||||
|
||||
@@ -709,32 +545,9 @@ function ProviderConnection(props: {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
void selectMethod(0)
|
||||
}
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSync()
|
||||
.refreshProviders()
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
return
|
||||
}
|
||||
props.onBack()
|
||||
@@ -750,14 +563,14 @@ function ProviderConnection(props: {
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<For each={methods()}>
|
||||
<For each={controller.methods()}>
|
||||
{(item, index) => {
|
||||
const details = () => methodDetails(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => void selectMethod(index())}
|
||||
onClick={() => void controller.auth.select(index())}
|
||||
>
|
||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||
@@ -785,11 +598,11 @@ function ProviderConnection(props: {
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
items={controller.methods}
|
||||
key={(m) => m?.label ?? m?.type}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
void selectMethod(index)
|
||||
void controller.auth.select(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -822,9 +635,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("apiKey")
|
||||
const apiKey = typeof value === "string" ? value : ""
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||
@@ -832,13 +645,7 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
})
|
||||
await complete()
|
||||
await controller.auth.connectKey(apiKey)
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -953,9 +760,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("code")
|
||||
const code = typeof value === "string" ? value : ""
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||
@@ -963,20 +770,7 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||
setFormStore("error", await controller.auth.completeCode(code))
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -984,14 +778,14 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
||||
<TextInputV2
|
||||
ref={codeInput}
|
||||
class="!w-full"
|
||||
@@ -1023,7 +817,7 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
@@ -1033,7 +827,9 @@ function ProviderConnection(props: {
|
||||
autofocus={!newLayout()}
|
||||
ref={codeInput}
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
label={language.t("provider.connect.oauth.code.label", {
|
||||
method: controller.currentMethod()?.label ?? "",
|
||||
})}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
name="code"
|
||||
value={formStore.value}
|
||||
@@ -1051,52 +847,18 @@ function ProviderConnection(props: {
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
const instructions = controller.authorization()?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!alive.value) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(poll, 1_000)
|
||||
}
|
||||
void poll()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
@@ -1131,7 +893,9 @@ function ProviderConnection(props: {
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
<Match
|
||||
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
|
||||
>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
@@ -1142,10 +906,10 @@ function ProviderConnection(props: {
|
||||
<div
|
||||
onKeyDown={handleKey}
|
||||
tabIndex={newLayout() ? undefined : 0}
|
||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||
autofocus={!newLayout() && controller.methodIndex() === undefined ? true : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<Match when={controller.loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
@@ -1153,10 +917,10 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<Match when={controller.auth.state() === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
@@ -1164,26 +928,26 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "form"}>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "key"}>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Match when={controller.currentMethod()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -6,21 +6,33 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show } from "solid-js"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { type ServerDomainController } from "@/components/server/server-management-controller"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
type ServerConnectionFormController = {
|
||||
state: {
|
||||
adding: () => boolean
|
||||
busy: () => boolean
|
||||
value: () => string
|
||||
name: () => string
|
||||
username: () => string
|
||||
password: () => string
|
||||
error: () => string
|
||||
status: () => boolean | undefined
|
||||
}
|
||||
change: {
|
||||
value: (value: string) => void
|
||||
name: (value: string) => void
|
||||
username: (value: string) => void
|
||||
password: (value: string) => void
|
||||
}
|
||||
reset: () => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -39,76 +51,6 @@ interface ServerFormProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -174,387 +116,11 @@ function ServerForm(props: ServerFormProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||
|
||||
return (
|
||||
<Dialog title={controller.formTitle()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
server.add(conn)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = server.key
|
||||
tabs.removeServer(originalKey)
|
||||
const newConn = server.add(next)
|
||||
if (!newConn) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(originalKey)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const settings = useSettings()
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||
)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
})
|
||||
|
||||
const editing = createMemo(() => {
|
||||
if (!store.editServer.id) return
|
||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
value: conn.http.url,
|
||||
name: conn.displayName ?? "",
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editServer.id) return
|
||||
if (editing()) return
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKey,
|
||||
canDefault,
|
||||
current,
|
||||
sortedItems,
|
||||
status: () => global.servers.health,
|
||||
isFormMode,
|
||||
isAddMode,
|
||||
formTitle,
|
||||
formBusy,
|
||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||
select,
|
||||
setDefault,
|
||||
startAdd,
|
||||
startEdit,
|
||||
resetForm,
|
||||
submitForm,
|
||||
canRemove: server.canRemove,
|
||||
handleRemove,
|
||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerDomainController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -568,10 +134,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.controller.sortedItems}
|
||||
items={props.domain.collection.items}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||
}}
|
||||
divider={true}
|
||||
>
|
||||
@@ -580,15 +146,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.controller.status()[key]?.healthy === false}
|
||||
status={props.controller.status()[key]}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -597,7 +163,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||
<Show
|
||||
when={
|
||||
props.domain.collection.current() &&
|
||||
ServerConnection.key(props.domain.collection.current()!) === key
|
||||
}
|
||||
>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
@@ -616,27 +187,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.controller.startEdit(i)
|
||||
props.onEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canRemove(key)}>
|
||||
<Show when={props.domain.connection.canRemove(key)}>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
onSelect={() => props.domain.connection.remove(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
@@ -657,7 +228,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.controller.startAdd}
|
||||
onClick={props.onAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -667,38 +238,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.controller.formValue()}
|
||||
name={props.controller.formName()}
|
||||
username={props.controller.formUsername()}
|
||||
password={props.controller.formPassword()}
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.controller.formBusy()}
|
||||
error={props.controller.formError()}
|
||||
status={props.controller.formStatus()}
|
||||
onChange={props.controller.handleFormChange()}
|
||||
onNameChange={props.controller.handleFormNameChange()}
|
||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||
onSubmit={props.controller.submitForm}
|
||||
onBack={props.controller.resetForm}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={props.controller.submitForm}
|
||||
disabled={props.controller.formBusy()}
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.controller.formBusy()
|
||||
{props.form.state.busy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.controller.isAddMode()
|
||||
: props.form.state.adding()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const queryClient = useQueryClient()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
}
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({ integrationID: input.provider, location: location() })
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
if (values?.length) return [...values]
|
||||
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as number | undefined,
|
||||
authorization: undefined as Authorization | undefined,
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
disposed: false,
|
||||
}
|
||||
const currentMethod = createMemo(() =>
|
||||
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
|
||||
)
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.form" }
|
||||
| { type: "auth.answer"; answer: FormAnswer | undefined }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: Authorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
const dispatch = (action: Action) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.form") {
|
||||
draft.state = "form"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.answer") {
|
||||
draft.formAnswer = action.answer
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cancelPolling = () => {
|
||||
polling.generation++
|
||||
if (polling.timer === undefined) return
|
||||
clearTimeout(polling.timer)
|
||||
polling.timer = undefined
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
await queryClient
|
||||
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
|
||||
.catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
const poll = async (authorization: Authorization, generation: number) => {
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID: options.provider(),
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, status: response.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
}
|
||||
const select = async (index: number, answer?: FormAnswer) => {
|
||||
cancelPolling()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.form?.length && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
if (selected.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
return
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
if (selected.form?.some((field) => field.type !== "string")) {
|
||||
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: options.provider(),
|
||||
methodID: selected.id,
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, authorization: response.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: String(result.error) })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: options.provider(),
|
||||
location: location(),
|
||||
key,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
})
|
||||
await finish()
|
||||
}
|
||||
const completeCode = async (code: string) => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: options.provider(),
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!result.ok) {
|
||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||
return message || language.t("provider.connect.oauth.code.invalid")
|
||||
}
|
||||
await finish()
|
||||
return undefined
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || integration.loading || methods().length !== 1) return
|
||||
auto = true
|
||||
void select(0)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
loading: () => integration.loading,
|
||||
methods,
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
select,
|
||||
reset,
|
||||
connectKey,
|
||||
completeCode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultKeyActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const set = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultKeyActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: () => defaultKey.latest,
|
||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const defaults = useDefaultServer()
|
||||
|
||||
const remove = async (key: ServerConnection.Key) => {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||
}
|
||||
|
||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||
|
||||
export function useServerCollectionController() {
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const actions = useServerActionsController()
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
})
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||
)
|
||||
const sorted = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
collection: {
|
||||
items: sorted,
|
||||
current,
|
||||
health: () => global.servers.health,
|
||||
},
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||
|
||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const collection = useServerCollectionController()
|
||||
|
||||
const select = async (connection: ServerConnection.Any) => {
|
||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||
}
|
||||
|
||||
return { ...collection, selection: { select } }
|
||||
}
|
||||
|
||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||
|
||||
describe("createServerHealthPreview", () => {
|
||||
test("ignores an older response that resolves after the latest response", async () => {
|
||||
const first = deferred<{ healthy: boolean }>()
|
||||
const second = deferred<{ healthy: boolean }>()
|
||||
const requests = [first, second]
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||
|
||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||
second.resolve({ healthy: true })
|
||||
await latest
|
||||
first.resolve({ healthy: false })
|
||||
await older
|
||||
|
||||
expect(status).toEqual([undefined, undefined, true])
|
||||
})
|
||||
|
||||
test("an incomplete value invalidates an in-flight response", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
await preview.preview(values("server"), (value) => status.push(value))
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
preview.cancel()
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe("replaceServerConnection", () => {
|
||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||
|
||||
test("moves active selection after adding the replacement and removes the original", () => {
|
||||
const calls: string[] = []
|
||||
|
||||
replaceServerConnection(ServerConnection.key(original), next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||
add: (server) => {
|
||||
calls.push(`add:${ServerConnection.key(server)}`)
|
||||
return server
|
||||
},
|
||||
setActive: (key) => calls.push(`active:${key}`),
|
||||
remove: (key) => calls.push(`remove:${key}`),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
"tabs:https://old.example.com",
|
||||
"add:https://new.example.com",
|
||||
"active:https://new.example.com",
|
||||
"remove:https://old.example.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original when the replacement cannot be added", () => {
|
||||
const removed: ServerConnection.Key[] = []
|
||||
|
||||
replaceServerConnection(ServerConnection.key(original), next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: () => {},
|
||||
add: () => undefined,
|
||||
setActive: () => {},
|
||||
remove: (key) => removed.push(key),
|
||||
})
|
||||
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
export type ServerFormValues = {
|
||||
url: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function createServerHealthPreview(
|
||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||
) {
|
||||
let generation = 0
|
||||
|
||||
const cancel = () => {
|
||||
generation += 1
|
||||
}
|
||||
|
||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||
const current = ++generation
|
||||
setStatus(undefined)
|
||||
const normalized = normalizeServerUrl(values.url)
|
||||
if (!normalized) return
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return
|
||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||
return
|
||||
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (values.username) http.username = values.username
|
||||
if (values.password) http.password = values.password
|
||||
const result = await check(http)
|
||||
if (current !== generation) return
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { cancel, preview }
|
||||
}
|
||||
|
||||
export function replaceServerConnection(
|
||||
originalKey: ServerConnection.Key,
|
||||
next: ServerConnection.Http,
|
||||
operations: {
|
||||
active: () => ServerConnection.Key | undefined
|
||||
removeTabs: (key: ServerConnection.Key) => void
|
||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||
setActive: (key: ServerConnection.Key) => void
|
||||
remove: (key: ServerConnection.Key) => void
|
||||
},
|
||||
) {
|
||||
const active = operations.active()
|
||||
operations.removeTabs(originalKey)
|
||||
const added = operations.add(next)
|
||||
if (!added) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||
if (nextActive) operations.setActive(nextActive)
|
||||
operations.remove(originalKey)
|
||||
}
|
||||
@@ -2,13 +2,13 @@ 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"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
domain: ServerActionsController
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
canRemove={props.controller.canRemove(key)}
|
||||
canDefault={props.domain.defaults.available()}
|
||||
isDefault={props.domain.defaults.key() === key}
|
||||
canRemove={props.domain.connection.canRemove(key)}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
onSetDefault={() => props.domain.defaults.set(key)}
|
||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||
onRemove={() => props.domain.connection.remove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
|
||||
@@ -3,51 +3,63 @@ import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@op
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
createServerHealthPreview,
|
||||
replaceServerConnection,
|
||||
type ServerFormValues,
|
||||
} from "@/components/server/server-management"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
export const DialogServerV2: Component<{
|
||||
mode: "add" | "edit"
|
||||
server?: ServerConnection.Http
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController({
|
||||
const form = createFormController({
|
||||
onSelect: () => dialog.close(),
|
||||
navigateOnAdd: false,
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
if (props.mode === "add") controller.startAdd()
|
||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||
if (props.mode === "add") form.start.add()
|
||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||
setOpened(true)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
controller.resetForm()
|
||||
form.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) return
|
||||
if (controller.isFormMode()) return
|
||||
if (form.state.open()) return
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
controller.submitForm()
|
||||
form.submit()
|
||||
}
|
||||
|
||||
const title = () =>
|
||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||
|
||||
const submitLabel = () => {
|
||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||
return language.t("common.save")
|
||||
}
|
||||
@@ -66,16 +78,16 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formValue()}
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!controller.formError()}
|
||||
disabled={controller.formBusy()}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={controller.formError()}>
|
||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||
<Show when={form.state.error()}>
|
||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -84,10 +96,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formName()}
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +110,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formUsername()}
|
||||
value={form.state.username()}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,10 +123,10 @@ export const DialogServerV2: Component<{
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={controller.formPassword()}
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,13 +134,171 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||
const [store, setStore] = createStore({
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
onCleanup(healthPreview.cancel)
|
||||
|
||||
const reset = () => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const allServers = () => {
|
||||
if (!server.current || server.list.includes(server.current)) return server.list
|
||||
return [server.current, ...server.list]
|
||||
}
|
||||
const editing = createMemo(() =>
|
||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||
)
|
||||
const add = (connection: ServerConnection.Http) => server.add(connection)
|
||||
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
||||
replaceServerConnection(originalKey, next, {
|
||||
active: () => server.key,
|
||||
removeTabs: (key) => tabs.removeServer(key),
|
||||
add,
|
||||
setActive: (key) => server.setActive(key),
|
||||
remove: (key) => server.remove(key),
|
||||
})
|
||||
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const normalized = normalizeServerUrl(store.values.url)
|
||||
if (!normalized) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const original = store.mode === "edit" ? editing() : undefined
|
||||
if (store.mode === "edit" && !original) return
|
||||
const name = store.values.name.trim() || undefined
|
||||
const username = store.values.username || undefined
|
||||
const password = store.values.password || undefined
|
||||
if (
|
||||
original?.type === "http" &&
|
||||
normalized === original.http.url &&
|
||||
name === original.displayName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const connection: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: {
|
||||
url: normalized,
|
||||
username: store.mode === "add" && !password ? undefined : username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
const result = await checkServerHealth(connection.http)
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
if (original?.type === "http") {
|
||||
if (normalized === original.http.url) add(connection)
|
||||
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
add(connection)
|
||||
options.onSelect?.()
|
||||
},
|
||||
}))
|
||||
|
||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||
const change = (field: keyof ServerFormValues, value: string) => {
|
||||
if (request.isPending) return
|
||||
setStore("values", field, value)
|
||||
setStore("error", "")
|
||||
if (field !== "name") preview()
|
||||
}
|
||||
const startAdd = () => {
|
||||
reset()
|
||||
setStore("mode", "add")
|
||||
}
|
||||
const startEdit = (connection: ServerConnection.Http) => {
|
||||
reset()
|
||||
setStore({
|
||||
mode: "edit",
|
||||
originalUrl: connection.http.url,
|
||||
values: {
|
||||
url: connection.http.url,
|
||||
name: connection.displayName ?? "",
|
||||
username: connection.http.username ?? "",
|
||||
password: connection.http.password ?? "",
|
||||
},
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||
})
|
||||
}
|
||||
const submit = () => {
|
||||
if (store.mode === "list" || request.isPending) return
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
if (editing()) return
|
||||
reset()
|
||||
})
|
||||
|
||||
return {
|
||||
state: {
|
||||
mode: () => store.mode,
|
||||
open: () => store.mode !== "list",
|
||||
adding: () => store.mode === "add",
|
||||
busy: () => request.isPending,
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
username: () => store.values.username,
|
||||
password: () => store.values.password,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
change: {
|
||||
value: (value: string) => change("url", value),
|
||||
name: (value: string) => change("name", value),
|
||||
username: (value: string) => change("username", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { useServerCollectionController } from "../server/server-management-controller"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
||||
export const SettingsServersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController()
|
||||
const controller = useServerCollectionController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||
const items = controller.collection.items().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<WslServerSettings controller={controller} servers={wslServers} />
|
||||
<WslServerSettings domain={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => controller.status()[key]
|
||||
const isDefault = () => controller.defaultKey() === key
|
||||
const health = () => controller.collection.health()[key]
|
||||
const isDefault = () => controller.defaults.key() === key
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={controller.canDefault() && isDefault()}>
|
||||
<Show when={controller.defaults.available() && isDefault()}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
@@ -16,14 +12,12 @@ import {
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
@@ -106,16 +100,6 @@ const useDefaultServerKey = (
|
||||
}
|
||||
}
|
||||
|
||||
type ServerStatusState = {
|
||||
servers: () => ServerStatusItem[]
|
||||
defaultKey: () => ServerConnection.Key | undefined
|
||||
ariaLabel: string
|
||||
serversLabel: string
|
||||
defaultLabel: string
|
||||
manageLabel: string
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
type ServerStatusItem = {
|
||||
key: ServerConnection.Key
|
||||
conn: ServerConnection.Any
|
||||
@@ -125,149 +109,13 @@ type ServerStatusItem = {
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverServerBody() {
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
|
||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const serverItems = createMemo(() =>
|
||||
sortedServers().map((conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return {
|
||||
key,
|
||||
conn,
|
||||
health: global.servers.health[key],
|
||||
blocked: global.servers.health[key]?.healthy === false,
|
||||
active: !!server.current && key === ServerConnection.key(server.current),
|
||||
onSelect: () => {
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerStatusPopoverView
|
||||
state={{
|
||||
servers: serverItems,
|
||||
defaultKey: defaultServer.key,
|
||||
ariaLabel: language.t("status.popover.ariaLabel"),
|
||||
serversLabel: language.t("status.popover.tab.servers"),
|
||||
defaultLabel: language.t("common.default"),
|
||||
manageLabel: language.t("status.popover.action.manageServers"),
|
||||
onManage: () => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={props.state.ariaLabel}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
|
||||
{props.state.serversLabel}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="servers">
|
||||
<ServerStatusList state={props.state} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerStatusList(props: { state: ServerStatusState }) {
|
||||
return (
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={props.state.servers()}>
|
||||
{(item) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !item.blocked,
|
||||
"cursor-not-allowed": item.blocked,
|
||||
}}
|
||||
aria-disabled={item.blocked}
|
||||
onClick={() => {
|
||||
if (item.blocked) return
|
||||
item.onSelect()
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={item.health} />
|
||||
<ServerRow
|
||||
conn={item.conn}
|
||||
dimmed={item.blocked}
|
||||
status={item.health}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={item.key === props.state.defaultKey()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{props.state.defaultLabel}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={item.active}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
|
||||
{props.state.manageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
@@ -315,17 +163,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
|
||||
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
|
||||
data-active="mcp"
|
||||
defaultValue="mcp"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
{!settings.general.newLayoutDesigns() && (
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
@@ -342,73 +184,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
</Show>
|
||||
</Tabs.List>
|
||||
|
||||
{!settings.general.newLayoutDesigns() && (
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => global.servers.health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={global.servers.health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "./status-popover-indicator"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||
|
||||
export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
@@ -82,8 +81,7 @@ export function StatusPopover() {
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverV2(props: { scope?: "server" }) {
|
||||
if (props.scope === "server") return <ServerStatusPopover />
|
||||
export function StatusPopoverV2() {
|
||||
return <DirectoryStatusPopover />
|
||||
}
|
||||
|
||||
@@ -124,30 +122,6 @@ function DirectoryStatusPopover() {
|
||||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
function ServerStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: serverHealth() !== undefined,
|
||||
serverHealth: serverHealth(),
|
||||
attention: false,
|
||||
issue: false,
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<ServerBody />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
type StatusPopoverState = {
|
||||
shown: boolean
|
||||
ready: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const serverManagement = useServerActionsController()
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
canDefault: serverManagement.canDefault,
|
||||
defaultKey: serverManagement.defaultKey,
|
||||
canDefault: serverManagement.defaults.available,
|
||||
defaultKey: serverManagement.defaults.key,
|
||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function timelineChildTitle(input: {
|
||||
parentID?: string
|
||||
taskDescription?: string
|
||||
title?: string
|
||||
fallback: string
|
||||
}) {
|
||||
if (!input.parentID) return input.title ?? ""
|
||||
if (input.taskDescription) return input.taskDescription
|
||||
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
|
||||
}
|
||||
|
||||
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
|
||||
const removed = new Set([sessionID])
|
||||
const byParent = Map.groupBy(
|
||||
sessions.filter((session) => session.parentID),
|
||||
(session) => session.parentID!,
|
||||
)
|
||||
const visit = (id: string) =>
|
||||
byParent.get(id)?.forEach((child) => {
|
||||
if (removed.has(child.id)) return
|
||||
removed.add(child.id)
|
||||
visit(child.id)
|
||||
})
|
||||
visit(sessionID)
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyParts: Part[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return undefined
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return undefined
|
||||
const value = part.state.input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function createTimelineController(input: { userMessages: Accessor<UserMessage[]> }) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const status = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(input.userMessages().map((message) => message.id))
|
||||
const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const projected = sync().data.session_message[id] ?? []
|
||||
if (!boundary) return projected
|
||||
const index = projected.findIndex((message) => message.id === boundary)
|
||||
return index < 0 ? projected : projected.slice(0, index)
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
const shareEnabled = () => false
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
|
||||
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return undefined
|
||||
return parentMessages()
|
||||
.flatMap((message) => parts(message.id))
|
||||
.map((item) => taskDescription(item, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
return timelineChildTitle({
|
||||
parentID: parentID(),
|
||||
taskDescription: childTaskDescription(),
|
||||
title: titleLabel(),
|
||||
fallback: language.t("command.session.new"),
|
||||
})
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages,
|
||||
userMessages: input.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts,
|
||||
status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
})
|
||||
const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = error.data
|
||||
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
|
||||
}
|
||||
if (error instanceof Error) return error.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const rename = async (title: string) => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.rename) return false
|
||||
const next = title.trim()
|
||||
if (!next || next === (titleLabel() ?? "")) return true
|
||||
setPending("rename", true)
|
||||
const success = await sdk()
|
||||
.api.session.rename({ sessionID: id, title: next })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
|
||||
return false
|
||||
})
|
||||
setPending("rename", false)
|
||||
if (!success) return false
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
const share = async () => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.share || !shareEnabled()) return
|
||||
}
|
||||
const unshare = async () => {
|
||||
const id = sessionID()
|
||||
if (!id || pending.unshare || !shareEnabled()) return
|
||||
}
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (params.id !== id) return
|
||||
if (parent) return navigate(href(parent))
|
||||
if (next) return navigate(href(next))
|
||||
if (params.serverKey)
|
||||
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
const exportSession = async (id: string) => {
|
||||
try {
|
||||
const data = await fetchSessionExport({ sessionID: id, api: sdk().api })
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
const session = sync().session.get(id)
|
||||
if (!session) return false
|
||||
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
const success = await sdk()
|
||||
.api.session.remove({ sessionID: id })
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
|
||||
return false
|
||||
})
|
||||
if (!success) return false
|
||||
const removed = timelineRemovedSessionIDs(sync().data.session, id)
|
||||
void navigateAfterRemoval(id, session.parentID, next?.id)
|
||||
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
|
||||
removed.forEach((sessionID) => sync().session.evict(sessionID))
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
function DeleteDialog(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const confirm = async () => {
|
||||
await remove(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description || sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
data: {
|
||||
sessionKey,
|
||||
sessionID,
|
||||
status,
|
||||
titleValue,
|
||||
titleLabel,
|
||||
shareUrl,
|
||||
shareEnabled,
|
||||
parentID,
|
||||
parentTitle,
|
||||
childTitle,
|
||||
showHeader,
|
||||
parts,
|
||||
part,
|
||||
projection,
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
},
|
||||
pending: {
|
||||
rename: () => pending.rename,
|
||||
share: () => pending.share,
|
||||
unshare: () => pending.unshare,
|
||||
},
|
||||
action: {
|
||||
rename,
|
||||
share,
|
||||
unshare,
|
||||
export: exportSession,
|
||||
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
|
||||
navigateParent: () => {
|
||||
const id = parentID()
|
||||
if (id) navigate(href(id))
|
||||
},
|
||||
viewShare: () => {
|
||||
const url = shareUrl()
|
||||
if (url) platform.openExternal(url)
|
||||
},
|
||||
copyShareUrl: async () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
await navigator.clipboard.writeText(url).then(
|
||||
() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: url,
|
||||
}),
|
||||
(error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineController = ReturnType<typeof createTimelineController>
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -35,8 +33,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
@@ -45,38 +41,22 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { AssistantMessage, Message as MessageType, Part as PartType, ToolPart, UserMessage } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController } from "./controller"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
||||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
@@ -84,14 +64,6 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
|
||||
const timelineFallbackItemSize = 60
|
||||
const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
|
||||
|
||||
const taskDescription = (part: PartType, sessionID: string) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return
|
||||
const value = part.state.input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
}
|
||||
|
||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
const nested = current?.closest("[data-scrollable]")
|
||||
@@ -229,7 +201,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
type MessageTimelineProps = {
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
@@ -249,92 +221,42 @@ export function MessageTimeline(props: {
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ userMessages: () => props.userMessages })
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
}
|
||||
|
||||
function MessageTimelineView(
|
||||
props: MessageTimelineProps & {
|
||||
data: TimelineController["data"]
|
||||
action: TimelineController["action"]
|
||||
pending: TimelineController["pending"]
|
||||
},
|
||||
) {
|
||||
let touchGesture: number | undefined
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(props.userMessages.map((message) => message.id))
|
||||
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const messages = sync().data.session_message[id] ?? []
|
||||
if (!boundary) return messages
|
||||
const index = messages.findIndex((message) => message.id === boundary)
|
||||
return index < 0 ? messages : messages.slice(0, index)
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
// TODO: Restore these actions when the V2 client exposes session sharing.
|
||||
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const shareEnabled = () => false
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = parentID()
|
||||
if (!id) return emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return parentMessages()
|
||||
.flatMap((message) => getMsgParts(message.id))
|
||||
.map((part) => taskDescription(part, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
if (!parentID()) return titleLabel() ?? ""
|
||||
if (childTaskDescription()) return childTaskDescription()
|
||||
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
|
||||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts: getMsgParts,
|
||||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
})
|
||||
const sessionID = props.data.sessionID
|
||||
const sessionStatus = props.data.status
|
||||
const titleLabel = props.data.titleLabel
|
||||
const shareUrl = props.data.shareUrl
|
||||
const shareEnabled = props.data.shareEnabled
|
||||
const parentID = props.data.parentID
|
||||
const parentTitle = props.data.parentTitle
|
||||
const childTitle = props.data.childTitle
|
||||
const showHeader = props.data.showHeader
|
||||
const getMsgParts = props.data.parts
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
@@ -524,9 +446,9 @@ export function MessageTimeline(props: {
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = sessionKey()
|
||||
let measuredSessionKey = props.data.sessionKey()
|
||||
createEffect(() => {
|
||||
const key = sessionKey()
|
||||
const key = props.data.sessionKey()
|
||||
timelineRows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
@@ -639,90 +561,6 @@ export function MessageTimeline(props: {
|
||||
props.setScrollRef(undefined)
|
||||
})
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
platform.openExternal(url)
|
||||
}
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
const shareMutation = useMutation(() => ({
|
||||
// TODO: Restore sharing when the V2 client exposes a session sharing API.
|
||||
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
|
||||
onError: (err) => {
|
||||
console.error("Failed to share session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const unshareMutation = useMutation(() => ({
|
||||
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
|
||||
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
|
||||
onError: (err) => {
|
||||
console.error("Failed to unshare session", err)
|
||||
},
|
||||
}))
|
||||
|
||||
const titleMutation = useMutation(() => ({
|
||||
mutationFn: (input: { id: string; title: string }) =>
|
||||
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
|
||||
onSuccess: (_, input) => {
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||
if (index !== -1) draft.session[index].title = input.title
|
||||
}),
|
||||
)
|
||||
setTitle("editing", false)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const shareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || shareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
shareMutation.mutate(id)
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const id = sessionID()
|
||||
if (!id || unshareMutation.isPending) return
|
||||
if (!shareEnabled()) return
|
||||
unshareMutation.mutate(id)
|
||||
}
|
||||
const copyShareUrl = () => {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
void navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("session.share.copy.copied"),
|
||||
description: url,
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
@@ -734,7 +572,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
props.data.sessionKey,
|
||||
() =>
|
||||
setTitle({
|
||||
draft: "",
|
||||
@@ -747,18 +585,6 @@ export function MessageTimeline(props: {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description) return
|
||||
if (sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID() || parentID()) return
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
@@ -770,186 +596,12 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (titleMutation.isPending) return
|
||||
if (props.pending.rename()) return
|
||||
setTitle("editing", false)
|
||||
}
|
||||
|
||||
const saveTitleEditor = () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleLabel() ?? "")) {
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
titleMutation.mutate({ id, title: next })
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
const href = (id: string) =>
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
if (parentID) {
|
||||
navigate(href(parentID))
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(href(nextSessionID))
|
||||
return
|
||||
}
|
||||
if (params.serverKey) {
|
||||
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const exportSession = async (sessionID: string) => {
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
api: sdk().api,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk()
|
||||
.api.session.remove({ sessionID })
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of sync().data.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of removed) {
|
||||
sync().session.evict(id)
|
||||
}
|
||||
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return (
|
||||
<DialogV2 fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</DialogV2>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
const saveTitleEditor = async () => {
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
|
||||
@@ -1028,7 +680,7 @@ export function MessageTimeline(props: {
|
||||
const defaultOpen = createMemo(() => {
|
||||
const item = part()
|
||||
if (!item) return
|
||||
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
|
||||
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1041,7 +693,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
useV2Actions={props.data.newLayoutDesigns()}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
@@ -1104,8 +756,8 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
|
||||
"border-[0.5px]": settings.general.newLayoutDesigns(),
|
||||
border: !settings.general.newLayoutDesigns(),
|
||||
"border-[0.5px]": props.data.newLayoutDesigns(),
|
||||
border: !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
@@ -1140,7 +792,7 @@ export function MessageTimeline(props: {
|
||||
if (m?.role === "user") return m
|
||||
})
|
||||
const messageComments = createMemo(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return []
|
||||
if (!props.data.newLayoutDesigns()) return []
|
||||
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||
})
|
||||
return (
|
||||
@@ -1153,7 +805,7 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
actions={props.actions}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
useV2Actions={props.data.newLayoutDesigns()}
|
||||
comments={messageComments()}
|
||||
/>
|
||||
</div>
|
||||
@@ -1201,7 +853,7 @@ export function MessageTimeline(props: {
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -1317,16 +969,16 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"bottom-8": settings.general.newLayoutDesigns(),
|
||||
"bottom-6": !settings.general.newLayoutDesigns(),
|
||||
"bottom-8": props.data.newLayoutDesigns(),
|
||||
"bottom-6": !props.data.newLayoutDesigns(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
|
||||
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
|
||||
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
@@ -1389,22 +1041,22 @@ export function MessageTimeline(props: {
|
||||
classList={{
|
||||
"sticky top-0 z-30": true,
|
||||
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
props.data.newLayoutDesigns(),
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
|
||||
!settings.general.newLayoutDesigns(),
|
||||
!props.data.newLayoutDesigns(),
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pr-3": true,
|
||||
"pl-2.5": settings.general.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||
"pl-2.5": props.data.newLayoutDesigns(),
|
||||
"pl-2 md:pl-4": !props.data.newLayoutDesigns(),
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||
"pr-3": !settings.general.newLayoutDesigns(),
|
||||
"pr-3": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
@@ -1413,7 +1065,7 @@ export function MessageTimeline(props: {
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-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}
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
@@ -1434,8 +1086,8 @@ export function MessageTimeline(props: {
|
||||
classList={{
|
||||
"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(),
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
@@ -1449,15 +1101,14 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"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(),
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
@@ -1485,17 +1136,17 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 flex items-center": true,
|
||||
"gap-2": settings.general.newLayoutDesigns(),
|
||||
"gap-3": !settings.general.newLayoutDesigns(),
|
||||
"gap-2": props.data.newLayoutDesigns(),
|
||||
"gap-3": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<SessionContextUsage
|
||||
placement="bottom"
|
||||
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
@@ -1558,14 +1209,12 @@ export function MessageTimeline(props: {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => exportSession(id)}>
|
||||
<DropdownMenu.Item onSelect={() => void props.action.export(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -1630,12 +1279,12 @@ export function MessageTimeline(props: {
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => exportSession(id)}>
|
||||
<MenuV2.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
@@ -1647,7 +1296,7 @@ export function MessageTimeline(props: {
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||
gutter={props.data.newLayoutDesigns() ? 6 : 4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
@@ -1659,7 +1308,7 @@ export function MessageTimeline(props: {
|
||||
data-component="popover-content"
|
||||
classList={{
|
||||
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
|
||||
settings.general.newLayoutDesigns(),
|
||||
props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{ "min-width": "320px" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
@@ -1679,7 +1328,7 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
when={props.data.newLayoutDesigns()}
|
||||
fallback={
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -1700,10 +1349,10 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
onClick={() => void props.action.share()}
|
||||
disabled={props.pending.share()}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
{props.pending.share()
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
@@ -1723,10 +1372,10 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={() => void props.action.unshare()}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
{props.pending.unshare()
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
@@ -1734,8 +1383,8 @@ export function MessageTimeline(props: {
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={props.action.viewShare}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
@@ -1763,10 +1412,10 @@ export function MessageTimeline(props: {
|
||||
<ButtonV2
|
||||
variant="contrast"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
onClick={() => void props.action.share()}
|
||||
disabled={props.pending.share()}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
{props.pending.share()
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</ButtonV2>
|
||||
@@ -1792,7 +1441,7 @@ export function MessageTimeline(props: {
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-copy" />}
|
||||
aria-label={language.t("session.share.copy.copyLink")}
|
||||
onClick={copyShareUrl}
|
||||
onClick={() => void props.action.copyShareUrl()}
|
||||
/>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
@@ -1800,18 +1449,18 @@ export function MessageTimeline(props: {
|
||||
variant="ghost-muted"
|
||||
icon={<IconV2 name="outline-square-arrow" />}
|
||||
aria-label={language.t("session.share.action.view")}
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={props.action.viewShare}
|
||||
disabled={props.pending.unshare()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<ButtonV2
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
onClick={() => void props.action.unshare()}
|
||||
disabled={props.pending.unshare()}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
{props.pending.unshare()
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</ButtonV2>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
type Controller = ReturnType<typeof useServerManagementController>
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
void dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
}
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
controller: Controller
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
request.mutate(() => props.domain.connection.remove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<Show when={opencodeAction()}>
|
||||
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
|
||||
{language.t("wsl.server.retryStart")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
resolvePart,
|
||||
turnDuration,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
@@ -1597,6 +1598,7 @@ function SessionGroupView(props: {
|
||||
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -1607,9 +1609,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
const duration = createMemo(() =>
|
||||
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
||||
)
|
||||
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
|
||||
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -348,6 +348,15 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
|
||||
return drop > 0 ? drop : undefined
|
||||
}
|
||||
|
||||
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
|
||||
if (message.time.completed === undefined) return 0
|
||||
const index = messages.findIndex((item) => item.id === message.id)
|
||||
const input = messages
|
||||
.slice(0, index === -1 ? messages.length : index)
|
||||
.findLast((item) => item.type === "user" || item.type === "synthetic")
|
||||
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
|
||||
}
|
||||
|
||||
function hasTokenUsage(
|
||||
message: SessionMessageAssistant,
|
||||
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
|
||||
|
||||
test("measures turn duration from the user prompt across assistant steps", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.time = { created: 8_000, completed: 11_000 }
|
||||
const final = assistant("assistant-2", [])
|
||||
final.time = { created: 27_000, completed: 30_000 }
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
|
||||
first,
|
||||
final,
|
||||
]
|
||||
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
|
||||
@@ -45,7 +45,7 @@ export function layer(
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
)
|
||||
return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, app)))
|
||||
return Layer.merge(logs, yield* Otlp.tracingLayer(options, app))
|
||||
}),
|
||||
).pipe(Layer.catchCause(() => local))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Layer } from "effect"
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { OtlpLogger } from "effect/unstable/observability"
|
||||
import { runID } from "./shared.js"
|
||||
|
||||
@@ -71,28 +71,39 @@ export function loggers(options: Options | undefined, app: App) {
|
||||
]
|
||||
}
|
||||
|
||||
export async function tracingLayer(options: Options | undefined, app: App) {
|
||||
export const tracingLayer = Effect.fnUntraced(function* (options: Options | undefined, app: App) {
|
||||
if (!options?.endpoint) return Layer.empty
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||
const { context } = await import("@opentelemetry/api")
|
||||
const [{ layer }, { OTLPTraceExporter }, { BatchSpanProcessor }, { AsyncLocalStorageContextManager }, { context }] =
|
||||
yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => import("@effect/opentelemetry/NodeSdk")),
|
||||
Effect.promise(() => import("@opentelemetry/exporter-trace-otlp-http")),
|
||||
Effect.promise(() => import("@opentelemetry/sdk-trace-base")),
|
||||
Effect.promise(() => import("@opentelemetry/context-async-hooks")),
|
||||
Effect.promise(() => import("@opentelemetry/api")),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
|
||||
const manager = new AsyncLocalStorageContextManager()
|
||||
manager.enable()
|
||||
context.setGlobalContextManager(manager)
|
||||
|
||||
return NodeSdk.layer(() => ({
|
||||
const tracing = layer(() => ({
|
||||
resource: resource(app),
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
spanProcessor: new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({
|
||||
url: `${options.endpoint}/v1/traces`,
|
||||
headers: parseHeaders(options.headers),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
return Layer.effectContext(
|
||||
Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit).pipe(Effect.ignoreCause)).pipe(
|
||||
Effect.flatMap((scope) => Layer.buildWithScope(tracing, scope)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export * as Otlp from "./otlp.js"
|
||||
|
||||
Reference in New Issue
Block a user