feat(terminal-dashboard): improve UX for pane/session management

- Add confirmation dialog when closing pane with active session
- Add explicit "Close Session" button to terminate backend PTY
- Handle "session not found" scenario with user-friendly message
- Add i18n keys for new UI elements (en/zh)
This commit is contained in:
catlog22
2026-02-20 21:21:02 +08:00
parent aa9f23782a
commit 1de283751b
5 changed files with 101 additions and 4 deletions

View File

@@ -6,7 +6,7 @@
// Renders within the TerminalGrid recursive layout.
// File preview is triggered from right sidebar FileSidebarPanel.
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import {
SplitSquareHorizontal,
@@ -23,6 +23,7 @@ import {
FileText,
ArrowLeft,
LogOut,
WifiOff,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TerminalInstance } from './TerminalInstance';
@@ -64,6 +65,7 @@ const statusDotStyles: Record<TerminalStatus, string> = {
error: 'bg-red-500',
paused: 'bg-yellow-500',
resuming: 'bg-blue-400 animate-pulse',
locked: 'bg-amber-500 animate-pulse',
};
// ========== Props ==========
@@ -129,6 +131,20 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
const status: TerminalStatus = meta?.status ?? 'idle';
const alertCount = meta?.alertCount ?? 0;
// Check if session exists - handles case where pane references a sessionId
// that no longer exists in cliSessionStore (e.g., after page refresh if backend restarted)
const isSessionNotFound = sessionId && !sessions[sessionId];
// Clear invalid sessionId after showing the message
useEffect(() => {
if (isSessionNotFound) {
const timer = setTimeout(() => {
assignSession(paneId, null);
}, 3000); // Clear after 3 seconds to let user see the message
return () => clearTimeout(timer);
}
}, [isSessionNotFound, paneId, assignSession]);
// Build session options for dropdown
// Use sessions from cliSessionStore directly (all sessions, not just grouped ones)
const sessionOptions = useMemo(() => {
@@ -261,7 +277,9 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
) : (
// Terminal mode header
<>
{sessionId && (
{isSessionNotFound ? (
<WifiOff className="w-3.5 h-3.5 text-yellow-500 shrink-0" />
) : sessionId && (
<span
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
/>
@@ -314,7 +332,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
>
<SplitSquareVertical className="w-3.5 h-3.5" />
</button>
{!isFileMode && sessionId && (
{!isFileMode && sessionId && !isSessionNotFound && (
<>
{/* Restart button */}
<button
@@ -417,6 +435,19 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
className="h-full"
/>
</div>
) : isSessionNotFound ? (
// Session not found state - pane references a sessionId that no longer exists
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center px-4">
<WifiOff className="h-6 w-6 mx-auto mb-1.5 text-yellow-500" />
<p className="text-sm font-medium text-foreground">
{formatMessage({ id: 'terminalDashboard.pane.sessionNotFound' })}
</p>
<p className="text-xs mt-1 opacity-70 max-w-[200px]">
{formatMessage({ id: 'terminalDashboard.pane.sessionNotFoundHint' })}
</p>
</div>
</div>
) : sessionId ? (
// Terminal mode with session
<div className="flex-1 min-h-0">

View File

@@ -143,6 +143,8 @@
"pane": {
"selectSession": "Select a session",
"selectSessionHint": "Choose a terminal session from the dropdown",
"sessionNotFound": "Session no longer exists",
"sessionNotFoundHint": "The session may have been closed or the server restarted. Please select another session.",
"splitHorizontal": "Split Right",
"splitVertical": "Split Down",
"clearTerminal": "Clear Terminal",

View File

@@ -143,6 +143,8 @@
"pane": {
"selectSession": "选择会话",
"selectSessionHint": "从下拉菜单中选择终端会话",
"sessionNotFound": "会话已不存在",
"sessionNotFoundHint": "该会话可能已关闭或服务器已重启,请选择其他会话。",
"splitHorizontal": "向右分割",
"splitVertical": "向下分割",
"clearTerminal": "清屏",

View File

@@ -361,6 +361,56 @@ export const useSessionManagerStore = create<SessionManagerStore>()(
throw error;
}
},
// ========== Session Lock Actions ==========
lockSession: (sessionId: string, reason: string, executionId?: string) => {
set(
(state) => {
const existing = state.terminalMetas[sessionId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[sessionId]: {
...existing,
status: 'locked' as TerminalStatus,
isLocked: true,
lockReason: reason,
lockedByExecutionId: executionId,
lockedAt: new Date().toISOString(),
},
},
};
},
false,
'lockSession'
);
},
unlockSession: (sessionId: string) => {
set(
(state) => {
const existing = state.terminalMetas[sessionId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[sessionId]: {
...existing,
status: 'active' as TerminalStatus,
isLocked: false,
lockReason: undefined,
lockedByExecutionId: undefined,
lockedAt: undefined,
},
},
};
},
false,
'unlockSession'
);
},
}),
{ name: 'SessionManagerStore' }
)

View File

@@ -18,7 +18,7 @@ export interface SessionLayout {
}
/** Terminal status indicator */
export type TerminalStatus = 'active' | 'idle' | 'error' | 'paused' | 'resuming';
export type TerminalStatus = 'active' | 'idle' | 'error' | 'paused' | 'resuming' | 'locked';
/** Metadata for a terminal instance in the dashboard */
export interface TerminalMeta {
@@ -28,6 +28,14 @@ export interface TerminalMeta {
status: TerminalStatus;
/** Number of unread alerts (errors, warnings) */
alertCount: number;
/** Whether the session is locked (executing a workflow) */
isLocked?: boolean;
/** Reason for the lock (e.g., workflow name) */
lockReason?: string;
/** Execution ID that locked this session */
lockedByExecutionId?: string;
/** Timestamp when the session was locked */
lockedAt?: string;
}
/** Group of terminal sessions */
@@ -91,6 +99,10 @@ export interface SessionManagerActions {
restartSession: (terminalId: string) => Promise<void>;
/** Close and terminate a terminal session permanently */
closeSession: (terminalId: string) => Promise<void>;
/** Lock a session to prevent user input during workflow execution */
lockSession: (sessionId: string, reason: string, executionId?: string) => void;
/** Unlock a session after workflow execution completes */
unlockSession: (sessionId: string) => void;
}
export type SessionManagerStore = SessionManagerState & SessionManagerActions;