Skip to content

Commit 7f33576

Browse files
HonaBrendonovich
andauthored
feat(app): improve desktop multi-server support (anomalyco#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
1 parent 7ae856a commit 7f33576

62 files changed

Lines changed: 1523 additions & 841 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/e2e/regression/prompt-thinking-level.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test, type Page } from "@playwright/test"
22
import { base64Encode } from "@opencode-ai/core/util/encode"
33
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectAppVisible } from "../utils/waits"
45

56
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
67
const projectID = "proj_prompt_thinking_level_regression"
@@ -56,7 +57,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
5657
const composer = page.locator('[data-component="session-composer"]')
5758
const input = composer.locator('[data-component="prompt-input"]')
5859
const control = composer.locator('[data-component="prompt-variant-control"]')
59-
await expect(composer).toBeVisible()
60+
await expectAppVisible(composer)
6061

6162
await idleComposer(page)
6263
await expect(control).toBeHidden()

packages/app/e2e/regression/session-list-path-loading.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { expect, test } from "@playwright/test"
1+
import { test } from "@playwright/test"
22
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
33
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectAppVisible } from "../utils/waits"
45

56
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
67
await mockOpenCodeServer(page, {
@@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async (
3334

3435
await page.goto("/")
3536
try {
36-
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
37+
await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
3738
} finally {
3839
releasePath()
3940
}

packages/app/e2e/regression/session-timeline-collapse-state.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, type Locator, type Page } from "@playwright/test"
22
import { mockOpenCodeServer } from "../utils/mock-server"
3+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
34

45
const directory = "C:/OpenCode/TimelineStateRegression"
56
const projectID = "proj_timeline_state_regression"
@@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => {
106107
await configurePage(page)
107108

108109
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
109-
await expect(page.getByRole("heading", { name: title })).toBeVisible()
110+
await expectSessionTitle(page, title)
110111

111112
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
112-
await expect(wrapper).toBeVisible()
113+
await expectAppVisible(wrapper)
113114
await expectExpanded(wrapper, true)
114115

115116
await wrapper.evaluate((element) => {
@@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => {
142143
await configurePage(page)
143144

144145
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
145-
await expect(page.getByRole("heading", { name: title })).toBeVisible()
146+
await expectSessionTitle(page, title)
146147

147148
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
148-
await expect(wrapper).toBeVisible()
149-
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
149+
await expectAppVisible(wrapper)
150+
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
150151
await markDiffProbe(page)
151152

152153
events.push({

packages/app/e2e/regression/session-timeline-context-resize.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test, type Page } from "@playwright/test"
22
import { mockOpenCodeServer } from "../utils/mock-server"
3+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
34

45
const directory = "C:/OpenCode/ContextResizeRegression"
56
const projectID = "proj_context_resize_regression"
@@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => {
2324
await configurePage(page)
2425

2526
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
26-
await expect(page.getByRole("heading", { name: title })).toBeVisible()
27-
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
28-
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
27+
await expectSessionTitle(page, title)
28+
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
29+
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
2930
await settle(page)
3031

3132
const samples = await sampleExpansion(page)

packages/app/e2e/smoke/session-timeline.spec.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
33
import { fixture, pageMessages } from "./session-timeline.fixture"
44
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
55
import { mockOpenCodeServer } from "../utils/mock-server"
6+
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
67

78
const forbiddenText = ["Load details", "Show earlier steps"]
89

@@ -411,18 +412,21 @@ function expectCompleteScroll(
411412

412413
async function selectHomeProject(page: Page, projectName: string) {
413414
await page.goto("/")
414-
await page
415+
const row = page
415416
.locator('[data-component="home-project-row"]')
416417
.filter({ hasText: new RegExp(projectName, "i") })
417-
.click()
418+
.first()
419+
await expectAppVisible(row)
420+
await row.click()
421+
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
418422
await expect(page).toHaveURL(/\/$/)
419423
}
420424

421425
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
422426
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
423-
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
427+
await expectSessionTitle(page, expectedTitle)
424428
}
425429

426430
async function expectSessionReady(page: Page) {
427-
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
431+
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
428432
}

packages/app/e2e/utils/waits.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, type Locator, type Page } from "@playwright/test"
2+
3+
export const APP_READY_TIMEOUT = 30_000
4+
5+
export async function expectAppVisible(locator: Locator) {
6+
await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
7+
}
8+
9+
export async function expectSessionTitle(page: Page, title: string) {
10+
await expectAppVisible(page.getByRole("heading", { name: title }))
11+
}

packages/app/src/app.tsx

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
onCleanup,
2626
type ParentProps,
2727
Show,
28-
Suspense,
2928
} from "solid-js"
3029
import { Dynamic } from "solid-js/web"
3130
import { CommandProvider } from "@/context/command"
@@ -44,6 +43,7 @@ import { PromptProvider } from "@/context/prompt"
4443
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
4544
import { SettingsProvider, useSettings } from "@/context/settings"
4645
import { TerminalProvider } from "@/context/terminal"
46+
import { TabsProvider } from "@/context/tabs"
4747
import DirectoryLayout from "@/pages/directory-layout"
4848
import Layout from "@/pages/layout"
4949
import { ErrorPage } from "./pages/error"
@@ -211,26 +211,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
211211
Effect.runPromise,
212212
),
213213
)
214+
const checking = createMemo(
215+
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
216+
)
214217

215218
return (
216-
<Suspense
219+
<Show
220+
when={!checking()}
217221
fallback={
218222
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
219223
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
220224
</div>
221225
}
222226
>
223-
{/*<Show
224-
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
225-
fallback={
226-
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
227-
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
228-
</div>
229-
}
230-
>*/}
231-
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
232227
<Show
233-
when={startupHealthCheck()}
228+
when={startupHealthCheck.latest}
234229
fallback={
235230
<ConnectionError
236231
onRetry={() => {
@@ -246,8 +241,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
246241
>
247242
{props.children}
248243
</Show>
249-
{/*</Show>*/}
250-
</Suspense>
244+
</Show>
251245
)
252246
}
253247

@@ -310,32 +304,41 @@ function ServerKey(props: ParentProps) {
310304
export function AppInterface(props: {
311305
children?: JSX.Element
312306
defaultServer: ServerConnection.Key
307+
canonicalLocalServer?: ServerConnection.Key
313308
servers?: Array<ServerConnection.Any>
314309
router?: Component<BaseRouterProps>
315310
disableHealthCheck?: boolean
316311
}) {
317312
return (
318-
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
319-
<GlobalProvider defaultServer={props.defaultServer} servers={props.servers}>
313+
<ServerProvider
314+
defaultServer={props.defaultServer}
315+
canonicalLocalServer={props.canonicalLocalServer}
316+
servers={props.servers}
317+
>
318+
<GlobalProvider>
320319
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
321-
<ServerKey>
322-
<QueryProvider>
323-
<ServerSDKProvider>
324-
<ServerSyncProvider>
325-
<Dynamic
326-
component={props.router ?? Router}
327-
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
328-
>
329-
<Route path="/" component={HomeRoute} />
330-
<Route path="/:dir" component={DirectoryLayout}>
331-
<Route path="/" component={() => <Navigate href="session" />} />
332-
<Route path="/session/:id?" component={SessionRoute} />
333-
</Route>
334-
</Dynamic>
335-
</ServerSyncProvider>
336-
</ServerSDKProvider>
337-
</QueryProvider>
338-
</ServerKey>
320+
<Dynamic
321+
component={props.router ?? Router}
322+
root={(routerProps) => (
323+
<TabsProvider>
324+
<ServerKey>
325+
<QueryProvider>
326+
<ServerSDKProvider>
327+
<ServerSyncProvider>
328+
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
329+
</ServerSyncProvider>
330+
</ServerSDKProvider>
331+
</QueryProvider>
332+
</ServerKey>
333+
</TabsProvider>
334+
)}
335+
>
336+
<Route path="/" component={HomeRoute} />
337+
<Route path="/:dir" component={DirectoryLayout}>
338+
<Route path="/" component={() => <Navigate href="session" />} />
339+
<Route path="/session/:id?" component={SessionRoute} />
340+
</Route>
341+
</Dynamic>
339342
</ConnectionGate>
340343
</GlobalProvider>
341344
</ServerProvider>

packages/app/src/components/dialog-edit-project.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
66
import { Icon } from "@opencode-ai/ui/icon"
77
import { createMemo, For, Show } from "solid-js"
88
import { createStore } from "solid-js/store"
9-
import { useServerSDK } from "@/context/server-sdk"
10-
import { useServerSync } from "@/context/server-sync"
119
import { type LocalProject, getAvatarColors } from "@/context/layout"
1210
import { getFilename } from "@opencode-ai/core/util/path"
1311
import { Avatar } from "@opencode-ai/ui/avatar"
1412
import { useLanguage } from "@/context/language"
1513
import { getProjectAvatarSource } from "@/pages/layout/helpers"
14+
import { ServerConnection } from "@/context/server"
15+
import { useGlobal } from "@/context/global"
1616

1717
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
1818

19-
export function DialogEditProject(props: { project: LocalProject }) {
19+
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
2020
const dialog = useDialog()
21-
const serverSDK = useServerSDK()
22-
const serverSync = useServerSync()
21+
const global = useGlobal()
2322
const language = useLanguage()
23+
const serverCtx = createMemo(() => global.createServerCtx(props.server))
24+
const serverSDK = () => serverCtx().sdk
25+
const serverSync = () => serverCtx().sync
2426

2527
const folderName = createMemo(() => getFilename(props.project.worktree))
2628
const defaultName = createMemo(() => props.project.name || folderName())
@@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
7880
const start = store.startup.trim()
7981

8082
if (props.project.id && props.project.id !== "global") {
81-
await serverSDK.client.project.update({
83+
await serverSDK().client.project.update({
8284
projectID: props.project.id,
8385
directory: props.project.worktree,
8486
name,
8587
icon: { color: store.color || "", override: store.iconOverride || "" },
8688
commands: { start },
8789
})
88-
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
90+
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
8991
dialog.close()
9092
return
9193
}
9294

93-
serverSync.project.meta(props.project.worktree, {
95+
serverSync().project.meta(props.project.worktree, {
9496
name,
9597
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
9698
commands: { start: start || undefined },

packages/app/src/components/dialog-select-server.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { usePlatform } from "@/context/platform"
1818
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
1919
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
2020
import { useSettings } from "@/context/settings"
21+
import { useTabs } from "@/context/tabs"
2122

2223
const DEFAULT_USERNAME = "opencode"
2324

@@ -191,6 +192,7 @@ export function DialogSelectServer() {
191192
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
192193
const navigate = useNavigate()
193194
const server = useServer()
195+
const tabs = useTabs()
194196
const global = useGlobal()
195197
const platform = usePlatform()
196198
const language = useLanguage()
@@ -311,12 +313,14 @@ export function useServerManagementController(options: { onSelect?: () => void }
311313
}))
312314

313315
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
316+
const originalKey = ServerConnection.key(original)
314317
const active = server.key
318+
tabs.removeServer(originalKey)
315319
const newConn = server.add(next)
316320
if (!newConn) return
317-
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
321+
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
318322
if (nextActive) server.setActive(nextActive)
319-
server.remove(ServerConnection.key(original))
323+
server.remove(originalKey)
320324
}
321325

322326
const items = createMemo(() => {
@@ -501,6 +505,7 @@ export function useServerManagementController(options: { onSelect?: () => void }
501505
})
502506

503507
async function handleRemove(url: ServerConnection.Key) {
508+
tabs.removeServer(url)
504509
server.remove(url)
505510
if ((await platform.getDefaultServer?.()) === url) {
506511
void platform.setDefaultServer?.(null)

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ beforeAll(async () => {
127127
mock.module("@/context/sdk", () => ({
128128
useSDK: () => {
129129
const sdk = {
130+
scope: "local",
130131
directory: "/repo/main",
131132
client: rootClient,
132133
url: "http://localhost:4096",

0 commit comments

Comments
 (0)