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:
+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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user