Files
headquarter/apps/web/src/hooks/use-instance-actions.ts
T
Developer 1d10283fc9 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
2026-06-10 17:04:03 +00:00

223 lines
5.2 KiB
TypeScript

import { useState, useCallback, useRef } from "react";
import {
stopInstance,
deleteInstance,
startInstance,
recreateInstanceTunnel,
renameInstance,
} from "../api/sessions";
import type { Session } from "../api/sessions";
interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>;
}
interface UseInstanceActionsReturn {
loadingSessionId: string | null;
dirtyDeleteSession: Session | null;
dirtyDeleteFiles: string[];
handleOpen: (session: Session) => void;
handleStart: (session: Session) => Promise<void>;
handleStop: (session: Session) => Promise<void>;
handleDelete: (session: Session) => Promise<void>;
handleForceDelete: (session: Session) => Promise<void>;
handleRecreateTunnel: (session: Session) => Promise<void>;
handleRename: (session: Session, newName: string) => Promise<void>;
clearDirtyDelete: () => void;
}
export function useInstanceActions(
options: UseInstanceActionsOptions,
): UseInstanceActionsReturn {
const { onRefresh } = options;
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
null,
);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const tabRefs = useRef<Map<string, Window | null>>(new Map());
const handleOpen = useCallback((session: Session) => {
const key = session.id;
const existing = tabRefs.current.get(key);
if (existing && !existing.closed) {
existing.focus();
return;
}
let url: string;
if (session.url) {
url = session.url;
} else if (session.tool_type_interfaces?.includes("terminal")) {
url = `/instances/${session.id}/terminal`;
} else {
url = `/projects/${session.project_id}`;
}
document.title = `${session.display_name} — Headquarter`;
const w = window.open(url, `session-${session.id}`);
tabRefs.current.set(key, w);
}, []);
const handleStart = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await startInstance(
session.project_id,
session.repository_id,
session.id,
);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await stopInstance(
session.project_id,
session.repository_id,
session.id,
);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(
session.project_id,
session.repository_id,
session.id,
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch (error) {
const axiosError = error as {
response?: {
status?: number;
data?: { detail?: { changed_files?: string[] } };
};
};
if (axiosError.response?.status === 409) {
const detail = axiosError.response.data?.detail;
if (detail?.changed_files) {
setDirtyDeleteSession(session);
setDirtyDeleteFiles(detail.changed_files);
return;
}
}
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(
session.project_id,
session.repository_id,
session.id,
true,
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id,
);
await onRefresh();
} catch (err) {
const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel";
alert(message);
} finally {
setLoadingSessionId(null);
}
},
[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([]);
}, []);
return {
loadingSessionId,
dirtyDeleteSession,
dirtyDeleteFiles,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleForceDelete,
handleRecreateTunnel,
handleRename,
clearDirtyDelete,
};
}