Merge branch 'feat/tool-definition-manifest' into dev
Conflicts resolved: - models/__init__.py: kept both TerminalSessionModel (from dev) and ToolDefinitionManifest (from feature branch) - alembic migration: kept full migration (already applied to DB) - openspec/config.yaml: kept full config with SDD settings
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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(
|
||||
instanceId: string,
|
||||
): Promise<TerminalSession[]> {
|
||||
const response = await apiClient.get(
|
||||
`/instances/${instanceId}/terminal/sessions`,
|
||||
);
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export async function createTerminalSession(
|
||||
instanceId: string,
|
||||
name?: string,
|
||||
): Promise<TerminalSessionCreateResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions`,
|
||||
{ name },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function closeTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; session_id: string }> {
|
||||
const response = await apiClient.delete(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function resetTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ id: string; name: string; status: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function renameTerminalSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
|
||||
{ name },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "./terminal-session-tabs";
|
||||
|
||||
const mockSessions: TerminalSessionInfo[] = [
|
||||
{ id: "s1", name: "Session 1", status: "connected" },
|
||||
{ id: "s2", name: "Session 2", status: "connecting" },
|
||||
{ id: "s3", name: "Session 3", status: "disconnected" },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TerminalSessionTabs", () => {
|
||||
it("renders all tabs", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a tab calls onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByText("Session 2")[0]);
|
||||
expect(onSelect).toHaveBeenCalledWith("s2");
|
||||
});
|
||||
|
||||
it("close button calls onClose after confirmation", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close session Session 1");
|
||||
// First click shows confirm
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.getByText("Close?")).toBeInTheDocument();
|
||||
|
||||
// Click confirm text
|
||||
fireEvent.click(screen.getByText("Close?"));
|
||||
expect(onClose).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Enter commits", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Escape cancels", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plus button is disabled at 5 sessions", () => {
|
||||
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `Session ${i + 1}`,
|
||||
status: "connected",
|
||||
}));
|
||||
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={fiveSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const newButton = screen.getByRole("button", { name: /new session/i });
|
||||
expect(newButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("status dot reflects connection state", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(3);
|
||||
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
|
||||
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
|
||||
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
|
||||
export interface TerminalSessionTabsProps {
|
||||
sessions: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onClose: (sessionId: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (sessionId: string, newName: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onCreate,
|
||||
onRename,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
|
||||
setRenamingId(session.id);
|
||||
setRenameValue(session.name);
|
||||
requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
onRename(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renamingId, renameValue, onRename]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
},
|
||||
[commitRename, cancelRename],
|
||||
);
|
||||
|
||||
const handleCloseClick = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirmCloseId === sessionId) {
|
||||
setConfirmCloseId(null);
|
||||
onClose(sessionId);
|
||||
} else {
|
||||
setConfirmCloseId(sessionId);
|
||||
// Auto-dismiss confirm after 3s
|
||||
setTimeout(() => {
|
||||
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[confirmCloseId, onClose],
|
||||
);
|
||||
|
||||
const isMaxSessions = sessions.length >= 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const isConfirmingClose = confirmCloseId === session.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-session-tab ${isActive ? "active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onDoubleClick={() => handleDoubleClick(session)}
|
||||
title={isRenaming ? "" : `${session.name} (${session.status})`}
|
||||
>
|
||||
<span
|
||||
className={`terminal-session-tab-status ${session.status}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="terminal-session-tab-input"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={commitRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Rename session"
|
||||
/>
|
||||
) : (
|
||||
<span className="terminal-session-tab-name">
|
||||
{session.name}
|
||||
</span>
|
||||
)}
|
||||
{isConfirmingClose ? (
|
||||
<button
|
||||
className="terminal-session-tab-confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmCloseId(null);
|
||||
onClose(session.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Close?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="terminal-session-tab-close"
|
||||
onClick={(e) => handleCloseClick(e, session.id)}
|
||||
type="button"
|
||||
aria-label={`Close session ${session.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="terminal-session-tab new-session"
|
||||
onClick={onCreate}
|
||||
disabled={isMaxSessions}
|
||||
type="button"
|
||||
aria-label="New session"
|
||||
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
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(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);
|
||||
}
|
||||
}, [instanceId, activeSessionId]);
|
||||
|
||||
const createSession = useCallback(
|
||||
async (name?: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const newSession = await createTerminalSession(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;
|
||||
}
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
const closeSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await closeTerminalSession(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",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId, activeSessionId],
|
||||
);
|
||||
|
||||
const renameSession = useCallback(
|
||||
async (sessionId: string, name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await renameTerminalSession(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",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
const resetSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await resetTerminalSession(instanceId, sessionId);
|
||||
// Refetch to get updated session info
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to reset session",
|
||||
);
|
||||
}
|
||||
},
|
||||
[instanceId, loadSessions],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
+309
-42
@@ -1,50 +1,317 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent } from "../components/terminal";
|
||||
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
|
||||
import { TerminalComponent, type TerminalRef } from "../components/terminal";
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "../components/terminal-session-tabs";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
||||
import type { TerminalSession } from "../api/terminal";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as TerminalSessionInfo["status"],
|
||||
}));
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const { instanceId } = useParams<{
|
||||
instanceId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
renameSession,
|
||||
resetSession,
|
||||
loading,
|
||||
error,
|
||||
} = useTerminalSessions(instanceId ?? "");
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileTerminalWrapper
|
||||
instanceId={instanceId}
|
||||
onBack={() => navigate(-1)}
|
||||
onClose={() => navigate(-1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Auto-create default session if none exist
|
||||
useEffect(() => {
|
||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||
void createSession("Session 1");
|
||||
}
|
||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||
|
||||
return (
|
||||
<section className="terminal-page">
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={() => navigate(-1)}
|
||||
isMobile={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
// Ensure refs map is kept in sync with sessions
|
||||
useEffect(() => {
|
||||
for (const session of sessions) {
|
||||
if (!terminalRefs.current[session.id]) {
|
||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||
}
|
||||
}
|
||||
// Clean up refs for closed sessions
|
||||
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 active terminal when switching tabs
|
||||
useEffect(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
const ref = terminalRefs.current[activeSessionId];
|
||||
// Small delay to allow display:block to apply
|
||||
const timer = setTimeout(() => {
|
||||
ref.current?.fit();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
// 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;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
sessions,
|
||||
activeSessionId,
|
||||
createSession,
|
||||
closeSession,
|
||||
resetSession,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// Exit fullscreen on Escape
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
},
|
||||
[setActiveSessionId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (sessionId: string) => {
|
||||
await closeSession(sessionId);
|
||||
},
|
||||
[closeSession],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
void createSession(`Session ${sessions.length + 1}`);
|
||||
}, [createSession, sessions.length]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(sessionId: string, newName: string) => {
|
||||
void renameSession(sessionId, newName);
|
||||
},
|
||||
[renameSession],
|
||||
);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<section
|
||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
>
|
||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
>
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
|
||||
{!isFullscreen && (
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
title="Toggle fullscreen (Alt+Shift+F)"
|
||||
>
|
||||
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={false}
|
||||
/>
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
|
||||
style={{
|
||||
display: session.id === activeSessionId ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<TerminalComponent
|
||||
ref={terminalRefs.current[session.id]}
|
||||
instanceId={instanceId}
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="terminal-empty-state">
|
||||
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
+2627
-2331
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user