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
This commit is contained in:
@@ -53,6 +53,13 @@ async def get_user_sessions(
|
||||
repo = await session.get(GitRepository, instance.repository_id)
|
||||
project = await session.get(Project, instance.project_id)
|
||||
|
||||
workspace_name = None
|
||||
if instance.workspace_id:
|
||||
from src.models import Workspace as WorkspaceModel
|
||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||
if workspace:
|
||||
workspace_name = workspace.name
|
||||
|
||||
sessions.append(
|
||||
{
|
||||
"id": str(instance.id),
|
||||
@@ -64,6 +71,7 @@ async def get_user_sessions(
|
||||
"repository_id": str(instance.repository_id),
|
||||
"project_name": project.name if project else "unknown",
|
||||
"project_id": str(instance.project_id),
|
||||
"workspace_name": workspace_name,
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"clone_mode": instance.clone_mode,
|
||||
|
||||
@@ -29,6 +29,7 @@ from src.services.tool.instance_service import (
|
||||
create_tool_instance,
|
||||
delete_tool_instance,
|
||||
recreate_instance_tunnel,
|
||||
rename_tool_instance,
|
||||
restart_tool_instance,
|
||||
start_tool_instance,
|
||||
stop_tool_instance,
|
||||
@@ -153,6 +154,45 @@ async def get_instance(
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
|
||||
summary="Rename instance",
|
||||
description="Update the display name of a tool instance.",
|
||||
)
|
||||
async def rename_instance(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
display_name = data.get("display_name", "").strip()
|
||||
if not display_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="display_name is required",
|
||||
)
|
||||
|
||||
try:
|
||||
instance = await rename_tool_instance(
|
||||
session, user_id, project_id, repo_id, instance_id, display_name
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"name": instance.name,
|
||||
"display_name": instance.display_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
|
||||
summary="Start instance",
|
||||
|
||||
@@ -13,7 +13,8 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models import ConfigProfile, GitRepository, SSHKey, ToolInstance, ToolType
|
||||
from src.models import ConfigProfile, GitRepository, Project, SSHKey, ToolInstance, ToolType
|
||||
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
|
||||
from src.services.git.clone import check_dirty_state, clone_repository
|
||||
from src.services.config.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
@@ -935,8 +936,10 @@ async def create_tool_instance(
|
||||
if data.display_name:
|
||||
instance_display = data.display_name
|
||||
else:
|
||||
project = await session.get(Project, project_id)
|
||||
project_name = project.name if project else "Unknown"
|
||||
scope_name = workspace.name if workspace else repo.name
|
||||
auto_name = f"{scope_name} / {tool_type.display_name}"
|
||||
auto_name = f"{project_name} / {scope_name} / {tool_type.display_name}"
|
||||
|
||||
if workspace:
|
||||
count_query = (
|
||||
@@ -2161,3 +2164,25 @@ async def stop_tool_instance(
|
||||
message="Instance stopped",
|
||||
)
|
||||
return {"status": instance.status}
|
||||
|
||||
|
||||
async def rename_tool_instance(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
instance_id: uuid.UUID,
|
||||
display_name: str,
|
||||
) -> ToolInstance:
|
||||
"""Rename a tool instance (update display_name only)."""
|
||||
instance = await session.get(ToolInstance, instance_id)
|
||||
if instance is None or instance.repository_id != repo_id:
|
||||
raise ValueError("instance not found")
|
||||
if instance.owner_id != user_id:
|
||||
raise ValueError("not authorized")
|
||||
|
||||
instance.display_name = display_name.strip()
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Session {
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
workspace_name?: string | null;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
@@ -188,6 +189,19 @@ export async function checkInstanceHealth(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function renameInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
displayName: string,
|
||||
): Promise<{ id: string; name: string; display_name: string }> {
|
||||
const response = await apiClient.patch(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||
{ display_name: displayName },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function recreateInstanceTunnel(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
|
||||
@@ -47,13 +47,18 @@ const SessionItem = ({ session }: { session: Session }) => {
|
||||
? `/instances/${session.id}/terminal`
|
||||
: `/projects/${session.project_id}`;
|
||||
|
||||
const tooltipParts = [session.display_name, session.project_name];
|
||||
if (session.workspace_name) tooltipParts.push(session.workspace_name);
|
||||
else if (session.repository_name) tooltipParts.push(session.repository_name);
|
||||
tooltipParts.push(`(${session.status})`);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={`session-${session.id}`}
|
||||
rel="noreferrer"
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
title={tooltipParts.join(" ")}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SessionCardProps {
|
||||
onStop?: (session: Session) => void;
|
||||
onDelete?: (session: Session) => void;
|
||||
onRecreateTunnel?: (session: Session) => void;
|
||||
onRename?: (session: Session, newName: string) => void;
|
||||
isBusy?: boolean;
|
||||
tunnelHealth?: {
|
||||
healthy: boolean;
|
||||
@@ -43,12 +44,15 @@ export function SessionCard({
|
||||
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] || {
|
||||
@@ -103,7 +107,43 @@ export function SessionCard({
|
||||
<div className="session-card-content">
|
||||
<div className="session-card-header">
|
||||
<div className="session-card-title">
|
||||
<h4>{session.display_name}</h4>
|
||||
{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}
|
||||
@@ -118,10 +158,22 @@ export function SessionCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted session-card-meta">
|
||||
{session.tool_type_name}
|
||||
{session.project_name && ` · ${session.project_name}`}
|
||||
{session.repository_name && ` · ${session.repository_name}`}
|
||||
<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">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SessionListProps {
|
||||
onStop?: (session: Session) => void;
|
||||
onDelete?: (session: Session) => void;
|
||||
onRecreateTunnel?: (session: Session) => void;
|
||||
onRename?: (session: Session, newName: string) => void;
|
||||
actionBusyId?: string | null;
|
||||
tunnelHealth?: Record<string, InstanceHealth>;
|
||||
showGrouping?: boolean;
|
||||
@@ -28,6 +29,7 @@ export function SessionList({
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
onRename,
|
||||
actionBusyId = null,
|
||||
tunnelHealth = {},
|
||||
showGrouping = true,
|
||||
@@ -56,6 +58,7 @@ export function SessionList({
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
onRename={onRename}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
@@ -88,6 +91,7 @@ export function SessionList({
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
onRename={onRename}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
@@ -113,6 +117,7 @@ export function SessionList({
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
onRename={onRename}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteInstance,
|
||||
startInstance,
|
||||
recreateInstanceTunnel,
|
||||
renameInstance,
|
||||
} from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
|
||||
@@ -21,6 +22,7 @@ interface UseInstanceActionsReturn {
|
||||
handleDelete: (session: Session) => Promise<void>;
|
||||
handleForceDelete: (session: Session) => Promise<void>;
|
||||
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||
handleRename: (session: Session, newName: string) => Promise<void>;
|
||||
clearDirtyDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ export function useInstanceActions(
|
||||
url = `/projects/${session.project_id}`;
|
||||
}
|
||||
|
||||
document.title = `${session.display_name} — Headquarter`;
|
||||
const w = window.open(url, `session-${session.id}`);
|
||||
tabRefs.current.set(key, w);
|
||||
}, []);
|
||||
@@ -177,6 +180,27 @@ export function useInstanceActions(
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (session: Session, newName: string) => {
|
||||
if (!newName.trim()) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await renameInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
newName.trim(),
|
||||
);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const clearDirtyDelete = useCallback(() => {
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
@@ -192,6 +216,7 @@ export function useInstanceActions(
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
handleRename,
|
||||
clearDirtyDelete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,20 @@ export const useTerminalPage = () => {
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
|
||||
// Update document title based on active terminal session
|
||||
useEffect(() => {
|
||||
if (!instanceId) {
|
||||
document.title = "Terminal — Headquarter";
|
||||
return;
|
||||
}
|
||||
const active = sessions.find((s) => s.id === activeSessionId);
|
||||
const name = active?.name ?? `Instance ${instanceId.slice(0, 8)}`;
|
||||
document.title = `${name} — Terminal — Headquarter`;
|
||||
return () => {
|
||||
document.title = "Headquarter";
|
||||
};
|
||||
}, [instanceId, activeSessionId, sessions]);
|
||||
|
||||
// Sync refs with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -72,5 +72,15 @@ export function useWorkspaceFiles(
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { entries, content, currentPath, loading, error, refresh, loadFile, saveFile, navigateTo };
|
||||
return {
|
||||
entries,
|
||||
content,
|
||||
currentPath,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
loadFile,
|
||||
saveFile,
|
||||
navigateTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const SessionsPage = () => {
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
handleRename,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
@@ -124,6 +125,7 @@ export const SessionsPage = () => {
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
@@ -139,6 +141,7 @@ export const SessionsPage = () => {
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
onRename={handleRename}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
|
||||
@@ -150,8 +150,16 @@ function MobileTabBar({
|
||||
/* ─── Files Tab ─── */
|
||||
|
||||
function FilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const { entries, content, currentPath, loadFile, saveFile, loading, error, navigateTo } =
|
||||
useWorkspaceFiles(workspaceId);
|
||||
const {
|
||||
entries,
|
||||
content,
|
||||
currentPath,
|
||||
loadFile,
|
||||
saveFile,
|
||||
loading,
|
||||
error,
|
||||
navigateTo,
|
||||
} = useWorkspaceFiles(workspaceId);
|
||||
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState<string | null>(null);
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
/* Session card rename */
|
||||
.session-card-rename input {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-card-context {
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.session-card-context strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Mobile Sessions Page */
|
||||
@media (max-width: 767px) {
|
||||
.sessions-page {
|
||||
|
||||
Reference in New Issue
Block a user