1d10283fc9
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
396 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|