Skip to content

Commit 84f60d9

Browse files
app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
1 parent cbf4b68 commit 84f60d9

11 files changed

Lines changed: 211 additions & 163 deletions

packages/app/e2e/actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
12
import { expect, type Locator, type Page } from "@playwright/test"
23
import fs from "node:fs/promises"
34
import os from "node:os"
@@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) {
361362
return next
362363
}
363364

365+
export async function resolveSlug(slug: string) {
366+
const directory = base64Decode(slug)
367+
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
368+
const resolved = await resolveDirectory(directory)
369+
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
370+
}
371+
372+
export async function waitDir(page: Page, directory: string) {
373+
const target = await resolveDirectory(directory)
374+
await expect
375+
.poll(
376+
async () => {
377+
const slug = slugFromUrl(page.url())
378+
if (!slug) return ""
379+
return resolveSlug(slug)
380+
.then((item) => item.directory)
381+
.catch(() => "")
382+
},
383+
{ timeout: 45_000 },
384+
)
385+
.toBe(target)
386+
return { directory: target, slug: base64Encode(target) }
387+
}
388+
364389
export function sessionIDFromUrl(url: string) {
365390
const match = /\/session\/([^/?#]+)/.exec(url)
366391
return match?.[1]

packages/app/e2e/projects/projects-switch.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { base64Decode } from "@opencode-ai/util/encode"
22
import type { Page } from "@playwright/test"
33
import { test, expect } from "../fixtures"
4-
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
4+
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions"
55
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
66
import { dirSlug, resolveDirectory } from "../utils"
77

@@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({
100100
await expect(btn).toBeVisible()
101101
await btn.click({ force: true })
102102

103-
// A new workspace can be discovered via a transient slug before the route and sidebar
104-
// settle to the canonical workspace path on Windows, so interact with either and assert
105-
// against the resolved workspace slug.
106103
await waitSlug(page)
107-
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
104+
await waitDir(page, space)
108105

109106
// Create a session by sending a prompt
110107
const prompt = page.locator(promptSelector)
@@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({
132129
await expect(rootButton).toBeVisible()
133130
await rootButton.click()
134131

132+
await waitDir(page, space)
135133
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
136134
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
137135
},

packages/app/e2e/projects/workspace-new-session.spec.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1-
import { base64Decode } from "@opencode-ai/util/encode"
21
import type { Page } from "@playwright/test"
32
import { test, expect } from "../fixtures"
4-
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
3+
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
54
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
65
import { createSdk } from "../utils"
76

8-
async function waitWorkspaceReady(page: Page, slug: string) {
7+
function item(space: { slug: string; raw: string }) {
8+
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
9+
}
10+
11+
function button(space: { slug: string; raw: string }) {
12+
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
13+
}
14+
15+
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
916
await openSidebar(page)
1017
await expect
1118
.poll(
1219
async () => {
13-
const item = page.locator(workspaceItemSelector(slug)).first()
20+
const row = page.locator(item(space)).first()
1421
try {
15-
await item.hover({ timeout: 500 })
22+
await row.hover({ timeout: 500 })
1623
return true
1724
} catch {
1825
return false
@@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
2734
await openSidebar(page)
2835
await page.getByRole("button", { name: "New workspace" }).first().click()
2936

30-
const slug = await waitSlug(page, [root, ...seen])
31-
const directory = base64Decode(slug)
32-
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
33-
return { slug, directory }
37+
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
38+
await waitDir(page, next.directory)
39+
return next
3440
}
3541

36-
async function openWorkspaceNewSession(page: Page, slug: string) {
37-
await waitWorkspaceReady(page, slug)
42+
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
43+
await waitWorkspaceReady(page, space)
3844

39-
const item = page.locator(workspaceItemSelector(slug)).first()
40-
await item.hover()
45+
const row = page.locator(item(space)).first()
46+
await row.hover()
4147

42-
const button = page.locator(workspaceNewSessionSelector(slug)).first()
43-
await expect(button).toBeVisible()
44-
await button.click({ force: true })
48+
const next = page.locator(button(space)).first()
49+
await expect(next).toBeVisible()
50+
await next.click({ force: true })
4551

46-
const next = await waitSlug(page)
47-
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
48-
return next
52+
return waitDir(page, space.directory)
4953
}
5054

51-
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
52-
const next = await openWorkspaceNewSession(page, slug)
55+
async function createSessionFromWorkspace(
56+
page: Page,
57+
space: { slug: string; raw: string; directory: string },
58+
text: string,
59+
) {
60+
const next = await openWorkspaceNewSession(page, space)
5361

5462
const prompt = page.locator(promptSelector)
5563
await expect(prompt).toBeVisible()
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
6068
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
6169
await prompt.press("Enter")
6270

63-
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
71+
await waitDir(page, next.directory)
6472
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
6573

6674
const sessionID = sessionIDFromUrl(page.url())
6775
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
68-
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
69-
return { sessionID, slug: next }
76+
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
77+
return { sessionID, slug: next.slug }
7078
}
7179

7280
async function sessionDirectory(directory: string, sessionID: string) {
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
8795

8896
const first = await createWorkspace(page, root, [])
8997
trackDirectory(first.directory)
90-
await waitWorkspaceReady(page, first.slug)
98+
await waitWorkspaceReady(page, first)
9199

92100
const second = await createWorkspace(page, root, [first.slug])
93101
trackDirectory(second.directory)
94-
await waitWorkspaceReady(page, second.slug)
102+
await waitWorkspaceReady(page, second)
95103

96104
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
97105
trackSession(firstSession.sessionID, first.directory)

packages/app/e2e/projects/workspaces.spec.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { base64Decode } from "@opencode-ai/util/encode"
21
import fs from "node:fs/promises"
32
import os from "node:os"
43
import path from "node:path"
@@ -13,8 +12,10 @@ import {
1312
confirmDialog,
1413
openSidebar,
1514
openWorkspaceMenu,
15+
resolveSlug,
1616
setWorkspacesEnabled,
1717
slugFromUrl,
18+
waitDir,
1819
waitSlug,
1920
} from "../actions"
2021
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
2728
await setWorkspacesEnabled(page, rootSlug, true)
2829

2930
await page.getByRole("button", { name: "New workspace" }).first().click()
30-
const slug = await waitSlug(page, [rootSlug])
31-
const dir = base64Decode(slug)
31+
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
32+
await waitDir(page, next.directory)
3233

3334
await openSidebar(page)
3435

3536
await expect
3637
.poll(
3738
async () => {
38-
const item = page.locator(workspaceItemSelector(slug)).first()
39+
const item = page.locator(workspaceItemSelector(next.slug)).first()
3940
try {
4041
await item.hover({ timeout: 500 })
4142
return true
@@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
4748
)
4849
.toBe(true)
4950

50-
return { rootSlug, slug, directory: dir }
51+
return { rootSlug, slug: next.slug, directory: next.directory }
5152
}
5253

5354
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => {
7980
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
8081

8182
await page.getByRole("button", { name: "New workspace" }).first().click()
82-
const workspaceSlug = await waitSlug(page, [slug])
83-
const workspaceDir = base64Decode(workspaceSlug)
83+
const next = await resolveSlug(await waitSlug(page, [slug]))
84+
await waitDir(page, next.directory)
8485

8586
await openSidebar(page)
8687

8788
await expect
8889
.poll(
8990
async () => {
90-
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
91+
const item = page.locator(workspaceItemSelector(next.slug)).first()
9192
try {
9293
await item.hover({ timeout: 500 })
9394
return true
@@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => {
99100
)
100101
.toBe(true)
101102

102-
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
103+
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
103104

104-
await cleanupTestProject(workspaceDir)
105+
await cleanupTestProject(next.directory)
105106
})
106107
})
107108

@@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
119120

120121
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
121122

122-
const activeDir = base64Decode(slugFromUrl(page.url()))
123+
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
123124
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
124125

125126
await openSidebar(page)
@@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
331332
for (const _ of [0, 1]) {
332333
const prev = slugFromUrl(page.url())
333334
await page.getByRole("button", { name: "New workspace" }).first().click()
334-
const slug = await waitSlug(page, [rootSlug, prev])
335-
const dir = base64Decode(slug)
336-
workspaces.push({ slug, directory: dir })
335+
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
336+
await waitDir(page, next.directory)
337+
workspaces.push(next)
337338

338339
await openSidebar(page)
339340
}

packages/app/e2e/session/session-model-persistence.spec.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { base64Decode } from "@opencode-ai/util/encode"
21
import type { Locator, Page } from "@playwright/test"
32
import { test, expect } from "../fixtures"
4-
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
3+
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
54
import {
65
promptAgentSelector,
76
promptModelSelector,
@@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
224223
await openSidebar(page)
225224
await page.getByRole("button", { name: "New workspace" }).first().click()
226225

227-
const slug = await waitSlug(page, [root, ...seen])
228-
const directory = base64Decode(slug)
229-
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
230-
return { slug, directory }
226+
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
227+
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
228+
return next
231229
}
232230

233231
async function waitWorkspace(page: Page, slug: string) {
@@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) {
257255
await expect(button).toBeVisible()
258256
await button.click({ force: true })
259257

260-
const next = await waitSlug(page)
261-
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
258+
const next = await resolveSlug(await waitSlug(page))
259+
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
262260
await expect(page.locator(promptSelector)).toBeVisible()
263261
return currentDir(page)
264262
}

packages/app/src/app.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,13 @@ import Layout from "@/pages/layout"
4646
import { ErrorPage } from "./pages/error"
4747
import { useCheckServerHealth } from "./utils/server-health"
4848

49-
const Home = lazy(() => import("@/pages/home"))
49+
const HomeRoute = lazy(() => import("@/pages/home"))
5050
const Session = lazy(() => import("@/pages/session"))
5151
const Loading = () => <div class="size-full" />
5252

53-
const HomeRoute = () => (
54-
<Suspense fallback={<Loading />}>
55-
<Home />
56-
</Suspense>
57-
)
58-
5953
const SessionRoute = () => (
6054
<SessionProviders>
61-
<Suspense fallback={<Loading />}>
62-
<Session />
63-
</Suspense>
55+
<Session />
6456
</SessionProviders>
6557
)
6658

@@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) {
124116
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
125117
return (
126118
<AppShellProviders>
127-
{props.appChildren}
128-
{props.children}
119+
<Suspense fallback={<Loading />}>
120+
{props.appChildren}
121+
{props.children}
122+
</Suspense>
129123
</AppShellProviders>
130124
)
131125
}

0 commit comments

Comments
 (0)