Adam 1 mese fa
parent
commit
f2858a42ba

+ 106 - 3
packages/app/e2e/projects/projects-switch.spec.ts

@@ -1,7 +1,19 @@
+import { base64Decode } from "@opencode-ai/util/encode"
 import { test, expect } from "../fixtures"
-import { defocus, createTestProject, cleanupTestProject } from "../actions"
-import { projectSwitchSelector } from "../selectors"
-import { dirSlug } from "../utils"
+import {
+  defocus,
+  createTestProject,
+  cleanupTestProject,
+  openSidebar,
+  setWorkspacesEnabled,
+  sessionIDFromUrl,
+} from "../actions"
+import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
+import { createSdk, dirSlug } from "../utils"
+
+function slugFromUrl(url: string) {
+  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
 
 test("can switch between projects from sidebar", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
     await cleanupTestProject(other)
   }
 })
+
+test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const otherSlug = dirSlug(other)
+  const stamp = Date.now()
+  let rootDir: string | undefined
+  let workspaceDir: string | undefined
+  let sessionID: string | undefined
+
+  try {
+    await withProject(
+      async ({ directory, slug }) => {
+        rootDir = directory
+        await defocus(page)
+        await openSidebar(page)
+        await setWorkspacesEnabled(page, slug, true)
+
+        await page.getByRole("button", { name: "New workspace" }).first().click()
+
+        await expect
+          .poll(
+            () => {
+              const next = slugFromUrl(page.url())
+              if (!next) return ""
+              if (next === slug) return ""
+              return next
+            },
+            { timeout: 45_000 },
+          )
+          .not.toBe("")
+
+        const workspaceSlug = slugFromUrl(page.url())
+        workspaceDir = base64Decode(workspaceSlug)
+        await openSidebar(page)
+
+        const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
+        await expect(workspace).toBeVisible()
+        await workspace.hover()
+
+        const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
+        await expect(newSession).toBeVisible()
+        await newSession.click({ force: true })
+
+        await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
+
+        const prompt = page.locator(promptSelector)
+        await expect(prompt).toBeVisible()
+        await prompt.fill(`project switch remembers workspace ${stamp}`)
+        await prompt.press("Enter")
+
+        await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
+        const created = sessionIDFromUrl(page.url())
+        if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
+        sessionID = created
+        await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
+
+        await openSidebar(page)
+
+        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+        await expect(otherButton).toBeVisible()
+        await otherButton.click()
+        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+        const rootButton = page.locator(projectSwitchSelector(slug)).first()
+        await expect(rootButton).toBeVisible()
+        await rootButton.click()
+
+        await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
+      },
+      { extra: [other] },
+    )
+  } finally {
+    if (sessionID) {
+      const id = sessionID
+      const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
+      await Promise.all(
+        dirs.map((directory) =>
+          createSdk(directory)
+            .session.delete({ sessionID: id })
+            .catch(() => undefined),
+        ),
+      )
+    }
+    if (workspaceDir) {
+      await cleanupTestProject(workspaceDir)
+    }
+    await cleanupTestProject(other)
+  }
+})

+ 31 - 15
packages/app/src/pages/layout.tsx

@@ -61,7 +61,6 @@ import {
   displayName,
   errorMessage,
   getDraggableId,
-  projectSessionTarget,
   sortedRootSessions,
   syncWorkspaceOrder,
   workspaceKey,
@@ -82,8 +81,7 @@ export default function Layout(props: ParentProps) {
   const [store, setStore, , ready] = persisted(
     Persist.global("layout.page", ["layout.page.v1"]),
     createStore({
-      lastSession: {} as { [directory: string]: string },
-      lastSessionAt: {} as { [directory: string]: number },
+      lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
       activeProject: undefined as string | undefined,
       activeWorkspace: undefined as string | undefined,
       workspaceOrder: {} as Record<string, string[]>,
@@ -1076,19 +1074,37 @@ export default function Layout(props: ParentProps) {
     dialog.show(() => <DialogSettings />)
   }
 
-  function navigateToProject(directory: string | undefined) {
-    if (!directory) return
-    server.projects.touch(directory)
+  function projectRoot(directory: string) {
     const project = layout.projects
       .list()
       .find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
-    const target = projectSessionTarget({
-      directory,
-      project,
-      lastSession: store.lastSession,
-      lastSessionAt: store.lastSessionAt,
-    })
-    navigateWithSidebarReset(`/${base64Encode(target.directory)}${target.id ? `/session/${target.id}` : ""}`)
+    if (project) return project.worktree
+
+    const known = Object.entries(store.workspaceOrder).find(
+      ([root, dirs]) => root === directory || dirs.includes(directory),
+    )
+    if (known) return known[0]
+
+    const [child] = globalSync.child(directory, { bootstrap: false })
+    const id = child.project
+    if (!id) return directory
+
+    const meta = globalSync.data.project.find((item) => item.id === id)
+    return meta?.worktree ?? directory
+  }
+
+  function navigateToProject(directory: string | undefined) {
+    if (!directory) return
+    const root = projectRoot(directory)
+    server.projects.touch(root)
+
+    const projectSession = store.lastProjectSession[root]
+    if (projectSession?.id) {
+      navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
+      return
+    }
+
+    navigateWithSidebarReset(`/${base64Encode(root)}/session`)
   }
 
   function navigateToSession(session: Session | undefined) {
@@ -1442,8 +1458,8 @@ export default function Layout(props: ParentProps) {
         if (!dir || !id) return
         const directory = decode64(dir)
         if (!directory) return
-        setStore("lastSession", directory, id)
-        setStore("lastSessionAt", directory, Date.now())
+        const at = Date.now()
+        setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
         notification.session.markViewed(id)
         const expanded = untrack(() => store.workspaceExpanded[directory])
         if (expanded === false) {

+ 1 - 38
packages/app/src/pages/layout/helpers.test.ts

@@ -1,13 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
-import {
-  displayName,
-  errorMessage,
-  getDraggableId,
-  projectSessionTarget,
-  syncWorkspaceOrder,
-  workspaceKey,
-} from "./helpers"
+import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
 
 describe("layout deep links", () => {
   test("parses open-project deep links", () => {
@@ -96,34 +89,4 @@ describe("layout workspace helpers", () => {
     expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
     expect(errorMessage("unknown", "fallback")).toBe("fallback")
   })
-
-  test("picks newest session across project workspaces", () => {
-    const result = projectSessionTarget({
-      directory: "/root",
-      project: { worktree: "/root", sandboxes: ["/root/a", "/root/b"] },
-      lastSession: {
-        "/root": "root-session",
-        "/root/a": "sandbox-a",
-        "/root/b": "sandbox-b",
-      },
-      lastSessionAt: {
-        "/root": 1,
-        "/root/a": 3,
-        "/root/b": 2,
-      },
-    })
-
-    expect(result).toEqual({ directory: "/root/a", id: "sandbox-a", at: 3 })
-  })
-
-  test("falls back to project route when no session exists", () => {
-    const result = projectSessionTarget({
-      directory: "/root",
-      project: { worktree: "/root", sandboxes: ["/root/a"] },
-      lastSession: {},
-      lastSessionAt: {},
-    })
-
-    expect(result).toEqual({ directory: "/root" })
-  })
 })

+ 0 - 18
packages/app/src/pages/layout/helpers.ts

@@ -62,24 +62,6 @@ export const errorMessage = (err: unknown, fallback: string) => {
   return fallback
 }
 
-export function projectSessionTarget(input: {
-  directory: string
-  project?: { worktree: string; sandboxes?: string[] }
-  lastSession: Record<string, string>
-  lastSessionAt: Record<string, number>
-}): { directory: string; id?: string; at?: number } {
-  const dirs = input.project ? [input.project.worktree, ...(input.project.sandboxes ?? [])] : [input.directory]
-  const best = dirs.reduce<{ directory: string; id: string; at: number } | undefined>((result, directory) => {
-    const id = input.lastSession[directory]
-    if (!id) return result
-    const at = input.lastSessionAt[directory] ?? 0
-    if (result && result.at >= at) return result
-    return { directory, id, at }
-  }, undefined)
-  if (best) return best
-  return { directory: input.directory }
-}
-
 export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
   if (!existing) return dirs
   const keep = existing.filter((d) => d !== local && dirs.includes(d))