feat(web): add agent output terminal tabs for orchestrator sessions (#522)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #522.
This commit is contained in:
@@ -7,18 +7,25 @@
|
||||
* rendering one XTerminal per session and keeping all instances mounted (for
|
||||
* scrollback preservation) while switching visibility with display:none.
|
||||
*
|
||||
* Also renders read-only agent output tabs from the orchestrator SSE stream
|
||||
* via useAgentStream. Agent tabs are automatically added when agents are active
|
||||
* and can be dismissed when completed or errored.
|
||||
*
|
||||
* Features:
|
||||
* - "+" button to open a new tab
|
||||
* - Per-tab close button
|
||||
* - Double-click tab label for inline rename
|
||||
* - Auto-creates the first session on connect
|
||||
* - "+" button to open a new terminal tab
|
||||
* - Per-tab close button (terminal) / dismiss button (agent)
|
||||
* - Double-click tab label for inline rename (terminal tabs only)
|
||||
* - Auto-creates the first terminal session on connect
|
||||
* - Connection error state
|
||||
* - Agent tabs: read-only, auto-appear, dismissable
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
||||
import { XTerminal } from "./XTerminal";
|
||||
import { AgentTerminal } from "./AgentTerminal";
|
||||
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||
import { useAgentStream } from "@/hooks/useAgentStream";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
@@ -59,6 +66,42 @@ export function TerminalPanel({
|
||||
registerOutputCallback,
|
||||
} = useTerminalSessions({ token });
|
||||
|
||||
// ==========================================
|
||||
// Agent stream
|
||||
// ==========================================
|
||||
|
||||
const { agents, dismissAgent } = useAgentStream();
|
||||
|
||||
// ==========================================
|
||||
// Active tab state (terminal session OR agent)
|
||||
// "terminal:<sessionId>" or "agent:<agentId>"
|
||||
// ==========================================
|
||||
|
||||
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
|
||||
|
||||
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
|
||||
useEffect(() => {
|
||||
setActiveTabId((prev) => {
|
||||
// If an agent tab is active, don't clobber it
|
||||
if (prev?.startsWith("agent:")) return prev;
|
||||
// Reflect active terminal session
|
||||
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
|
||||
return prev;
|
||||
});
|
||||
}, [activeSessionId]);
|
||||
|
||||
// If the active agent tab is dismissed, fall back to the terminal session
|
||||
useEffect(() => {
|
||||
if (activeTabId?.startsWith("agent:")) {
|
||||
const agentId = activeTabId.slice("agent:".length);
|
||||
if (!agents.has(agentId)) {
|
||||
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
|
||||
}
|
||||
}
|
||||
}, [agents, activeTabId, activeSessionId]);
|
||||
|
||||
// ==========================================
|
||||
// Inline rename state
|
||||
// ==========================================
|
||||
@@ -187,6 +230,16 @@ export function TerminalPanel({
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Agent status dot color
|
||||
// ==========================================
|
||||
|
||||
const agentDotColor = (status: string): string => {
|
||||
if (status === "running" || status === "spawning") return "var(--success)";
|
||||
if (status === "error") return "var(--danger)";
|
||||
return "var(--muted)";
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Render
|
||||
// ==========================================
|
||||
@@ -203,8 +256,10 @@ export function TerminalPanel({
|
||||
<div style={headerStyle}>
|
||||
{/* Tab bar */}
|
||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||
{/* ---- Terminal session tabs ---- */}
|
||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||
const isActive = sessionId === activeSessionId;
|
||||
const tabKey = `terminal:${sessionId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const isEditing = sessionId === editingTabId;
|
||||
|
||||
const tabStyle: CSSProperties = {
|
||||
@@ -223,7 +278,7 @@ export function TerminalPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sessionId} style={tabStyle}>
|
||||
<div key={tabKey} style={tabStyle}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
@@ -262,6 +317,7 @@ export function TerminalPanel({
|
||||
padding: 0,
|
||||
}}
|
||||
onClick={(): void => {
|
||||
setActiveTabId(tabKey);
|
||||
setActiveSession(sessionId);
|
||||
}}
|
||||
onDoubleClick={(): void => {
|
||||
@@ -364,6 +420,146 @@ export function TerminalPanel({
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* ---- Agent section divider (only when agents exist) ---- */}
|
||||
{agents.size > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 1,
|
||||
height: 16,
|
||||
background: "var(--border)",
|
||||
marginLeft: 6,
|
||||
marginRight: 4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---- Agent tabs ---- */}
|
||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||
const tabKey = `agent:${agentId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
|
||||
const canDismiss =
|
||||
agentSession.status === "completed" || agentSession.status === "error";
|
||||
|
||||
const agentTabStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 6px 3px 8px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabKey}
|
||||
style={agentTabStyle}
|
||||
data-testid="agent-tab"
|
||||
data-agent-id={agentId}
|
||||
data-agent-status={agentSession.status}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: agentDotColor(agentSession.status),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Agent tab button — read-only, no rename */}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.75rem",
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
maxWidth: 100,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={(): void => {
|
||||
setActiveTabId(tabKey);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}
|
||||
}}
|
||||
aria-label={`Agent: ${agentSession.agentType}`}
|
||||
>
|
||||
{agentSession.agentType}
|
||||
</button>
|
||||
|
||||
{/* Dismiss button — only for completed/error agents */}
|
||||
{canDismiss && (
|
||||
<button
|
||||
aria-label={`Dismiss ${agentSession.agentType} agent`}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={(): void => {
|
||||
dismissAgent(agentId);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1L7 7M7 1L1 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
@@ -428,8 +624,10 @@ export function TerminalPanel({
|
||||
|
||||
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
|
||||
<div style={bodyStyle}>
|
||||
{/* ---- Terminal session panels ---- */}
|
||||
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||
const isActive = sessionId === activeSessionId;
|
||||
const tabKey = `terminal:${sessionId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const termStyle: CSSProperties = {
|
||||
display: isActive ? "flex" : "none",
|
||||
flex: 1,
|
||||
@@ -438,7 +636,7 @@ export function TerminalPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sessionId} style={termStyle}>
|
||||
<div key={tabKey} style={termStyle}>
|
||||
<XTerminal
|
||||
sessionId={sessionId}
|
||||
sendInput={sendInput}
|
||||
@@ -458,8 +656,26 @@ export function TerminalPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{sessions.size === 0 && (
|
||||
{/* ---- Agent session panels ---- */}
|
||||
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||
const tabKey = `agent:${agentId}`;
|
||||
const isActive = tabKey === activeTabId;
|
||||
const agentPanelStyle: CSSProperties = {
|
||||
display: isActive ? "flex" : "none",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={tabKey} style={agentPanelStyle}>
|
||||
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
|
||||
{sessions.size === 0 && agents.size === 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
|
||||
Reference in New Issue
Block a user