diff --git a/app/src/components/RunDetail.tsx b/app/src/components/RunDetail.tsx
index c912a4a..23ba52d 100644
--- a/app/src/components/RunDetail.tsx
+++ b/app/src/components/RunDetail.tsx
@@ -1103,6 +1103,13 @@ export function RunDetail({ runId, routeBase, initialData, isReplay, source, onF
},
[navigate, routeBase, runId],
);
+ const openConversationTurn = useCallback((id: string) => {
+ if (routeBase === "/saved" && (id === runId || isEventSaved(id))) {
+ navigate(tracePath("/saved", id));
+ return;
+ }
+ navigate(runPath(id));
+ }, [navigate, routeBase, runId]);
// When another surface fires a span deep-link, open it in the span tree route.
useEffect(() => {
@@ -1389,7 +1396,7 @@ export function RunDetail({ runId, routeBase, initialData, isReplay, source, onF
{activeTab === "convo" && run.convo_id && (
source === "cloud"
?
- : navigate(runPath(id))} />
+ :
)}
diff --git a/app/tests-e2e/workshop-actions.spec.ts b/app/tests-e2e/workshop-actions.spec.ts
index 1b8f3cc..81316a7 100644
--- a/app/tests-e2e/workshop-actions.spec.ts
+++ b/app/tests-e2e/workshop-actions.spec.ts
@@ -13,6 +13,7 @@ import { readWorkshopRun, readWorkshopSpans } from "./helpers";
// is the source of truth — if it changes, update these numbers in lock
// step with that change.
const FIXTURE_RUN_ID = "00000000000000000000000000000001";
+const FIXTURE_CONVO_SIBLING_RUN_ID = "00000000000000000000000000000002";
const FIXTURE_EVENT_NAME = "code-agent";
const FIXTURE_SPAN_COUNT = 6;
@@ -30,6 +31,38 @@ async function clearWorkshop(workshopUrl: string) {
expect(res.ok, `POST /api/clear -> ${res.status}`).toBe(true);
}
+async function saveRun(workshopUrl: string, runId: string) {
+ const detailRes = await fetch(`${workshopUrl}/api/runs/detail/${encodeURIComponent(runId)}`);
+ expect(detailRes.ok, `GET /api/runs/detail/${runId} -> ${detailRes.status}`).toBe(true);
+ const detail = await detailRes.json() as {
+ run: {
+ id: string;
+ event_name?: string | null;
+ name?: string | null;
+ user_id?: string | null;
+ convo_id?: string | null;
+ started_at: number;
+ };
+ };
+ const run = detail.run;
+ const saveRes = await fetch(`${workshopUrl}/api/saved-runs/events/${encodeURIComponent(runId)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: run.id,
+ event_name: run.event_name ?? run.name ?? run.id.slice(0, 12),
+ user_id: run.user_id ?? null,
+ convo_id: run.convo_id ?? null,
+ timestamp: new Date(run.started_at).toISOString(),
+ user_input: "Fix the typo in README.md",
+ assistant_output: null,
+ saved_at: Date.now(),
+ source: "local",
+ }),
+ });
+ expect(saveRes.ok, `PUT /api/saved-runs/events/${runId} -> ${saveRes.status}`).toBe(true);
+}
+
// Each test gets a clean slate. Tests that need fixtures call seedFixtures
// themselves so it's obvious in the spec what data each one depends on.
test.beforeEach(async ({ workshop }) => {
@@ -191,6 +224,41 @@ test("workshop UI: switching between runs preserves each run's span tree", async
await expect(page.locator(`[data-span-row="${spansB[0].id}"]`)).toHaveCount(0);
});
+test("workshop UI: saved conversation open preserves saved/unsaved route targets", async ({ page, workshop }) => {
+ await seedFixtures(workshop.url);
+ await saveRun(workshop.url, FIXTURE_RUN_ID);
+
+ await page.goto(`${workshop.url}/saved/${FIXTURE_RUN_ID}/convo`);
+ await expect(page.getByRole("button", { name: /^convo$/i })).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText(/^conversation$/i)).toBeVisible({ timeout: 10_000 });
+
+ const openButtons = page.getByRole("button", { name: /^open →$/i });
+ await expect(openButtons).toHaveCount(2);
+
+ const openSavedTurn = openButtons.first();
+ await expect(openSavedTurn).toBeVisible({ timeout: 5_000 });
+ await openSavedTurn.click();
+ await expect(page).toHaveURL(new RegExp(`/saved/${FIXTURE_RUN_ID}(?:[/?#]|$)`));
+
+ await page.goto(`${workshop.url}/saved/${FIXTURE_RUN_ID}/convo`);
+ await expect(openButtons).toHaveCount(2);
+ await openButtons.nth(1).click();
+ await expect(page).toHaveURL(new RegExp(`/runs/${FIXTURE_CONVO_SIBLING_RUN_ID}(?:[/?#]|$)`));
+});
+
+test("workshop UI: saved conversation open keeps saved sibling turns in saved route", async ({ page, workshop }) => {
+ await seedFixtures(workshop.url);
+ await saveRun(workshop.url, FIXTURE_RUN_ID);
+ await saveRun(workshop.url, FIXTURE_CONVO_SIBLING_RUN_ID);
+
+ await page.goto(`${workshop.url}/saved/${FIXTURE_RUN_ID}/convo`);
+ const openButtons = page.getByRole("button", { name: /^open →$/i });
+ await expect(openButtons).toHaveCount(2);
+
+ await openButtons.nth(1).click();
+ await expect(page).toHaveURL(new RegExp(`/saved/${FIXTURE_CONVO_SIBLING_RUN_ID}(?:[/?#]|$)`));
+});
+
test("workshop UI: deleting a run via API removes it from the sidebar", async ({ page, workshop }) => {
await seedFixtures(workshop.url);