v1.10: booterm container — xterm.js + tmux + node-pty

This commit is contained in:
2026-05-18 14:06:46 +00:00
parent d85b17081e
commit 7486e7d3e0
25 changed files with 1515 additions and 29 deletions

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -115,6 +116,20 @@ export function Workspace({
.filter((c): c is Chat => c !== undefined);
}
// v1.10 booterm: per-terminal label used by the registry that powers the
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
const terminalLabels = useMemo(() => {
const out = new Map<string, string>();
let n = 0;
for (const p of panes) {
if (p.kind === 'terminal') {
n += 1;
out.set(p.id, `Terminal ${n}`);
}
}
return out;
}, [panes]);
return (
<div className="flex flex-col h-full min-h-0">
{!isMobile && (
@@ -165,6 +180,7 @@ export function Workspace({
>
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -176,6 +192,9 @@ export function Workspace({
}
return null;
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
return (
<div
key={pane.id}
@@ -187,19 +206,18 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isChromeless && panes.length > 1}
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -214,6 +232,28 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -226,6 +266,12 @@ export function Workspace({
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : isTerminal ? (
<TerminalPane
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}