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
+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 {