fix: remove rel=noopener from all session/instance links to enable tab reuse
rel=noopener forces a fresh browsing context, breaking target name matching
and window reference tracking across browsers.
Changes:
- use-instance-actions.ts: track opened tabs in Map, call .focus() on existing
- session-card.tsx: all Open buttons now go through handleOpen callback
- app-shell.tsx: sidebar session links use target='session-{id}' + noreferrer
- workspace-instance-chips.tsx: remove noopener from chip links
- instance-list.tsx: remove noopener from Open links
- WorkspaceDetailPage.tsx: use named target + noreferrer
Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
This commit is contained in:
@@ -50,8 +50,8 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target={`session-${session.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -58,11 +58,6 @@ export function SessionCard({
|
|||||||
const isTerminalOnly =
|
const isTerminalOnly =
|
||||||
session.tool_type_interfaces?.includes("terminal") &&
|
session.tool_type_interfaces?.includes("terminal") &&
|
||||||
!session.tool_type_interfaces?.includes("web");
|
!session.tool_type_interfaces?.includes("web");
|
||||||
const openHref = session.url
|
|
||||||
? session.url
|
|
||||||
: isTerminalOnly
|
|
||||||
? `/instances/${session.id}/terminal`
|
|
||||||
: undefined;
|
|
||||||
const hasTunnelError =
|
const hasTunnelError =
|
||||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||||
const hasAppError =
|
const hasAppError =
|
||||||
@@ -138,13 +133,13 @@ export function SessionCard({
|
|||||||
)}
|
)}
|
||||||
{session.url && (
|
{session.url && (
|
||||||
<p className="session-card-url">
|
<p className="session-card-url">
|
||||||
<a
|
<button
|
||||||
href={session.url}
|
type="button"
|
||||||
target={`session-${session.id}`}
|
className="link-button"
|
||||||
rel="noopener noreferrer"
|
onClick={() => onOpen?.(session)}
|
||||||
>
|
>
|
||||||
{session.url}
|
{session.url}
|
||||||
</a>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{session.created_at && (
|
{session.created_at && (
|
||||||
@@ -159,27 +154,15 @@ export function SessionCard({
|
|||||||
<div className="session-card-actions mobile">
|
<div className="session-card-actions mobile">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
{openHref ? (
|
<button
|
||||||
<a
|
className="secondary-button mobile-primary"
|
||||||
href={openHref}
|
onClick={() => onOpen?.(session)}
|
||||||
target={`session-${session.id}`}
|
type="button"
|
||||||
rel="noopener noreferrer"
|
disabled={isBusy}
|
||||||
className="secondary-button mobile-primary"
|
>
|
||||||
>
|
<Icon name="external" size="sm" />
|
||||||
<Icon name="external" size="sm" />
|
Open
|
||||||
Open
|
</button>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button mobile-primary"
|
|
||||||
onClick={() => onOpen?.(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button mobile-more"
|
className="ghost-button mobile-more"
|
||||||
onClick={() => setShowActionSheet(true)}
|
onClick={() => setShowActionSheet(true)}
|
||||||
@@ -216,27 +199,15 @@ export function SessionCard({
|
|||||||
<div className="session-card-actions">
|
<div className="session-card-actions">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
{openHref ? (
|
<button
|
||||||
<a
|
className="secondary-button small"
|
||||||
href={openHref}
|
onClick={() => onOpen?.(session)}
|
||||||
target={`session-${session.id}`}
|
type="button"
|
||||||
rel="noopener noreferrer"
|
disabled={isBusy}
|
||||||
className="secondary-button small"
|
>
|
||||||
>
|
<Icon name="external" size="sm" />
|
||||||
<Icon name="external" size="sm" />
|
<span className="action-label">Open</span>
|
||||||
<span className="action-label">Open</span>
|
</button>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => onOpen?.(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="external" size="sm" />
|
|
||||||
<span className="action-label">Open</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isTerminalOnly && onRecreateTunnel && (
|
{!isTerminalOnly && onRecreateTunnel && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export const InstanceList = ({
|
|||||||
: `${API_BASE_URL}${instance.url}`
|
: `${API_BASE_URL}${instance.url}`
|
||||||
}
|
}
|
||||||
target={`instance-${instance.id}`}
|
target={`instance-${instance.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
>
|
>
|
||||||
<Icon name="external" size="sm" />
|
<Icon name="external" size="sm" />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function WorkspaceInstanceChips({
|
|||||||
<a
|
<a
|
||||||
href={inst.url}
|
href={inst.url}
|
||||||
target={`instance-${inst.id}`}
|
target={`instance-${inst.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
↗
|
↗
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
stopInstance,
|
stopInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
@@ -33,18 +33,27 @@ export function useInstanceActions(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
const tabRefs = useRef<Map<string, Window | null>>(new Map());
|
||||||
|
|
||||||
const handleOpen = useCallback((session: Session) => {
|
const handleOpen = useCallback((session: Session) => {
|
||||||
const tabName = `session-${session.id}`;
|
const key = session.id;
|
||||||
|
const existing = tabRefs.current.get(key);
|
||||||
|
if (existing && !existing.closed) {
|
||||||
|
existing.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string;
|
||||||
if (session.url) {
|
if (session.url) {
|
||||||
window.open(session.url, tabName);
|
url = session.url;
|
||||||
return;
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
url = `/instances/${session.id}/terminal`;
|
||||||
|
} else {
|
||||||
|
url = `/projects/${session.project_id}`;
|
||||||
}
|
}
|
||||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
window.open(`/instances/${session.id}/terminal`, tabName);
|
const w = window.open(url, `session-${session.id}`);
|
||||||
return;
|
tabRefs.current.set(key, w);
|
||||||
}
|
|
||||||
window.open(`/projects/${session.project_id}`, tabName);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStart = useCallback(
|
const handleStart = useCallback(
|
||||||
|
|||||||
@@ -343,8 +343,8 @@ function ToolsTab({ workspace }: { workspace: Workspace }) {
|
|||||||
{instance.url && (
|
{instance.url && (
|
||||||
<a
|
<a
|
||||||
href={instance.url}
|
href={instance.url}
|
||||||
target="_blank"
|
target={`instance-${instance.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user