Files
headquarter/apps/web/src/components/features/session/session-card.tsx
T
Developer 1d10283fc9 feat: improve session naming, project display, rename support, tab titles
Backend:
- sessions.py: include workspace_name in session response
- instance_service.py: auto-generate display names as
  'Project / Workspace / Tool #N' instead of 'Workspace / Tool #N'
- instance_service.py: add rename_tool_instance() service function
- tool_instances.py: add PATCH /instances/{id} endpoint for renaming
  display_name

Frontend:
- api/sessions.ts: add workspace_name to Session type, add renameInstance()
- use-instance-actions.ts: add handleRename, set document.title when opening
- session-card.tsx: click-to-edit display_name inline; always show project
  context line (Project / Workspace or Repo / Tool)
- session-list.tsx: pass through onRename prop
- SessionsPage.tsx: wire handleRename to SessionCard and SessionList
- app-shell.tsx: sidebar tooltip includes workspace or repo name
- use-terminal-page.ts: set document.title based on active terminal session

Quality gates: py_compile all backend files pass, tsc --noEmit pass,
npm run build pass, 82/82 tests pass
2026-06-10 17:04:03 +00:00

396 lines
10 KiB
TypeScript

import { useState } from "react";
import type { Session } from "../../../api/sessions";
import { Icon } from "../../icon";
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
import { MobileActionSheet } from "../mobile/mobile-action-sheet";
import type { IconName } from "../../icon";
export interface SessionCardProps {
session: Session;
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
onRename?: (session: Session, newName: string) => void;
isBusy?: boolean;
tunnelHealth?: {
healthy: boolean;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
} | null;
}
const statusConfig: Record<string, { color: string; label: string }> = {
running: { color: "running", label: "Running" },
building: { color: "pending", label: "Building" },
starting: { color: "starting", label: "Starting" },
probing: { color: "probing", label: "Probing" },
pending: { color: "pending", label: "Pending" },
stopped: { color: "stopped", label: "Stopped" },
error: { color: "error", label: "Error" },
unhealthy: { color: "unhealthy", label: "Unhealthy" },
};
export function SessionCard({
session,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
onRename,
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showActionSheet, setShowActionSheet] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(session.display_name);
const isMobile = useMobileViewport();
const status = statusConfig[session.status] || {
color: "gray",
label: session.status,
};
const isTerminalOnly =
session.tool_type_interfaces?.includes("terminal") &&
!session.tool_type_interfaces?.includes("web");
const hasTunnelError =
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError =
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
const handleStop = () => {
if (showStopConfirm) {
setShowStopConfirm(false);
onStop?.(session);
} else {
setShowStopConfirm(true);
}
};
const handleDelete = () => {
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete?.(session);
} else {
setShowDeleteConfirm(true);
}
};
const handleCancelStop = () => setShowStopConfirm(false);
const handleCancelDelete = () => setShowDeleteConfirm(false);
const isActive = [
"running",
"building",
"starting",
"probing",
"pending",
"unhealthy",
].includes(session.status);
return (
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
{isBusy && (
<div className="session-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
{isEditingName ? (
<div className="session-card-rename">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRename?.(session, editName);
setIsEditingName(false);
}
if (e.key === "Escape") {
setEditName(session.display_name);
setIsEditingName(false);
}
}}
onBlur={() => {
if (editName.trim() && editName !== session.display_name) {
onRename?.(session, editName);
}
setIsEditingName(false);
}}
autoFocus
/>
</div>
) : (
<h4
onClick={() => {
setEditName(session.display_name);
setIsEditingName(true);
}}
title="Click to rename"
style={{ cursor: "pointer" }}
>
{session.display_name}
</h4>
)}
<div className="session-card-status-badges">
<span className={`status-badge ${status.color}`}>
{status.label}
</span>
{hasTunnelError && (
<span className="status-badge error">Tunnel Error</span>
)}
{hasAppError && (
<span className="status-badge warning">
App Error {tunnelHealth?.tunnel_status_code}
</span>
)}
</div>
</div>
<p className="muted session-card-meta session-card-context">
<strong>{session.project_name}</strong>
{session.workspace_name && (
<>
{" "}
/ <span>{session.workspace_name}</span>
</>
)}
{!session.workspace_name && session.repository_name && (
<>
{" "}
/ <span>{session.repository_name}</span>
</>
)}
{" "}
· {session.tool_type_name}
</p>
{session.clone_mode && (
<p className="muted session-card-meta">
<Icon name="branch" size="sm" />
{session.clone_mode === "clone"
? `Clone${session.branch ? ` (${session.branch})` : ""}`
: "Mount"}
</p>
)}
{session.url && (
<p className="session-card-url">
<button
type="button"
className="link-button"
onClick={() => onOpen?.(session)}
>
{session.url}
</button>
</p>
)}
{session.created_at && (
<p className="muted session-card-meta">
Created: {new Date(session.created_at).toLocaleString()}
</p>
)}
</div>
</div>
{isMobile ? (
<div className="session-card-actions mobile">
{isActive && (
<>
<button
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
) : (
<div className="session-card-actions">
{isActive && (
<>
<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 && (
<button
className="ghost-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
title="Recreate Cloudflare tunnel"
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
)}
<MobileActionSheet
isOpen={showActionSheet}
onClose={() => setShowActionSheet(false)}
title={session.display_name}
actions={[
...(isActive && !isTerminalOnly && onRecreateTunnel
? [
{
id: "tunnel",
label: "Recreate Tunnel",
icon: "refresh" as IconName,
onClick: () => onRecreateTunnel(session),
},
]
: []),
...(isActive && onStop
? [
{
id: "stop",
label: "Stop",
icon: "stop" as IconName,
variant: "danger" as const,
onClick: () => onStop(session),
},
]
: []),
...(onDelete
? [
{
id: "delete",
label: "Delete",
icon: "delete" as IconName,
variant: "danger" as const,
onClick: () => onDelete(session),
},
]
: []),
]}
/>
</article>
);
}