Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test:events": "node scripts/events.mjs",
"test:todos": "node scripts/todos.mjs",
"test:permissions": "node scripts/permissions.mjs",
"test:session-scope": "bun scripts/session-scope.ts",
"test:session-switch": "node scripts/session-switch.mjs",
"test:fs-engine": "node scripts/fs-engine.mjs",
"test:local-file-path": "node scripts/local-file-path.mjs",
Expand Down
150 changes: 150 additions & 0 deletions apps/app/scripts/session-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import assert from "node:assert/strict";

Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
},
});

const {
resolveScopedClientDirectory,
scopedRootsMatch,
shouldApplyScopedSessionLoad,
shouldRedirectMissingSessionAfterScopedLoad,
toSessionTransportDirectory,
} = await import("../src/app/lib/session-scope.ts");

const starterRoot = "/Users/test/OpenWork/starter";
const otherRoot = "/Users/test/OpenWork/second";

const results = {
ok: true,
steps: [] as Array<Record<string, unknown>>,
};

async function step(name: string, fn: () => void | Promise<void>) {
results.steps.push({ name, status: "running" });
const index = results.steps.length - 1;

try {
await fn();
results.steps[index] = { name, status: "ok" };
} catch (error) {
results.ok = false;
results.steps[index] = {
name,
status: "error",
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}

try {
await step("local connect prefers explicit target root", () => {
assert.equal(
resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }),
starterRoot,
);
assert.equal(
resolveScopedClientDirectory({
workspaceType: "local",
directory: otherRoot,
targetRoot: starterRoot,
}),
otherRoot,
);
});

await step("remote connect still waits for remote discovery", () => {
assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), "");
});

await step("scope matching is stable on desktop-style paths", () => {
assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true);
assert.equal(scopedRootsMatch(starterRoot, otherRoot), false);
});

await step("stale session loads cannot overwrite another workspace sidebar", () => {
for (let index = 0; index < 50; index += 1) {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
}),
false,
);
}
});

await step("same-scope session loads still update the active workspace", () => {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: `${starterRoot}/`,
workspaceRoot: starterRoot,
}),
true,
);
});

await step("windows create and list use the same transport directory", () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
});

const winRoot = String.raw`C:\Users\Test\OpenWork\starter`;
const transport = toSessionTransportDirectory(winRoot);

assert.equal(transport, "C:/Users/Test/OpenWork/starter");
assert.equal(resolveScopedClientDirectory({ workspaceType: "local", targetRoot: winRoot }), transport);
assert.equal(resolveScopedClientDirectory({ workspaceType: "local", directory: winRoot }), transport);
});

await step("route guard only redirects when the loaded scope matches", () => {
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
false,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
true,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: true,
}),
false,
);
});

console.log(JSON.stringify(results, null, 2));
} catch (error) {
results.ok = false;
console.error(
JSON.stringify(
{
...results,
error: error instanceof Error ? error.message : String(error),
},
null,
2,
),
);
process.exitCode = 1;
}
37 changes: 33 additions & 4 deletions apps/app/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ import {
normalizeModelBehaviorValue,
sanitizeModelBehaviorValue,
} from "./lib/model-behavior";
import {
shouldApplyScopedSessionLoad,
shouldRedirectMissingSessionAfterScopedLoad,
toSessionTransportDirectory,
} from "./lib/session-scope";

const fileToDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
Expand Down Expand Up @@ -1499,6 +1504,7 @@ export default function App() {

const {
sessions,
loadedScopeRoot: loadedSessionScopeRoot,
sessionById,
sessionStatusById,
selectedSession,
Expand Down Expand Up @@ -2197,7 +2203,8 @@ export default function App() {
}

const root = workspaceStore.activeWorkspaceRoot().trim();
const params = root ? { sessionID: trimmed, directory: root } : { sessionID: trimmed };
const directory = toSessionTransportDirectory(root);
const params = directory ? { sessionID: trimmed, directory } : { sessionID: trimmed };
unwrap(await c.session.delete(params));

// Remove the deleted session from the store and sidebar locally.
Expand Down Expand Up @@ -3096,7 +3103,7 @@ export default function App() {
if (workspace.workspaceType === "local") {
const info = workspaceStore.engine();
const baseUrl = info?.baseUrl?.trim() ?? "";
const directory = workspace.path?.trim() ?? "";
const directory = toSessionTransportDirectory(workspace.path?.trim() ?? "");
const username = info?.opencodeUsername?.trim() ?? "";
const password = info?.opencodePassword?.trim() ?? "";
const auth: OpencodeAuth | undefined = username && password ? { username, password } : undefined;
Expand Down Expand Up @@ -3331,6 +3338,21 @@ export default function App() {
? activeWorkspace.path
: activeWorkspace?.directory ?? activeWorkspace?.path,
);
if (
!shouldApplyScopedSessionLoad({
loadedScopeRoot: loadedSessionScopeRoot(),
workspaceRoot: activeWorkspaceRoot,
})
) {
if (developerMode()) {
console.log("[sidebar-sync] skip stale session scope", {
wsId,
loadedScopeRoot: loadedSessionScopeRoot(),
activeWorkspaceRoot,
});
}
return;
}
const scopedSessions = activeWorkspaceRoot
? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot)
: allSessions;
Expand Down Expand Up @@ -6189,7 +6211,7 @@ export default function App() {
try {
mark("session:create:start");
rawResult = await c.session.create({
directory: workspaceStore.activeWorkspaceRoot().trim(),
directory: toSessionTransportDirectory(workspaceStore.activeWorkspaceRoot().trim()) || undefined,
});
mark("session:create:ok");
} catch (createErr) {
Expand Down Expand Up @@ -7774,7 +7796,14 @@ export default function App() {

// If the URL points at a session that no longer exists (e.g. after deletion),
// route back to /session so the app can fall back safely.
if (sessionsLoaded() && !sessions().some((session) => session.id === id)) {
if (
sessionsLoaded() &&
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: loadedSessionScopeRoot(),
workspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
hasMatchingSession: sessions().some((session) => session.id === id),
})
) {
if (selectedSessionId() === id) {
setSelectedSessionId(null);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/app/context/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export function createSessionStore(options: {
const [messageLimitBySession, setMessageLimitBySession] = createSignal<Record<string, number>>({});
const [messageCompleteBySession, setMessageCompleteBySession] = createSignal<Record<string, boolean>>({});
const [messageLoadBusyBySession, setMessageLoadBusyBySession] = createSignal<Record<string, boolean>>({});
const [loadedScopeRoot, setLoadedScopeRoot] = createSignal("");
const reloadDetectionSet = new Set<string>();
const invalidToolDetectionSet = new Set<string>();
const syntheticContinueEventTimesBySession = new Map<string, number[]>();
Expand Down Expand Up @@ -819,6 +820,7 @@ export function createSessionStore(options: {
})),
});
sessionDebug("sessions:load:filtered", { root: root || null, count: filtered.length });
setLoadedScopeRoot(root);
rememberSessions(filtered);
setStore("sessions", reconcile(sortSessionsByActivity(filtered), { key: "id" }));
}
Expand Down Expand Up @@ -1727,6 +1729,7 @@ export function createSessionStore(options: {

return {
sessions,
loadedScopeRoot,
sessionById,
sessionErrorTurnsById: (sessionID: string | null) => (sessionID ? store.sessionErrorTurns[sessionID] ?? [] : []),
selectedSessionErrorTurns: createMemo(() => {
Expand Down
Loading