fix(tui): scope connect dialog to location

This commit is contained in:
Dax Raad 2026-08-31 23:42:35 -04:00
parent 5d73a5789f
commit 815d4ab9b4
2 changed files with 115 additions and 51 deletions

View file

@ -9,6 +9,7 @@ import type {
FormField,
FormFields,
FormValue,
LocationRef,
} from "@opencode-ai/client"
import open from "open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
@ -16,6 +17,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
@ -74,10 +76,12 @@ export function DialogIntegration(
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
) {
const data = useData()
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
integrationOptions(data.location.integration.list() ?? []).filter(
integrationOptions(data.location.integration.list(location) ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
),
)
@ -88,14 +92,14 @@ export function DialogIntegration(
if (!integration) return
const methods = connectMethods(integration)
if (credentialConnections(integration).length) {
manageConnections(integration, methods, dialog, props.onConnected)
manageConnections(integration, methods, location, dialog, props.onConnected)
return
}
selectMethod(integration, methods, dialog, props.onConnected)
selectMethod(integration, methods, location, dialog, props.onConnected)
})
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providers = data.location.websearch.list(location) ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
return integrations().map((integration) => {
const methods = connectMethods(integration)
@ -117,8 +121,8 @@ export function DialogIntegration(
? () => <text fg={theme.text.feedback.success.default}></text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
return selectMethod(integration, methods, dialog, props.onConnected)
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
},
}
})
@ -145,6 +149,7 @@ export function DialogIntegration(
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@ -156,7 +161,9 @@ function manageConnections(
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id))
const current = createMemo(() =>
data.location.integration.list(location)?.find((item) => item.id === integration.id),
)
return (
<DialogSelect
@ -174,7 +181,7 @@ function manageConnections(
{
title: "Add account",
value: "add",
onSelect: () => selectMethod(current() ?? integration, methods, dialog, onConnected),
onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
},
]
: []),
@ -193,7 +200,7 @@ function manageConnections(
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
.activate({ credentialID: connection.id, location: location(data) })
.activate({ credentialID: connection.id, location: locationQuery(location) })
.catch(toast.error)
},
}
@ -217,8 +224,8 @@ function manageConnections(
const label = value.trim()
if (!label) return
void client.api.credential
.update({ credentialID: option.value, label, location: location(data) })
.then(() => manageConnections(integration, methods, dialog, onConnected))
.update({ credentialID: option.value, label, location: locationQuery(location) })
.then(() => manageConnections(integration, methods, location, dialog, onConnected))
.catch(toast.error)
}}
/>
@ -234,7 +241,7 @@ function manageConnections(
if (deleting() !== option.value) return setDeleting(option.value)
const final = credentialConnections(current() ?? integration).length === 1
void client.api.credential
.remove({ credentialID: option.value, location: location(data) })
.remove({ credentialID: option.value, location: locationQuery(location) })
.then(() => {
setDeleting(undefined)
if (!final) return
@ -256,17 +263,18 @@ function manageConnections(
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, dialog, onConnected),
onSelect: () => openMethod(integration, method, location, dialog, onConnected),
}))}
/>
))
@ -275,23 +283,27 @@ function selectMethod(
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
void beginKey(integration, method, dialog, onConnected)
void beginKey(integration, method, location, dialog, onConnected)
return
}
if (method.type === "command") {
dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
dialog.replace(() => (
<CommandStarting integration={integration} method={method} location={location} onConnected={onConnected} />
))
return
}
void beginOAuth(integration, method, dialog, onConnected)
void beginOAuth(integration, method, location, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
@ -300,16 +312,16 @@ async function beginKey(
: undefined
if (answer === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} answer={answer} onConnected={onConnected} />
<KeyMethod integration={integration} method={method} location={location} answer={answer} onConnected={onConnected} />
))
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "command" }>
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@ -321,14 +333,14 @@ function CommandStarting(props: {
.connect({
integrationID: props.integration.id,
methodID: props.method.id,
location: location(data),
location: locationQuery(props.location),
})
.then((result) => {
if (closed) {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: result.data.attemptID,
location: location(data),
location: locationQuery(props.location),
})
return
}
@ -338,6 +350,7 @@ function CommandStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@ -359,6 +372,7 @@ function CommandPending(props: {
integration: IntegrationInfo
title: string
attempt: CommandAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@ -374,7 +388,7 @@ function CommandPending(props: {
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
.then((result) => {
const status = result.data
@ -385,7 +399,7 @@ function CommandPending(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, data, dialog, toast, props.onConnected)
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({
@ -408,7 +422,7 @@ function CommandPending(props: {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@ -449,6 +463,7 @@ function CommandView(props: { title: string; output: string; message: string })
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
@ -468,11 +483,11 @@ function KeyMethod(props: {
void client.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
location: locationQuery(props.location),
key,
...(props.answer ? { answer: props.answer } : {}),
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => (
@ -485,23 +500,30 @@ function KeyMethod(props: {
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
if (answer === null) return
dialog.replace(() => (
<OAuthStarting integration={integration} method={method} answer={answer} onConnected={onConnected} />
<OAuthStarting
integration={integration}
method={method}
location={location}
answer={answer}
onConnected={onConnected}
/>
))
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@ -510,7 +532,7 @@ function OAuthStarting(props: {
void client.api.integration.oauth
.connect({
integrationID: props.integration.id,
location: location(data),
location: locationQuery(props.location),
methodID: props.method.id,
...(props.answer ? { answer: props.answer } : {}),
})
@ -521,6 +543,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@ -531,6 +554,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
location={props.location}
onConnected={props.onConnected}
/>
))
@ -548,6 +572,7 @@ function OAuthAuto(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@ -591,7 +616,11 @@ function OAuthAuto(props: {
const poll = () => {
void client.api.integration.oauth
.status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) })
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: locationQuery(props.location),
})
.then((result) => {
const status = result.data
if (status.status === "pending") {
@ -600,7 +629,7 @@ function OAuthAuto(props: {
}
settled = true
if (status.status === "complete") {
void connected(props.integration, data, dialog, toast, props.onConnected)
void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
@ -620,7 +649,7 @@ function OAuthAuto(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@ -640,6 +669,7 @@ function OAuthCode(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@ -655,7 +685,7 @@ function OAuthCode(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
})
})
@ -669,12 +699,12 @@ function OAuthCode(props: {
.complete({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
location: location(data),
location: locationQuery(props.location),
code,
})
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)
return connected(props.integration, props.location, data, dialog, toast, props.onConnected)
})
.catch((cause) => setError(message(cause)))
}}
@ -968,26 +998,31 @@ async function externalAnswer(
async function connected(
integration: IntegrationInfo,
location: LocationRef,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
onConnected?: OnIntegrationConnected,
) {
data.location.integration.invalidate()
data.location.model.invalidate()
data.location.provider.invalidate()
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
data.location.integration.invalidate(location)
data.location.model.invalidate(location)
data.location.provider.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.model.sync(location),
data.location.provider.sync(location),
])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
if (onConnected) {
onConnected(providerID(data, integration.id))
onConnected(providerID(data, location, integration.id))
return
}
dialog.clear()
}
function providerID(data: ReturnType<typeof useData>, integrationID: string) {
const models = data.location.model.list() ?? []
const matches = (data.location.provider.list() ?? []).filter(
function providerID(data: ReturnType<typeof useData>, location: LocationRef, integrationID: string) {
const models = data.location.model.list(location) ?? []
const matches = (data.location.provider.list(location) ?? []).filter(
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
)
return (
@ -997,9 +1032,8 @@ function providerID(data: ReturnType<typeof useData>, integrationID: string) {
)
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
function locationQuery(location: LocationRef) {
return { directory: location.directory, workspace: location.workspaceID }
}
function message(cause: unknown) {

View file

@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import type { LocationRef } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
@ -8,7 +9,7 @@ import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@ -203,9 +204,27 @@ test("hides account rename and delete actions while the add account row is selec
}
})
async function renderIntegration() {
test("uses the active location for integration data and credential requests", async () => {
const location = { directory: "/remote/project", workspaceID: "workspace_test" }
const fixture = await renderIntegration(location)
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.requests.length === 1)
expect(fixture.locations).toContainEqual(location)
expect(fixture.locations.at(-1)).toEqual(location)
} finally {
fixture.app.renderer.destroy()
}
})
async function renderIntegration(activeLocation?: LocationRef) {
const events = createEventStream()
const requests: Array<{ method: string; path: string; body?: { label: string } }> = []
const locations: LocationRef[] = []
const reads = { integration: 0, model: 0, provider: 0 }
let accounts = [
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
@ -213,12 +232,19 @@ async function renderIntegration() {
]
const calls = createFetch(async (url, request) => {
const directory =
url.searchParams.get("location[directory]") ??
decodeURIComponent(request.headers.get("x-opencode-directory") ?? process.cwd())
const workspaceID =
url.searchParams.get("location[workspace]") ?? request.headers.get("x-opencode-workspace") ?? undefined
const requestedLocation = { directory, ...(workspaceID ? { workspaceID } : {}) }
const location = {
directory: process.cwd(),
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
...requestedLocation,
project: { id: "proj_test", directory, canonical: directory },
}
if (url.pathname === "/api/integration") {
locations.push(requestedLocation)
reads.integration++
return json({
location,
@ -244,6 +270,7 @@ async function renderIntegration() {
}
if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) {
locations.push(requestedLocation)
const id = url.pathname.split("/")[3]
const active = accounts.find((account) => account.id === id)
if (!active) throw new Error(`unknown credential: ${id}`)
@ -289,9 +316,11 @@ async function renderIntegration() {
function Probe() {
const data = useData()
const dialog = useDialog()
const location = useLocation()
onMount(() => {
location.set(activeLocation)
void data.location.integration
.sync()
.sync(activeLocation)
.then(() => dialog.replace(() => <DialogIntegration integrationID="openai" autoConnect />))
})
return null
@ -332,6 +361,7 @@ async function renderIntegration() {
app,
reads,
requests,
locations,
get accounts() {
return accounts
},