feat(web/ui): unify session opening and add repo card in project list

- Add openSession utility with tab deduplication/focus
- Use openSession in navbar live sessions, use-instance-actions, and project tool links
- Pass onAddRepository to ProjectListItem and add dashed 'Add Repository' card
- Style add-repo card in projects.css
This commit is contained in:
Developer
2026-06-16 21:31:38 +00:00
parent d5119f29f6
commit bf698b3ff2
6 changed files with 82 additions and 67 deletions
+5 -18
View File
@@ -2,6 +2,7 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme"; import { useTheme } from "../hooks/use-theme";
import { openSession } from "../utils/open-session";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions"; import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
@@ -33,19 +34,6 @@ const NAV_ITEMS: {
const SessionItem = ({ session }: { session: Session }) => { const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running"; const isRunning = session.status === "running";
// Determine the link target:
// - Web tools open their tunnel URL
// - Terminal tools open the terminal page
// - Everything else falls back to the project page
const hasTerminal = session.tool_type_interfaces.includes("terminal");
const hasWeb = session.tool_type_interfaces.includes("web");
const href =
session.url && hasWeb
? session.url
: hasTerminal
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
const contextName = session.workspace_name || session.repository_name; const contextName = session.workspace_name || session.repository_name;
const tooltipParts = [ const tooltipParts = [
session.display_name, session.display_name,
@@ -56,10 +44,9 @@ const SessionItem = ({ session }: { session: Session }) => {
tooltipParts.push(`(${session.status})`); tooltipParts.push(`(${session.status})`);
return ( return (
<a <button
href={href} type="button"
target={`session-${session.id}`} onClick={() => openSession(session)}
rel="noreferrer"
className="nav-item session-item" className="nav-item session-item"
title={tooltipParts.join(" · ")} title={tooltipParts.join(" · ")}
> >
@@ -76,7 +63,7 @@ const SessionItem = ({ session }: { session: Session }) => {
{session.tool_type_name} · {session.project_name} {session.tool_type_name} · {session.project_name}
</span> </span>
</span> </span>
</a> </button>
); );
}; };
@@ -5,6 +5,7 @@ import { Icon } from "../../icon";
import type { Session } from "../../../api/sessions"; import type { Session } from "../../../api/sessions";
import type { IconName } from "../../../utils/icons"; import type { IconName } from "../../../utils/icons";
import type { ProjectWithRepos, RepositorySummary } from "../../../types"; import type { ProjectWithRepos, RepositorySummary } from "../../../types";
import { openSession } from "../../../utils/open-session";
interface ProjectListItemProps { interface ProjectListItemProps {
project: ProjectWithRepos; project: ProjectWithRepos;
@@ -13,6 +14,7 @@ interface ProjectListItemProps {
onDelete: () => void; onDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onCancelDelete: () => void; onCancelDelete: () => void;
onAddRepository?: () => void;
sessions: Session[]; sessions: Session[];
} }
@@ -23,6 +25,7 @@ export const ProjectListItem = ({
onDelete, onDelete,
onConfirmDelete, onConfirmDelete,
onCancelDelete, onCancelDelete,
onAddRepository,
sessions, sessions,
}: ProjectListItemProps) => { }: ProjectListItemProps) => {
return ( return (
@@ -75,17 +78,23 @@ export const ProjectListItem = ({
</header> </header>
<div className="project-repo-list"> <div className="project-repo-list">
{project.repositories.length === 0 ? ( {project.repositories.map((repo) => (
<p className="muted">No repositories.</p> <ProjectRepoItem
) : ( key={repo.id}
project.repositories.map((repo) => ( repo={repo}
<ProjectRepoItem project={project}
key={repo.id} sessions={sessions}
repo={repo} />
project={project} ))}
sessions={sessions} {onAddRepository && (
/> <button
)) type="button"
className="project-repo-item project-add-repo-card"
onClick={onAddRepository}
>
<Icon name="add" size="md" />
<span className="project-repo-name">Add Repository</span>
</button>
)} )}
</div> </div>
</article> </article>
@@ -169,22 +178,10 @@ function ProjectRepoItem({
} }
function SessionToolLink({ session }: { session: Session }) { function SessionToolLink({ session }: { session: Session }) {
const hasTerminal = session.tool_type_interfaces.includes("terminal");
const hasWeb = session.tool_type_interfaces.includes("web");
const href =
session.url && hasWeb
? session.url
: hasTerminal
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
const isExternal = href.startsWith("http");
return ( return (
<a <button
href={href} type="button"
target={isExternal ? `session-${session.id}` : undefined} onClick={() => openSession(session)}
rel={isExternal ? "noreferrer" : undefined}
className="workspace-tool-link" className="workspace-tool-link"
title={`${session.display_name} (${session.status})`} title={`${session.display_name} (${session.status})`}
> >
@@ -195,6 +192,6 @@ function SessionToolLink({ session }: { session: Session }) {
<span className="tool-name"> <span className="tool-name">
{session.display_name || session.tool_type_name} {session.display_name || session.tool_type_name}
</span> </span>
</a> </button>
); );
} }
+3 -20
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback } from "react";
import { import {
stopInstance, stopInstance,
deleteInstance, deleteInstance,
@@ -8,6 +8,7 @@ import {
} from "../api/sessions"; } from "../api/sessions";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
import { useSessions } from "../state/sessions"; import { useSessions } from "../state/sessions";
import { openSession } from "../utils/open-session";
interface UseInstanceActionsOptions { interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
@@ -37,27 +38,9 @@ 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 key = session.id; openSession(session);
const existing = tabRefs.current.get(key);
if (existing && !existing.closed) {
existing.focus();
return;
}
let url: string;
if (session.url) {
url = session.url;
} else if (session.tool_type_interfaces?.includes("terminal")) {
url = `/instances/${session.id}/terminal`;
} else {
url = `/projects/${session.project_id}`;
}
const w = window.open(url, `session-${session.id}`);
tabRefs.current.set(key, w);
}, []); }, []);
const handleStart = useCallback( const handleStart = useCallback(
+4
View File
@@ -252,6 +252,10 @@ export const ProjectsPage = () => {
onDelete={() => setDeleteConfirmId(project.id)} onDelete={() => setDeleteConfirmId(project.id)}
onConfirmDelete={() => void handleDelete(project.id)} onConfirmDelete={() => void handleDelete(project.id)}
onCancelDelete={() => setDeleteConfirmId(null)} onCancelDelete={() => setDeleteConfirmId(null)}
onAddRepository={() => {
setSelectedProject(project);
setMobileView("create-repo");
}}
/> />
))} ))}
</div> </div>
+22 -2
View File
@@ -177,8 +177,28 @@
color: var(--danger); color: var(--danger);
} }
.project-add-repo { .project-add-repo-card {
display: none; display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
background: transparent;
color: var(--muted);
border-style: dashed;
cursor: pointer;
min-height: 120px;
}
.project-add-repo-card:hover {
background: var(--bg);
color: var(--ink);
border-color: var(--brand);
}
.project-add-repo-card .project-repo-name {
font-weight: 500;
white-space: normal;
text-align: center;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
+24
View File
@@ -0,0 +1,24 @@
import type { Session } from "../api/sessions";
const tabRefs = new Map<string, Window | null>();
export function openSession(session: Session): void {
const key = session.id;
const existing = tabRefs.get(key);
if (existing && !existing.closed) {
existing.focus();
return;
}
let url: string;
if (session.url) {
url = session.url;
} else if (session.tool_type_interfaces?.includes("terminal")) {
url = `/instances/${session.id}/terminal`;
} else {
url = `/projects/${session.project_id}`;
}
const w = window.open(url, `session-${session.id}`);
tabRefs.set(key, w);
}