feat: multi-session terminal frontend UI + tests (PR 3)

- Add TerminalSessionTabs component with status dots, rename, close, max-5 limit
- Add 7 component tests for tab rendering, selection, close, rename
- TerminalComponent: sessionId prop, forwardRef with fit() method
- TerminalPage: multi-session orchestration, tab switching, auto-create default
- Fullscreen mode: Alt+Shift+F toggle, auto-hide tabs, Esc exit
- Keyboard shortcuts: Alt+Shift+N/W/ArrowLeft/ArrowRight/R
- Add CSS for tabs, fullscreen, mobile responsive
- Update useTerminalSessions hook for session CRUD
- terminal_manager.py: lookup by internal session_id fallback

Quality gates: tsc --noEmit clean, vitest (7/7 new tests passed), pytest (182 passed)
This commit is contained in:
2026-05-28 13:35:45 +02:00
parent 0b35ae3bf0
commit 62d1bdc462
26 changed files with 4589 additions and 775 deletions
+141 -128
View File
@@ -1,145 +1,158 @@
import { useCallback, useEffect, useState } from "react";
import {
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
} from "../api/terminal";
export interface UseTerminalSessionsResult {
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useTerminalSessions(
projectId: string,
repoId: string,
instanceId: string
projectId: string,
repoId: string,
instanceId: string,
): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId]
);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name,
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId],
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to close session");
}
},
[projectId, repoId, instanceId, activeSessionId]
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to close session",
);
}
},
[projectId, repoId, instanceId, activeSessionId],
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(projectId, repoId, instanceId, sessionId, name);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rename session");
}
},
[projectId, repoId, instanceId]
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(
projectId,
repoId,
instanceId,
sessionId,
name,
);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename session",
);
}
},
[projectId, repoId, instanceId],
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reset session");
}
},
[projectId, repoId, instanceId, loadSessions]
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset session",
);
}
},
[projectId, repoId, instanceId, loadSessions],
);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
}