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:
Developer
2026-06-10 17:04:03 +00:00
parent 886be83af5
commit 1d10283fc9
13 changed files with 243 additions and 11 deletions
+8
View File
@@ -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,
+40
View File
@@ -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",
+27 -2
View File
@@ -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
+14
View File
@@ -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,
+6 -1
View File
@@ -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,
};
}
+14
View File
@@ -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) {
+11 -1
View File
@@ -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,
};
}
+3
View File
@@ -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}
/>
+10 -2
View File
@@ -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);
+23
View File
@@ -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 {