feat: multi-session terminal backend API + frontend client (PR 2)
- Add WebSocket route /ws/tool-instances/{instance_id}/terminal/{session_id}
- Preserve /terminal as default-session alias for backward compatibility
- Extract shared _handle_terminal_websocket handler for both routes
- Add REST endpoints: GET list, POST create, DELETE close, POST reset, POST rename
- Preserve legacy POST .../terminal/reset as default session alias
- Add frontend API client (apps/web/src/api/terminal.ts)
- Add useTerminalSessions React hook for session CRUD + state management
- Add integration tests for auth requirements on all new endpoints
Quality gates: pytest (8 new passed, 182 total passed, 51 pre-existing failures)
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
has_websockets: boolean;
|
||||
created_at: string;
|
||||
last_activity_at: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalSessionListResponse {
|
||||
sessions: TerminalSession[];
|
||||
}
|
||||
|
||||
export interface TerminalSessionCreateRequest {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSessionCreateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listTerminalSessions(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string
|
||||
): Promise<TerminalSession[]> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`
|
||||
);
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export async function createTerminalSession(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
name?: string
|
||||
): Promise<TerminalSessionCreateResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
|
||||
{ name }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function closeTerminalSession(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
sessionId: string
|
||||
): Promise<{ status: string; session_id: string }> {
|
||||
const response = await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function resetTerminalSession(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
sessionId: string
|
||||
): Promise<{ id: string; name: string; status: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function renameTerminalSession(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
name: string
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
|
||||
{ name }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
export function useTerminalSessions(
|
||||
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 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 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 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]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user