v1.10: booterm container — xterm.js + tmux + node-pty
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user