feat: reuse existing tabs when opening sessions instead of always creating new ones

Use named window targets (session-{id}, instance-{id}) instead of _blank:
- use-instance-actions.ts: window.open(..., 'session-{id}') for programmatic opens
- session-card.tsx: <a target='session-{id}'> for direct link opens (mobile + desktop)
- workspace-instance-chips.tsx: <a target='instance-{id}'> for chip links
- instance-list.tsx: <a target='instance-{id}'> for instance open links

Browser behavior: if a tab with that target name exists, it navigates/focuses
that tab instead of opening a new one.

Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
This commit is contained in:
Developer
2026-06-09 11:01:40 +00:00
parent 152f87a254
commit 486f3cbc44
5 changed files with 136 additions and 102 deletions
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab"; import { RepositoriesSettingsTab } from "./repositories-settings-tab";
@@ -6,119 +12,146 @@ import * as gitRepositoriesApi from "../../../api/git-repositories";
import * as sshKeysApi from "../../../api/ssh-keys"; import * as sshKeysApi from "../../../api/ssh-keys";
const mockRepositories = [ const mockRepositories = [
{ {
id: "repo-1", id: "repo-1",
name: "Main Repo", name: "Main Repo",
path: "/repos/main", path: "/repos/main",
project_id: "proj-1", project_id: "proj-1",
owner_id: "user-1", owner_id: "user-1",
is_mirror: false, is_mirror: false,
remote_url: null, remote_url: null,
last_push: null, last_push: null,
created_at: null, created_at: null,
}, },
]; ];
vi.mock("react-router-dom", async () => { vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom"); const actual =
return { await vi.importActual<typeof import("react-router-dom")>(
...actual, "react-router-dom",
useParams: () => ({ projectId: "proj-1" }), );
}; return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("RepositoriesSettingsTab", () => { describe("RepositoriesSettingsTab", () => {
it("opens create dialog and clones an existing repository", async () => { it("opens create dialog and clones an existing repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); const listMock = vi
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]); .spyOn(gitRepositoriesApi, "listRepositories")
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([ .mockResolvedValue(mockRepositories);
{ id: "key-1", name: "My Key", public_key: "ssh-ed25519 AAA...", created_at: "2024-01-01" }, const createMock = vi
]); .spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
{
id: "key-1",
name: "My Key",
public_key: "ssh-ed25519 AAA...",
created_at: "2024-01-01",
},
]);
render(<RepositoriesSettingsTab />); render(<RepositoriesSettingsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument(); expect(screen.getByText("Main Repo")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /add repository/i })); fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" }, target: { value: "New Repo" },
}); });
fireEvent.change(screen.getByPlaceholderText(/owner/i), { fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "alice" }, target: { value: "alice" },
}); });
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), { fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" }, target: { value: "demo" },
}); });
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i }); const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
await waitFor(() => expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy()); await waitFor(() =>
(sshSelect as HTMLSelectElement).value = "key-1"; expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
fireEvent.change(sshSelect); );
fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); (sshSelect as HTMLSelectElement).value = "key-1";
fireEvent.change(sshSelect);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => { await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", { expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo", name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git", remote_url: "git@git.commumedia.org:alice/demo.git",
ssh_key_id: "key-1", ssh_key_id: "key-1",
}); });
}); });
expect(listMock).toHaveBeenCalledTimes(2); expect(listMock).toHaveBeenCalledTimes(2);
}); });
it("uses advanced url fallback when requested", async () => { it("uses advanced url fallback when requested", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); const listMock = vi
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]); .spyOn(gitRepositoriesApi, "listRepositories")
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]); .mockResolvedValue(mockRepositories);
const createMock = vi
.spyOn(gitRepositoriesApi, "createRepository")
.mockResolvedValue(mockRepositories[0]);
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
render(<RepositoriesSettingsTab />); render(<RepositoriesSettingsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument(); expect(screen.getByText("Main Repo")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /add repository/i })); fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" }, target: { value: "New Repo" },
}); });
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i })); fireEvent.click(
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), { screen.getByRole("button", { name: /use full url instead/i }),
target: { value: "https://github.com/user/repo.git" }, );
}); fireEvent.change(
fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
{
target: { value: "https://github.com/user/repo.git" },
},
);
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => { await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", { expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo", name: "New Repo",
remote_url: "https://github.com/user/repo.git", remote_url: "https://github.com/user/repo.git",
}); });
}); });
expect(listMock).toHaveBeenCalledTimes(2); expect(listMock).toHaveBeenCalledTimes(2);
}); });
it("shows validation when cloning without a remote url", async () => { it("shows validation when cloning without a remote url", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories); vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
render(<RepositoriesSettingsTab />); mockRepositories,
);
render(<RepositoriesSettingsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument(); expect(screen.getByText("Main Repo")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /add repository/i })); fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), { fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" }, target: { value: "New Repo" },
}); });
fireEvent.change(screen.getByPlaceholderText(/owner/i), { fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "" }, target: { value: "" },
}); });
fireEvent.click(screen.getByRole("button", { name: /clone repository/i })); fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument(); expect(
}); screen.getByText(/owner and repository name are required/i),
).toBeInTheDocument();
});
}); });
@@ -138,7 +138,7 @@ export function SessionCard({
)} )}
{session.url && ( {session.url && (
<p className="session-card-url"> <p className="session-card-url">
<a href={session.url} target="_blank" rel="noopener noreferrer"> <a href={session.url} target={`session-${session.id}`} rel="noopener noreferrer">
{session.url} {session.url}
</a> </a>
</p> </p>
@@ -158,7 +158,7 @@ export function SessionCard({
{openHref ? ( {openHref ? (
<a <a
href={openHref} href={openHref}
target="_blank" target={`session-${session.id}`}
rel="noopener noreferrer" rel="noopener noreferrer"
className="secondary-button mobile-primary" className="secondary-button mobile-primary"
> >
@@ -215,7 +215,7 @@ export function SessionCard({
{openHref ? ( {openHref ? (
<a <a
href={openHref} href={openHref}
target="_blank" target={`session-${session.id}`}
rel="noopener noreferrer" rel="noopener noreferrer"
className="secondary-button small" className="secondary-button small"
> >
@@ -282,7 +282,7 @@ export const InstanceList = ({
? instance.url ? instance.url
: `${API_BASE_URL}${instance.url}` : `${API_BASE_URL}${instance.url}`
} }
target="_blank" target={`instance-${instance.id}`}
rel="noopener noreferrer" rel="noopener noreferrer"
className="secondary-button small" className="secondary-button small"
> >
@@ -43,7 +43,7 @@ export function WorkspaceInstanceChips({
{inst.status === "running" && inst.url && ( {inst.status === "running" && inst.url && (
<a <a
href={inst.url} href={inst.url}
target="_blank" target={`instance-${inst.id}`}
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
+4 -3
View File
@@ -35,15 +35,16 @@ export function useInstanceActions(
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const handleOpen = useCallback((session: Session) => { const handleOpen = useCallback((session: Session) => {
const tabName = `session-${session.id}`;
if (session.url) { if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer"); window.open(session.url, tabName, "noopener,noreferrer");
return; return;
} }
if (session.tool_type_interfaces?.includes("terminal")) { if (session.tool_type_interfaces?.includes("terminal")) {
window.open(`/instances/${session.id}/terminal`, "_blank", "noopener,noreferrer"); window.open(`/instances/${session.id}/terminal`, tabName, "noopener,noreferrer");
return; return;
} }
window.open(`/projects/${session.project_id}`, "_blank", "noopener,noreferrer"); window.open(`/projects/${session.project_id}`, tabName, "noopener,noreferrer");
}, []); }, []);
const handleStart = useCallback( const handleStart = useCallback(