Skip to content
Open
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
183 changes: 183 additions & 0 deletions frontend/src/App.activity-back-navigation.browser.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";

import { mountBrowserApp, pressKey, resetKeyboardModuleState, type MountedBrowserApp } from "./test/browserAppHarness.js";
import { jsonResponse, mockSettings, type MockRouteOverride } from "./test/mockApiFetch.js";

const WAIT = 10_000;

const repo = {
provider: "github",
platform_host: "github.com",
owner: "acme",
name: "widgets",
repo_path: "acme/widgets",
capabilities: {},
};

const activityItems = [
{
id: "a1",
cursor: "a1",
activity_type: "comment",
author: "marius",
body_preview: "",
created_at: "2026-03-30T14:00:00Z",
item_number: 42,
item_state: "open",
item_title: "PR 42 title",
item_type: "pr",
item_url: "https://github.com/acme/widgets/pull/42",
repo,
},
{
id: "b1",
cursor: "b1",
activity_type: "comment",
author: "marius",
body_preview: "",
created_at: "2026-03-30T13:00:00Z",
item_number: 55,
item_state: "open",
item_title: "Issue 55 title",
item_type: "issue",
item_url: "https://github.com/acme/widgets/issues/55",
repo,
},
{
id: "c1",
cursor: "c1",
activity_type: "default_branch_commit",
author: "marius",
body_preview: "Bump dependency",
created_at: "2026-03-30T12:00:00Z",
item_number: 0,
item_state: "",
item_title: "",
item_type: "pr",
item_url: "https://github.com/acme/widgets/commit/abcdef1234567890",
branch_name: "main",
commit_sha: "abcdef1234567890",
repo,
},
];

function activityOverrides(): MockRouteOverride[] {
return [
(req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/settings") return null;
return jsonResponse({
...mockSettings,
activity: {
...mockSettings.activity,
view_mode: "flat",
collapse_threads: false,
},
});
},
(req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/activity") return null;
return jsonResponse({ capped: false, items: activityItems });
},
];
}

function activityRow(text: string): Element {
return Array.from(document.querySelectorAll(".activity-row")).find((row) =>
(row.textContent ?? "").includes(text),
)!;
}

async function openSelection(text: string): Promise<string> {
await vi.waitFor(() => expect(document.querySelector(".activity-row")).not.toBeNull(), WAIT);
const row = activityRow(text);
expect(row).not.toBeUndefined();
await page.elementLocator(row).click();
await vi.waitFor(() => expect(document.querySelector(".activity-detail")).not.toBeNull(), WAIT);
return window.location.pathname + window.location.search;
}

async function leaveAndRestore(destination: "/pulls" | "/issues"): Promise<void> {
const { navigate } = await import("./lib/stores/router.svelte.js");
navigate(destination);
await vi.waitFor(() => expect(document.querySelector(".activity-detail")).toBeNull(), WAIT);
window.history.back();
await vi.waitFor(() => expect(document.querySelector(".activity-detail")).not.toBeNull(), WAIT);
}

describe("Activity detail restoration after browser Back", () => {
vi.setConfig({ testTimeout: 30_000 });

let mounted: MountedBrowserApp | null = null;

beforeEach(async () => {
await page.viewport(1280, 900);
});

afterEach(async () => {
mounted?.unmount();
mounted = null;
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
await resetKeyboardModuleState();
});

it("restores the commit detail pane after leaving Activity and pressing Back", async () => {
mounted = await mountBrowserApp("/", { overrides: activityOverrides() });
const activityUrl = await openSelection("Bump dependency");

await leaveAndRestore("/pulls");

expect(document.querySelector(".commit-diff-panel")).not.toBeNull();
expect(document.querySelector(".activity-detail-header")?.textContent).toContain("acme/widgets");
expect(document.querySelector(".activity-detail-header")?.textContent).toContain("main");
expect(document.querySelector(".activity-detail-header")?.textContent).toContain("abcdef123456");
const selected = new URL(activityUrl, window.location.origin).searchParams;
expect(selected.get("selected")).toBe("commit:abcdef1234567890");
expect(selected.get("provider")).toBe("github");
expect(selected.get("platform_host")).toBe("github.com");
expect(selected.get("repo_path")).toBe("acme/widgets");
expect(selected.get("branch")).toBe("main");
});

it("restores a PR selection after leaving Activity and pressing Back", async () => {
mounted = await mountBrowserApp("/", { overrides: activityOverrides() });
await openSelection("PR 42 title");

await leaveAndRestore("/pulls");

expect(document.querySelector(".activity-detail")).not.toBeNull();
expect(document.querySelector(".activity-detail-header")?.textContent).toContain("acme/widgets#42");
});

it("restores an issue selection after leaving Activity and pressing Back", async () => {
mounted = await mountBrowserApp("/", { overrides: activityOverrides() });
await openSelection("Issue 55 title");

await leaveAndRestore("/issues");

expect(document.querySelector(".activity-detail")).not.toBeNull();
expect(document.querySelector(".activity-detail-header")?.textContent).toContain("acme/widgets#55");
});

it("Escape closes a restored commit pane", async () => {
mounted = await mountBrowserApp("/", { overrides: activityOverrides() });
await openSelection("Bump dependency");
await leaveAndRestore("/pulls");

pressKey("Escape");
await vi.waitFor(() => expect(document.querySelector(".activity-detail")).toBeNull(), WAIT);
expect(new URL(window.location.href).searchParams.has("selected")).toBe(false);
});

it("replaces a commit selection when an item row is selected", async () => {
mounted = await mountBrowserApp("/", { overrides: activityOverrides() });
await openSelection("Bump dependency");

await page.getByText("PR 42 title").click();
await vi.waitFor(() => expect(document.querySelector(".activity-detail-header")?.textContent).toContain("acme/widgets#42"), WAIT);
expect(new URL(window.location.href).searchParams.get("selected")).toBe("pr:42");
expect(document.querySelector(".commit-diff-panel")).toBeNull();
});
});
53 changes: 47 additions & 6 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@
import {
buildActivitySelectionSearch,
parseActivitySelection,
type ActivityCommitSelection,
type ActivityDetailTab,
type ActivitySelection,
} from "./lib/utils/activitySelection.js";
import { docsHref } from "./lib/api/docs/route.js";
import {
Expand Down Expand Up @@ -899,17 +901,23 @@
const route = getRoute();
const page = route.page;

if (page !== "activity") {
drawerItem = null;
} else if (!stores.settings.hasConfiguredRepos()) {
if (page !== "activity" || !stores.settings.hasConfiguredRepos()) {
drawerItem = null;
commitItem = null;
} else {
const nextDrawer = parseActivitySelection(
const nextSelection = parseActivitySelection(
window.location.search,
);
const nextDrawer =
nextSelection && nextSelection.itemType !== "commit" ? nextSelection : null;
const nextCommit =
nextSelection && nextSelection.itemType === "commit" ? nextSelection : null;
if (!sameActivitySelection(drawerItem, nextDrawer)) {
drawerItem = nextDrawer;
}
if (!sameActivityCommitSelection(commitItem, nextCommit)) {
commitItem = nextCommit;
}
}

if (route.page === "pulls") {
Expand Down Expand Up @@ -961,6 +969,9 @@
};

let drawerItem = $state<DrawerItem | null>(null);
// Owned here for the same reason drawerItem is: the Activity selection lives
// in the page's query string, and only this component writes it.
let commitItem = $state<ActivityCommitSelection | null>(null);

function sameActivitySelection(
left: DrawerItem | null,
Expand All @@ -978,8 +989,26 @@
&& left.detailTab === right.detailTab;
}

function sameActivityCommitSelection(
left: ActivityCommitSelection | null,
right: ActivityCommitSelection | null,
): boolean {
if (left === right) return true;
if (left === null || right === null) return false;
// `title` is display text the feed supplies and the URL does not carry, so
// it is deliberately not part of identity: comparing it would let a
// reparse downgrade a live commit subject to the short SHA.
return left.provider === right.provider
&& left.platformHost === right.platformHost
&& left.repoPath === right.repoPath
&& left.owner === right.owner
&& left.name === right.name
&& left.branchName === right.branchName
&& left.commitSha === right.commitSha;
}

function updateDrawerURL(
item: DrawerItem | null,
item: ActivitySelection | null,
): void {
if (getPage() !== "activity") return;
const sp = buildActivitySelectionSearch(
Expand Down Expand Up @@ -1017,6 +1046,7 @@
...selectedItem,
detailTab: "conversation",
};
commitItem = null;
updateDrawerURL(drawerItem);
}

Expand All @@ -1035,6 +1065,14 @@
updateDrawerURL(drawerItem);
}

function handleActivityCommitSelect(
item: ActivityCommitSelection,
): void {
drawerItem = null;
commitItem = item;
updateDrawerURL(commitItem);
}

function handleResponsiveStackMemberNavigate(
ref: PullRequestRouteRef,
): boolean | void {
Expand All @@ -1049,6 +1087,7 @@

function closeDrawer(): void {
drawerItem = null;
commitItem = null;
updateDrawerURL(null);
}

Expand Down Expand Up @@ -1076,7 +1115,7 @@
scope: "global",
binding: { key: "Escape" },
priority: 50,
when: (ctx) => ctx.page === "activity" && drawerItem !== null,
when: (ctx) => ctx.page === "activity" && (drawerItem !== null || commitItem !== null),
handler: () => closeDrawer(),
},
]);
Expand Down Expand Up @@ -1452,6 +1491,8 @@
detailTab={drawerItem?.detailTab ?? "conversation"}
onDetailTabChange={handleActivityDetailTabChange}
onDrawerItemChange={handleActivityDrawerItemChange}
{commitItem}
onSelectCommit={handleActivityCommitSelect}
inlineWorkspace={getInlineWorkspaceController("activity")}
{workspacePaneControls}
/>
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/lib/stores/keyboard/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,18 @@ describe("defaultActions", () => {
expect(locationPath()).toBe(
"/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fproject",
);

window.history.replaceState(
null,
"",
"/?selected=commit:abcdef1234567890&provider=github&platform_host=github.com&repo_path=acme%2Fwidgets&branch=main",
);
const commitContext = ctx("activity", { selectedPR: staleSelected });
expect(action.when(commitContext)).toBe(true);
action.handler(commitContext);
expect(locationPath()).toBe(
"/repo/browser?provider=github&platform_host=github.com&repo_path=acme%2Fwidgets",
);
});

it("opens the repo browser from the route-selected issue before stale issue store state", () => {
Expand Down
Loading