diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 680fe51..6b258d1 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -41,11 +41,12 @@ const SessionItem = ({ session }: { session: Session }) => { // - Everything else falls back to the project page const hasTerminal = session.tool_type_interfaces.includes("terminal"); const hasWeb = session.tool_type_interfaces.includes("web"); - const href = session.url && hasWeb - ? session.url - : hasTerminal - ? `/instances/${session.id}/terminal` - : `/projects/${session.project_id}`; + const href = + session.url && hasWeb + ? session.url + : hasTerminal + ? `/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); diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index e6c9e4d..de59f10 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -171,8 +171,7 @@ export function SessionCard({ {" "} / {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 04a7fd4..7776c5d 100644 --- a/apps/web/src/components/features/session/session-list.tsx +++ b/apps/web/src/components/features/session/session-list.tsx @@ -3,128 +3,137 @@ import { SessionCard } from "./session-card"; import type { InstanceHealth } from "../../../api/sessions"; export interface SessionListProps { - sessions: Session[]; - onOpen?: (session: Session) => void; - onStart?: (session: Session) => void; - 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; - activeTitle?: string; - recentTitle?: string; - maxRecent?: number; - emptyMessage?: string; + sessions: Session[]; + onOpen?: (session: Session) => void; + onStart?: (session: Session) => void; + 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; + activeTitle?: string; + recentTitle?: string; + maxRecent?: number; + emptyMessage?: string; } -const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"]; +const activeStatuses = [ + "running", + "building", + "starting", + "probing", + "pending", + "unhealthy", +]; const recentStatuses = ["stopped", "error"]; export function SessionList({ - sessions, - onOpen, - onStart, - onStop, - onDelete, - onRecreateTunnel, - onRename, - actionBusyId = null, - tunnelHealth = {}, - showGrouping = true, - activeTitle = "Active Sessions", - recentTitle = "Recent Sessions", - maxRecent = 5, - emptyMessage = "No sessions", + sessions, + onOpen, + onStart, + onStop, + onDelete, + onRecreateTunnel, + onRename, + actionBusyId = null, + tunnelHealth = {}, + showGrouping = true, + activeTitle = "Active Sessions", + recentTitle = "Recent Sessions", + maxRecent = 5, + emptyMessage = "No sessions", }: SessionListProps) { - const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status)); - const recentSessions = sessions - .filter((s) => recentStatuses.includes(s.status)) - .slice(0, maxRecent); + const activeSessions = sessions.filter((s) => + activeStatuses.includes(s.status), + ); + const recentSessions = sessions + .filter((s) => recentStatuses.includes(s.status)) + .slice(0, maxRecent); - if (!showGrouping) { - return ( -
- {sessions.length === 0 ? ( -

{emptyMessage}

- ) : ( - sessions.map((session) => ( - - )) - )} -
- ); - } + if (!showGrouping) { + return ( +
+ {sessions.length === 0 ? ( +

{emptyMessage}

+ ) : ( + sessions.map((session) => ( + + )) + )} +
+ ); + } - return ( -
- {/* Active Sessions */} -
-
-

{activeTitle}

- {activeSessions.length > 0 && ( - {activeSessions.length} - )} -
- {activeSessions.length === 0 ? ( -

No active sessions

- ) : ( -
- {activeSessions.map((session) => ( - - ))} -
- )} -
+ return ( +
+ {/* Active Sessions */} +
+
+

{activeTitle}

+ {activeSessions.length > 0 && ( + {activeSessions.length} + )} +
+ {activeSessions.length === 0 ? ( +

No active sessions

+ ) : ( +
+ {activeSessions.map((session) => ( + + ))} +
+ )} +
- {/* Recent Sessions */} - {recentSessions.length > 0 && ( -
-
-

{recentTitle}

- {recentSessions.length} -
-
- {recentSessions.map((session) => ( - - ))} -
-
- )} -
- ); + {/* Recent Sessions */} + {recentSessions.length > 0 && ( +
+
+

{recentTitle}

+ {recentSessions.length} +
+
+ {recentSessions.map((session) => ( + + ))} +
+
+ )} +
+ ); } diff --git a/apps/web/src/components/features/tool/tool-starter.tsx b/apps/web/src/components/features/tool/tool-starter.tsx index d5b0b4c..bda85ba 100644 --- a/apps/web/src/components/features/tool/tool-starter.tsx +++ b/apps/web/src/components/features/tool/tool-starter.tsx @@ -33,6 +33,7 @@ export function ToolStarter({ const [sshKeysLoading, setSshKeysLoading] = useState(true); const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]); + const [displayName, setDisplayName] = useState(workspace.name); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); @@ -119,7 +120,7 @@ export function ToolStarter({ workspace.project_id, workspace.repo_id, selectedToolTypeId, - workspace.name, + displayName.trim() || undefined, undefined, undefined, undefined, @@ -140,7 +141,7 @@ export function ToolStarter({ } finally { setStarting(false); } - }, [selectedToolTypeId, selectedProfileId, workspace, onStarted]); + }, [selectedToolTypeId, selectedProfileId, displayName, workspace, onStarted]); return (
@@ -187,6 +188,19 @@ export function ToolStarter({ {toolTypesError && {toolTypesError}}
+ {/* Session Name */} +
+ + setDisplayName(e.target.value)} + placeholder="My dev environment" + disabled={starting} + /> +
+ {/* Config Profile */} {selectedToolTypeId && (
diff --git a/apps/web/src/hooks/use-terminal-page.ts b/apps/web/src/hooks/use-terminal-page.ts index 10fe534..9974e8c 100644 --- a/apps/web/src/hooks/use-terminal-page.ts +++ b/apps/web/src/hooks/use-terminal-page.ts @@ -10,294 +10,301 @@ import type { TerminalSession } from "../api/terminal"; import type { ModifierKey } from "./use-special-keys"; const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] => - sessions.map((s) => ({ - id: s.id, - name: s.name, - status: s.status as TerminalSessionInfo["status"], - })); + sessions.map((s) => ({ + id: s.id, + name: s.name, + status: s.status as TerminalSessionInfo["status"], + })); type TerminalStatus = - | "connecting" - | "connected" - | "disconnected" - | "error" - | "resetting"; + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting"; export const useTerminalPage = () => { - const { instanceId } = useParams<{ instanceId: string }>(); - const navigate = useNavigate(); - const isMobile = useMobileViewport(); - const [isFullscreen, setIsFullscreen] = useState(false); - const terminalRefs = useRef>>({}); - const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); + const { instanceId } = useParams<{ instanceId: string }>(); + const navigate = useNavigate(); + const isMobile = useMobileViewport(); + const [isFullscreen, setIsFullscreen] = useState(false); + const terminalRefs = useRef>>({}); + const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); - const [terminalStatuses, setTerminalStatuses] = useState< - Record - >({}); - const changeFontSizeRef = useRef<((delta: number) => void) | null>(null); - const sendDataRef = useRef<((data: string) => void) | null>(null); - const focusInputRef = useRef<(() => void) | null>(null); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); - const [activeModifier, setActiveModifier] = useState(null); - const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard(); + const [terminalStatuses, setTerminalStatuses] = useState< + Record + >({}); + const changeFontSizeRef = useRef<((delta: number) => void) | null>(null); + const sendDataRef = useRef<((data: string) => void) | null>(null); + const focusInputRef = useRef<(() => void) | null>(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); + const [activeModifier, setActiveModifier] = useState( + null, + ); + const { isOpen: isKeyboardOpen, height: keyboardHeight } = + useVirtualKeyboard(); - const { - sessions, - activeSessionId, - setActiveSessionId, - createSession, - closeSession, - renameSession, - resetSession, - loading, - error, - } = useTerminalSessions(instanceId ?? ""); + const { + sessions, + activeSessionId, + setActiveSessionId, + createSession, + closeSession, + renameSession, + resetSession, + loading, + error, + } = useTerminalSessions(instanceId ?? ""); - // Auto-create default session - useEffect(() => { - if (!loading && sessions.length === 0 && !error && instanceId) { - void createSession("Session 1"); - } - }, [loading, sessions.length, error, instanceId, createSession]); + // Auto-create default session + useEffect(() => { + if (!loading && sessions.length === 0 && !error && instanceId) { + void createSession("Session 1"); + } + }, [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]); + // 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) { - if (!terminalRefs.current[session.id]) { - terminalRefs.current[session.id] = React.createRef(); - } - } - const currentIds = new Set(sessions.map((s) => s.id)); - for (const id of Object.keys(terminalRefs.current)) { - if (!currentIds.has(id)) { - delete terminalRefs.current[id]; - } - } - }, [sessions]); + // Sync refs with sessions + useEffect(() => { + for (const session of sessions) { + if (!terminalRefs.current[session.id]) { + terminalRefs.current[session.id] = React.createRef(); + } + } + const currentIds = new Set(sessions.map((s) => s.id)); + for (const id of Object.keys(terminalRefs.current)) { + if (!currentIds.has(id)) { + delete terminalRefs.current[id]; + } + } + }, [sessions]); - // Fit and focus active terminal - useEffect(() => { - if (activeSessionId && terminalRefs.current[activeSessionId]) { - const ref = terminalRefs.current[activeSessionId]; - let raf1 = 0; - let raf2 = 0; - raf1 = requestAnimationFrame(() => { - raf2 = requestAnimationFrame(() => { - ref.current?.fit(); - ref.current?.focus(); - }); - }); - return () => { - cancelAnimationFrame(raf1); - cancelAnimationFrame(raf2); - }; - } - }, [activeSessionId]); + // Fit and focus active terminal + useEffect(() => { + if (activeSessionId && terminalRefs.current[activeSessionId]) { + const ref = terminalRefs.current[activeSessionId]; + let raf1 = 0; + let raf2 = 0; + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + ref.current?.fit(); + ref.current?.focus(); + }); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + } + }, [activeSessionId]); - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; - if (!isAltShift) return; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; + if (!isAltShift) return; - switch (e.key.toLowerCase()) { - case "n": - e.preventDefault(); - if (sessions.length < 5) { - void createSession(`Session ${sessions.length + 1}`); - } - break; - case "w": - e.preventDefault(); - if (activeSessionId && window.confirm("Close this terminal session?")) { - void closeSession(activeSessionId); - } - break; - case "arrowleft": - e.preventDefault(); - if (activeSessionId) { - const idx = sessions.findIndex((s) => s.id === activeSessionId); - if (idx > 0) setActiveSessionId(sessions[idx - 1].id); - } - break; - case "arrowright": - e.preventDefault(); - if (activeSessionId) { - const idx = sessions.findIndex((s) => s.id === activeSessionId); - if (idx < sessions.length - 1) setActiveSessionId(sessions[idx + 1].id); - } - break; - case "r": - e.preventDefault(); - if (activeSessionId) void resetSession(activeSessionId); - break; - case "f": - e.preventDefault(); - setIsFullscreen((prev) => !prev); - break; - } - }; + switch (e.key.toLowerCase()) { + case "n": + e.preventDefault(); + if (sessions.length < 5) { + void createSession(`Session ${sessions.length + 1}`); + } + break; + case "w": + e.preventDefault(); + if ( + activeSessionId && + window.confirm("Close this terminal session?") + ) { + void closeSession(activeSessionId); + } + break; + case "arrowleft": + e.preventDefault(); + if (activeSessionId) { + const idx = sessions.findIndex((s) => s.id === activeSessionId); + if (idx > 0) setActiveSessionId(sessions[idx - 1].id); + } + break; + case "arrowright": + e.preventDefault(); + if (activeSessionId) { + const idx = sessions.findIndex((s) => s.id === activeSessionId); + if (idx < sessions.length - 1) + setActiveSessionId(sessions[idx + 1].id); + } + break; + case "r": + e.preventDefault(); + if (activeSessionId) void resetSession(activeSessionId); + break; + case "f": + e.preventDefault(); + setIsFullscreen((prev) => !prev); + break; + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - sessions, - activeSessionId, - createSession, - closeSession, - resetSession, - setActiveSessionId, - ]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + sessions, + activeSessionId, + createSession, + closeSession, + resetSession, + setActiveSessionId, + ]); - // Keep screen awake - useEffect(() => { - let wakeLock: WakeLockSentinel | null = null; - const requestWakeLock = async () => { - try { - if ("wakeLock" in navigator) { - wakeLock = await navigator.wakeLock.request("screen"); - } - } catch { - // ignore - } - }; - void requestWakeLock(); - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") void requestWakeLock(); - }; - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - wakeLock?.release().catch(() => {}); - }; - }, []); + // Keep screen awake + useEffect(() => { + let wakeLock: WakeLockSentinel | null = null; + const requestWakeLock = async () => { + try { + if ("wakeLock" in navigator) { + wakeLock = await navigator.wakeLock.request("screen"); + } + } catch { + // ignore + } + }; + void requestWakeLock(); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") void requestWakeLock(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + wakeLock?.release().catch(() => {}); + }; + }, []); - // Lock page scroll on mobile - useEffect(() => { - if (!isMobile) return; - document.documentElement.classList.add("terminal-page-open"); - document.body.classList.add("terminal-page-open"); - return () => { - document.documentElement.classList.remove("terminal-page-open"); - document.body.classList.remove("terminal-page-open"); - }; - }, [isMobile]); + // Lock page scroll on mobile + useEffect(() => { + if (!isMobile) return; + document.documentElement.classList.add("terminal-page-open"); + document.body.classList.add("terminal-page-open"); + return () => { + document.documentElement.classList.remove("terminal-page-open"); + document.body.classList.remove("terminal-page-open"); + }; + }, [isMobile]); - const handleFullscreenClick = useCallback( - (e: React.MouseEvent) => { - if (!isFullscreen) return; - const target = e.target as Node; - const current = e.currentTarget as HTMLElement; - const content = current.querySelector(".terminal-page-content"); - const header = current.querySelector(".terminal-fullscreen-header"); - if (content?.contains(target) || header?.contains(target)) return; - setIsFullscreen(false); - }, - [isFullscreen], - ); + const handleFullscreenClick = useCallback( + (e: React.MouseEvent) => { + if (!isFullscreen) return; + const target = e.target as Node; + const current = e.currentTarget as HTMLElement; + const content = current.querySelector(".terminal-page-content"); + const header = current.querySelector(".terminal-fullscreen-header"); + if (content?.contains(target) || header?.contains(target)) return; + setIsFullscreen(false); + }, + [isFullscreen], + ); - const handleSelect = useCallback( - (sessionId: string) => setActiveSessionId(sessionId), - [setActiveSessionId], - ); + const handleSelect = useCallback( + (sessionId: string) => setActiveSessionId(sessionId), + [setActiveSessionId], + ); - const handleClose = useCallback( - async (sessionId: string) => closeSession(sessionId), - [closeSession], - ); + const handleClose = useCallback( + async (sessionId: string) => closeSession(sessionId), + [closeSession], + ); - const handleCreate = useCallback(() => { - void createSession(`Session ${sessions.length + 1}`); - }, [createSession, sessions.length]); + const handleCreate = useCallback(() => { + void createSession(`Session ${sessions.length + 1}`); + }, [createSession, sessions.length]); - const handleRename = useCallback( - (sessionId: string, newName: string) => { - void renameSession(sessionId, newName); - }, - [renameSession], - ); + const handleRename = useCallback( + (sessionId: string, newName: string) => { + void renameSession(sessionId, newName); + }, + [renameSession], + ); - const handleTerminalReady = useCallback( - ( - sendData: (data: string) => void, - status: TerminalStatus, - focusInput: () => void, - changeFontSize: (delta: number) => void, - ) => { - setTerminalStatuses((prev) => ({ - ...prev, - [activeSessionId ?? "default"]: status, - })); - sendDataRef.current = sendData; - focusInputRef.current = focusInput; - changeFontSizeRef.current = changeFontSize; - }, - [activeSessionId], - ); + const handleTerminalReady = useCallback( + ( + sendData: (data: string) => void, + status: TerminalStatus, + focusInput: () => void, + changeFontSize: (delta: number) => void, + ) => { + setTerminalStatuses((prev) => ({ + ...prev, + [activeSessionId ?? "default"]: status, + })); + sendDataRef.current = sendData; + focusInputRef.current = focusInput; + changeFontSizeRef.current = changeFontSize; + }, + [activeSessionId], + ); - const handleFontSizeChange = useCallback((delta: number) => { - changeFontSizeRef.current?.(delta); - }, []); + const handleFontSizeChange = useCallback((delta: number) => { + changeFontSizeRef.current?.(delta); + }, []); - const handleSendKey = useCallback((data: string) => { - sendDataRef.current?.(data); - }, []); + const handleSendKey = useCallback((data: string) => { + sendDataRef.current?.(data); + }, []); - const handleReset = useCallback(() => { - if (activeSessionId && terminalRefs.current[activeSessionId]) { - terminalRefs.current[activeSessionId].current?.reset(); - } - }, [activeSessionId]); + const handleReset = useCallback(() => { + if (activeSessionId && terminalRefs.current[activeSessionId]) { + terminalRefs.current[activeSessionId].current?.reset(); + } + }, [activeSessionId]); - return { - instanceId, - navigate, - isMobile, - isFullscreen, - setIsFullscreen, - terminalRefs, - headerAutoHide, - terminalStatuses, - sendDataRef, - focusInputRef, - changeFontSizeRef, - showResetConfirm, - setShowResetConfirm, - showSpecialKeysPanel, - setShowSpecialKeysPanel, - activeModifier, - setActiveModifier, - isKeyboardOpen, - keyboardHeight, - sessions, - activeSessionId, - setActiveSessionId, - loading, - error, - handleFullscreenClick, - handleSelect, - handleClose, - handleCreate, - handleRename, - handleTerminalReady, - handleFontSizeChange, - handleSendKey, - handleReset, - sessionInfos: SESSIONS_TO_INFO(sessions), - }; + return { + instanceId, + navigate, + isMobile, + isFullscreen, + setIsFullscreen, + terminalRefs, + headerAutoHide, + terminalStatuses, + sendDataRef, + focusInputRef, + changeFontSizeRef, + showResetConfirm, + setShowResetConfirm, + showSpecialKeysPanel, + setShowSpecialKeysPanel, + activeModifier, + setActiveModifier, + isKeyboardOpen, + keyboardHeight, + sessions, + activeSessionId, + setActiveSessionId, + loading, + error, + handleFullscreenClick, + handleSelect, + handleClose, + handleCreate, + handleRename, + handleTerminalReady, + handleFontSizeChange, + handleSendKey, + handleReset, + sessionInfos: SESSIONS_TO_INFO(sessions), + }; }; diff --git a/apps/web/src/styles/pages/sessions.css b/apps/web/src/styles/pages/sessions.css index 11881dc..1d360ee 100644 --- a/apps/web/src/styles/pages/sessions.css +++ b/apps/web/src/styles/pages/sessions.css @@ -89,4 +89,3 @@ min-height: 44px; } } -