Compare commits

...
1 Commits
Author SHA1 Message Date
Hona 25429a9373 fix(app): retain draft while loading the first session 2026-09-04 21:48:01 +00:00
3 changed files with 131 additions and 14 deletions
@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
const directory = "/fixture/first-prompt"
const projectID = "proj_first_prompt"
const text = "Keep the composer visible while opening this session."
test.use({ serviceWorkers: "block" })
for (const input of ["keyboard", "pointer"] as const) {
test(`retains the draft during a cold first prompt from ${input}`, async ({ page }, testInfo) => {
const sessions: ReturnType<typeof currentSession>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
await mockOpenCodeServer(page, {
directory,
project: { id: projectID, worktree: directory, name: "first-prompt", vcs: "git", sandboxes: [] },
provider: {
all: [{ id: "opencode", name: "OpenCode", models: { fixture: { id: "fixture", name: "Fixture Model" } } }],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "fixture" },
},
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (prompt) => prompts.push(prompt),
})
await page.route("**/api/session", (route) => {
if (route.request().method() !== "POST") return route.fallback()
const session = currentSession({ ...route.request().postDataJSON(), projectID, title: "First prompt" }, directory)
sessions.push(session)
return route.fulfill({ json: { data: session } })
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, directory)
// The session route awaits the file viewer chunk. Keep that real import pending
// until the test has inspected the draft; do not add timing-dependent sleeps.
const requested = Promise.withResolvers<void>()
const loaded = Promise.withResolvers<void>()
await page.route(
/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/,
async (route) => {
requested.resolve()
await loaded.promise
await route.continue()
},
)
await page.goto("/")
await page.locator('[data-action="home-new-session"]').click()
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(editor).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toHaveText("Fixture Model")
await editor.fill(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const observation = await page.evaluateHandle(() => {
const frames: { visible: boolean; time: number }[] = []
const start = performance.now()
let frame = 0
const sample = () => {
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
frames.push({
visible: !!editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
time: performance.now() - start,
})
frame = requestAnimationFrame(sample)
}
sample()
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
try {
if (input === "keyboard") await editor.press("Enter")
if (input === "pointer") await page.locator('[data-action="composer-submit"]').click()
await requested.promise
await expect(editor).toBeVisible()
await expect(editor).toHaveText(text)
await testInfo.attach("loading-session", { body: await page.screenshot(), contentType: "image/png" })
} finally {
loaded.resolve()
}
await expect(page).toHaveURL(/\/session\/ses_/)
await expect.poll(() => prompts).toEqual([{ sessionID: sessions[0]!.id, body: expect.objectContaining({ text }) }])
await expect(editor).toHaveText("")
await expect(editor).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toHaveText("Fixture Model")
const frames = await observation.evaluate((observation) => observation.stop())
await observation.dispose()
await testInfo.attach("composer-frames", { body: JSON.stringify(frames), contentType: "application/json" })
expect(frames.length).toBeGreaterThan(0)
expect(
frames.filter((frame) => !frame.visible),
"composer must remain visible through the route handoff",
).toEqual([])
await page.keyboard.type("Follow-up")
await expect(editor).toHaveText("Follow-up")
await expect(editor).toBeFocused()
})
}
+5 -13
View File
@@ -1,10 +1,10 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { createMemo, lazy, Show, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
@@ -36,17 +36,9 @@ export function AppRoutes() {
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteFrame>
)}
/>
+15 -1
View File
@@ -8,6 +8,8 @@ import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { useCurrentRoute } from "@/shell/state/layout"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -15,6 +17,7 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform()
const settings = useSettingsSurface()
const preferences = useSettings()
const route = useCurrentRoute()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
@@ -91,7 +94,18 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="flex size-full min-h-0 min-w-0 flex-col">
<Suspense>{props.children}</Suspense>
{/* Retain the previous page during navigation; only show the empty shell on initial load. */}
<Suspense
fallback={
<Show when={route().type === "session"}>
<SessionRouteFrame padded>
<SessionPanelFrame raised />
</SessionRouteFrame>
</Show>
}
>
{props.children}
</Suspense>
</div>
</main>
</div>