diff --git a/apps/api/src/api/tool/sessions.py b/apps/api/src/api/tool/sessions.py index d25ceb5..280d741 100644 --- a/apps/api/src/api/tool/sessions.py +++ b/apps/api/src/api/tool/sessions.py @@ -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, diff --git a/apps/api/src/api/tool/tool_instances.py b/apps/api/src/api/tool/tool_instances.py index f4d57fa..40f26dc 100644 --- a/apps/api/src/api/tool/tool_instances.py +++ b/apps/api/src/api/tool/tool_instances.py @@ -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", diff --git a/apps/api/src/services/tool/instance_service.py b/apps/api/src/services/tool/instance_service.py index 25010b4..344a105 100644 --- a/apps/api/src/services/tool/instance_service.py +++ b/apps/api/src/services/tool/instance_service.py @@ -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 + diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 751bf6e..2957168 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -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, diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 283b017..680fe51 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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 ( diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index 68cfb25..e6c9e4d 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -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({
-

{session.display_name}

+ {isEditingName ? ( +
+ 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 + /> +
+ ) : ( +

{ + setEditName(session.display_name); + setIsEditingName(true); + }} + title="Click to rename" + style={{ cursor: "pointer" }} + > + {session.display_name} +

+ )}
{status.label} @@ -118,10 +158,22 @@ export function SessionCard({ )}
-

- {session.tool_type_name} - {session.project_name && ` · ${session.project_name}`} - {session.repository_name && ` · ${session.repository_name}`} +

+ {session.project_name} + {session.workspace_name && ( + <> + {" "} + / {session.workspace_name} + + )} + {!session.workspace_name && session.repository_name && ( + <> + {" "} + / {session.repository_name} + + )} + {" "} + · {session.tool_type_name}

{session.clone_mode && (

diff --git a/apps/web/src/components/features/session/session-list.tsx b/apps/web/src/components/features/session/session-list.tsx index 2164092..04a7fd4 100644 --- a/apps/web/src/components/features/session/session-list.tsx +++ b/apps/web/src/components/features/session/session-list.tsx @@ -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; 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} /> diff --git a/apps/web/src/hooks/use-instance-actions.ts b/apps/web/src/hooks/use-instance-actions.ts index 3407954..9c9459d 100644 --- a/apps/web/src/hooks/use-instance-actions.ts +++ b/apps/web/src/hooks/use-instance-actions.ts @@ -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; handleForceDelete: (session: Session) => Promise; handleRecreateTunnel: (session: Session) => Promise; + handleRename: (session: Session, newName: string) => Promise; 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, }; } diff --git a/apps/web/src/hooks/use-terminal-page.ts b/apps/web/src/hooks/use-terminal-page.ts index b6a8414..10fe534 100644 --- a/apps/web/src/hooks/use-terminal-page.ts +++ b/apps/web/src/hooks/use-terminal-page.ts @@ -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) { diff --git a/apps/web/src/hooks/use-workspace-files.ts b/apps/web/src/hooks/use-workspace-files.ts index 3201a28..2d0e01b 100644 --- a/apps/web/src/hooks/use-workspace-files.ts +++ b/apps/web/src/hooks/use-workspace-files.ts @@ -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, + }; } diff --git a/apps/web/src/pages/SessionsPage.tsx b/apps/web/src/pages/SessionsPage.tsx index 6c27fbe..c6ac630 100644 --- a/apps/web/src/pages/SessionsPage.tsx +++ b/apps/web/src/pages/SessionsPage.tsx @@ -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} /> diff --git a/apps/web/src/pages/WorkspaceDetailPage.tsx b/apps/web/src/pages/WorkspaceDetailPage.tsx index 1ad23e0..be3cb49 100644 --- a/apps/web/src/pages/WorkspaceDetailPage.tsx +++ b/apps/web/src/pages/WorkspaceDetailPage.tsx @@ -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(null); const [editContent, setEditContent] = useState(null); diff --git a/apps/web/src/styles/pages/sessions.css b/apps/web/src/styles/pages/sessions.css index 6075e59..11881dc 100644 --- a/apps/web/src/styles/pages/sessions.css +++ b/apps/web/src/styles/pages/sessions.css @@ -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 {