Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions packages/app-ai-native/src/pages/multica/open-session-by-id.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { deriveWorkspaceName, findWorkspaceForDevice, openSessionById } from "./open-session-by-id"
import { deriveWorkspaceName, findWorkspaceForSession, openSessionById } from "./open-session-by-id"
import type { OpenSessionDeps } from "./open-session-by-id"
import type { Device, Workspace } from "@/pages/workspace/types"

Expand Down Expand Up @@ -30,6 +30,15 @@ function workspace(id: string, opts: Partial<Workspace> = {}): Workspace {
}
}

/** Shorthand for a workspace bound to a device with one registered directory. */
function workspaceWithDir(id: string, deviceUniqueId: string, path: string): Workspace {
return workspace(id, {
deviceUniqueId,
// Only `path` matters for directory matching; cast to keep the fixture small.
directories: [{ path } as any],
})
}

// Collects the effects of openSessionById for assertions.
function harness(over: Partial<OpenSessionDeps> = {}) {
const calls = {
Expand All @@ -56,20 +65,43 @@ function harness(over: Partial<OpenSessionDeps> = {}) {
return { calls, deps }
}

describe("findWorkspaceForDevice", () => {
test("matches on deviceUniqueId (routing id)", () => {
const ws = [workspace("w1", { deviceUniqueId: "dev-uniq" })]
expect(findWorkspaceForDevice(ws, device("db-1", "dev-uniq"))).toBe("w1")
describe("findWorkspaceForSession", () => {
test("matches the workspace whose directory contains the session dir", () => {
const ws = [workspaceWithDir("w1", "dev-uniq", "/p")]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir")).toBe("w1")
})

test("matches on deviceId (db id) when deviceUniqueId is absent", () => {
const ws = [workspace("w1", { deviceId: "db-1", directories: [{ path: "/p" } as any] })]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir")).toBe("w1")
})

test("prefers the most specific (longest) containing directory", () => {
const ws = [
workspaceWithDir("w-root", "dev-uniq", "/p"),
workspaceWithDir("w-nested", "dev-uniq", "/p/workdir"),
]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir/task")).toBe("w-nested")
})

test("does not match a same-device workspace with a different directory", () => {
const ws = [workspaceWithDir("w1", "dev-uniq", "/other/project")]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir")).toBeUndefined()
})

test("matches on deviceId (db id) as fallback", () => {
const ws = [workspace("w1", { deviceId: "db-1" })]
expect(findWorkspaceForDevice(ws, device("db-1", "dev-uniq"))).toBe("w1")
test("does not match a containing directory on another device", () => {
const ws = [workspaceWithDir("w1", "other-device", "/p")]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir")).toBeUndefined()
})

test("returns undefined when no workspace is on the device", () => {
const ws = [workspace("w1", { deviceUniqueId: "other" })]
expect(findWorkspaceForDevice(ws, device("db-1", "dev-uniq"))).toBeUndefined()
test("a path prefix without a separator boundary is not containment", () => {
const ws = [workspaceWithDir("w1", "dev-uniq", "/p/work")]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "/p/workdir")).toBeUndefined()
})

test("returns undefined for an empty session directory", () => {
const ws = [workspaceWithDir("w1", "dev-uniq", "/p")]
expect(findWorkspaceForSession(ws, device("db-1", "dev-uniq"), "")).toBeUndefined()
})
})

Expand All @@ -86,11 +118,11 @@ describe("deriveWorkspaceName", () => {
})

describe("openSessionById", () => {
test("navigates to an existing workspace on the owning device", async () => {
test("navigates to the workspace containing the session directory", async () => {
const { calls, deps } = harness({
listDevices: async () => [device("db-1", "dev-1"), device("db-2", "dev-2")],
probeSession: async (d) => (d.id === "db-2" ? { directory: "/p/workdir" } : null),
listWorkspaces: async () => [workspace("w2", { deviceUniqueId: "dev-2" })],
listWorkspaces: async () => [workspaceWithDir("w2", "dev-2", "/p")],
})
await openSessionById("sess-1", deps)
expect(calls.navigated).toEqual({ workspaceId: "w2", sessionId: "sess-1" })
Expand All @@ -113,6 +145,24 @@ describe("openSessionById", () => {
expect(calls.navigated).toEqual({ workspaceId: "ws-new", sessionId: "sess-1" })
})

test("creates a new workspace when the device only has mismatched-directory workspaces", async () => {
// Regression: reusing a same-device workspace that points at another
// directory rendered the session in the wrong project tree and dropped it
// from the directory-scoped session list (lost on refresh).
const { calls, deps } = harness({
listDevices: async () => [device("db-1", "dev-1")],
probeSession: async () => ({ directory: "/p/multica_workspaces/x/workdir" }),
listWorkspaces: async () => [workspaceWithDir("w-other", "dev-1", "/Users/x/project")],
})
await openSessionById("sess-1", deps)
expect(calls.created).toEqual({
name: "workdir",
deviceId: "db-1",
directory: "/p/multica_workspaces/x/workdir",
})
expect(calls.navigated).toEqual({ workspaceId: "ws-new", sessionId: "sess-1" })
})

test("skips offline devices when probing", async () => {
const probed: string[] = []
const { calls, deps } = harness({
Expand All @@ -121,7 +171,7 @@ describe("openSessionById", () => {
probed.push(d.id)
return d.id === "db-2" ? { directory: "/p/workdir" } : null
},
listWorkspaces: async () => [workspace("w2", { deviceUniqueId: "dev-2" })],
listWorkspaces: async () => [workspaceWithDir("w2", "dev-2", "/p")],
})
await openSessionById("sess-1", deps)
expect(probed).toEqual(["db-2"])
Expand All @@ -145,7 +195,7 @@ describe("openSessionById", () => {
if (d.id === "db-1") throw new Error("proxy down")
return { directory: "/p/workdir" }
},
listWorkspaces: async () => [workspace("w2", { deviceUniqueId: "dev-2" })],
listWorkspaces: async () => [workspaceWithDir("w2", "dev-2", "/p")],
})
await openSessionById("sess-1", deps)
expect(calls.navigated?.workspaceId).toBe("w2")
Expand Down
52 changes: 40 additions & 12 deletions packages/app-ai-native/src/pages/multica/open-session-by-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import type { Device, Workspace } from "@/pages/workspace/types"
* 1. enumerate the user's devices,
* 2. probe each ONLINE device for the session id (lookup is directory-
* independent on the device side),
* 3. once found, reuse an existing workspace on that device, or create one
* on demand for the session's directory,
* 3. once found, reuse the workspace on that device whose registered
* directories CONTAIN the session's directory — never a workspace that
* points elsewhere, or the session would render inside the wrong project
* tree and fall out of the directory-scoped session list — or create a
* workspace on demand for the session's directory,
* 4. navigate to /workspace/<id>?session=<sessionId>.
*
* All I/O is injected so the orchestration is unit-testable.
Expand Down Expand Up @@ -50,7 +53,7 @@ export async function openSessionById(sessionId: string, deps: OpenSessionDeps):
}

const workspaces = await deps.listWorkspaces()
const existing = findWorkspaceForDevice(workspaces, hit.device)
const existing = findWorkspaceForSession(workspaces, hit.device, hit.directory)
if (existing) {
deps.navigateToSession(existing, sessionId)
return
Expand All @@ -68,17 +71,42 @@ export async function openSessionById(sessionId: string, deps: OpenSessionDeps):
}

/**
* Find an existing workspace bound to the given device. Matches on the routing
* id (`Workspace.deviceUniqueId` === `Device.deviceId`) first, then the DB id
* (`Workspace.deviceId` === `Device.id`).
* Find the workspace that owns the session's directory. Only workspaces on the
* given device are considered, and a workspace matches only when one of its
* registered directories CONTAINS the session's working directory — reusing a
* workspace that points at a different directory would render the session
* inside the wrong project tree and drop it from the directory-scoped session
* list. When several nested directories match, the longest (most specific)
* path wins. Returns undefined when nothing contains the directory, so the
* caller creates a workspace for the session's own directory instead.
*/
export function findWorkspaceForDevice(workspaces: Workspace[], device: Device): string | undefined {
const match = workspaces.find(
(w) =>
export function findWorkspaceForSession(
workspaces: Workspace[],
device: Device,
directory: string,
): string | undefined {
const dir = normalizePath(directory)
if (!dir) return undefined
let best: { id: string; len: number } | undefined
for (const w of workspaces) {
const onDevice =
(!!w.deviceUniqueId && w.deviceUniqueId === device.deviceId) ||
(!!w.deviceId && w.deviceId === device.id),
)
return match?.id
(!!w.deviceId && w.deviceId === device.id)
if (!onDevice) continue
for (const d of w.directories ?? []) {
const p = normalizePath(d.path)
if (!p) continue
if (dir === p || dir.startsWith(p + "/") || dir.startsWith(p + "\\")) {
if (!best || p.length > best.len) best = { id: w.id, len: p.length }
}
}
}
return best?.id
}

/** Strip trailing slashes so directory containment checks compare cleanly. */
function normalizePath(p: string): string {
return (p ?? "").replace(/[/\\]+$/, "")
}

/** Last path segment of the session's directory, used to name an auto-created workspace. */
Expand Down
Loading