feat: improved passthrough and workflow (#237)
This commit is contained in:
@@ -28,9 +28,10 @@ type SessionCommandsProps = {
|
||||
baseBranch?: string;
|
||||
isAgentRunning?: boolean;
|
||||
hasWorktree?: boolean;
|
||||
isPassthrough?: boolean;
|
||||
};
|
||||
|
||||
export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree }: SessionCommandsProps) {
|
||||
export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree, isPassthrough }: SessionCommandsProps) {
|
||||
const git = useGitOperations(sessionId);
|
||||
const panels = usePanelActions();
|
||||
const { openCommitDialog, openPRDialog } = useVcsDialogs();
|
||||
@@ -77,14 +78,16 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'session-plan-mode',
|
||||
label: 'Toggle Plan Mode',
|
||||
group: 'Session',
|
||||
icon: <IconFileTextSpark className="size-3.5" />,
|
||||
keywords: ['plan', 'mode', 'toggle'],
|
||||
action: () => panels.addPlan(),
|
||||
});
|
||||
if (!isPassthrough) {
|
||||
items.push({
|
||||
id: 'session-plan-mode',
|
||||
label: 'Toggle Plan Mode',
|
||||
group: 'Session',
|
||||
icon: <IconFileTextSpark className="size-3.5" />,
|
||||
keywords: ['plan', 'mode', 'toggle'],
|
||||
action: () => panels.addPlan(),
|
||||
});
|
||||
}
|
||||
|
||||
// Git — only when worktree is set up
|
||||
if (hasWorktree) {
|
||||
@@ -164,26 +167,30 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork
|
||||
keywords: ['terminal', 'shell', 'console'],
|
||||
action: () => panels.addTerminal(),
|
||||
},
|
||||
{
|
||||
);
|
||||
|
||||
if (!isPassthrough) {
|
||||
items.push({
|
||||
id: 'panel-plan',
|
||||
label: 'Add Plan Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileText className="size-3.5" />,
|
||||
keywords: ['plan', 'document'],
|
||||
action: () => panels.addPlan(),
|
||||
},
|
||||
{
|
||||
id: 'panel-changes',
|
||||
label: 'Add Changes Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileDiff className="size-3.5" />,
|
||||
keywords: ['changes', 'diff', 'git'],
|
||||
action: () => panels.addChanges(),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'panel-changes',
|
||||
label: 'Add Changes Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileDiff className="size-3.5" />,
|
||||
keywords: ['changes', 'diff', 'git'],
|
||||
action: () => panels.addChanges(),
|
||||
});
|
||||
|
||||
return items.map((cmd) => ({ ...cmd, priority: PAGE_PRIORITY }));
|
||||
}, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, openCommitDialog, openPRDialog, runGitWithFeedback]);
|
||||
}, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, isPassthrough, openCommitDialog, openPRDialog, runGitWithFeedback]);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth
|
||||
label: `${agentProfile.agent_display_name} • ${agentProfile.name}`,
|
||||
agent_id: agentItem.id,
|
||||
agent_name: agentItem.name,
|
||||
cli_passthrough: agentProfile.cli_passthrough,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kandev/ui/tooltip';
|
||||
import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction } from '@/lib/types/http';
|
||||
import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction, OnExitAction } from '@/lib/types/http';
|
||||
import { useDebouncedCallback } from '@/hooks/use-debounce';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -93,6 +93,10 @@ function hasDisablePlanMode(step: WorkflowStep): boolean {
|
||||
return step.events?.on_turn_complete?.some((a) => a.type === 'disable_plan_mode') ?? false;
|
||||
}
|
||||
|
||||
function hasOnExitAction(step: WorkflowStep, type: string): boolean {
|
||||
return step.events?.on_exit?.some((a) => a.type === type) ?? false;
|
||||
}
|
||||
|
||||
function getTransitionLabel(step: WorkflowStep): string {
|
||||
const t = getTransitionType(step);
|
||||
if (t === 'move_to_next') return 'auto';
|
||||
@@ -368,6 +372,16 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
|
||||
onUpdate({ events: { ...currentEvents, on_turn_complete: newOnTurnComplete } });
|
||||
};
|
||||
|
||||
const toggleOnExitAction = (type: string) => {
|
||||
const currentEvents = step.events ?? {};
|
||||
const onExit = currentEvents.on_exit ?? [];
|
||||
const exists = onExit.some((a) => a.type === type);
|
||||
const newOnExit = exists
|
||||
? onExit.filter((a) => a.type !== type)
|
||||
: [...onExit, { type } as OnExitAction];
|
||||
onUpdate({ events: { ...currentEvents, on_exit: newOnExit } });
|
||||
};
|
||||
|
||||
// Reset local state only when the step identity changes (not on local edits round-tripping)
|
||||
const resetKey = step.id;
|
||||
|
||||
@@ -657,6 +671,29 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On Exit */}
|
||||
{hasOnEnterAction(step, 'enable_plan_mode') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs font-medium">On Exit</Label>
|
||||
<HelpTip text="Runs when leaving this step (before entering the next step)." />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`${step.id}-exit-disable-plan`}
|
||||
checked={hasOnExitAction(step, 'disable_plan_mode')}
|
||||
onCheckedChange={() => {
|
||||
if (readOnly) return;
|
||||
toggleOnExitAction('disable_plan_mode');
|
||||
}}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label htmlFor={`${step.id}-exit-disable-plan`} className="text-sm">Disable plan mode</Label>
|
||||
<HelpTip text="Turn off plan mode when leaving this step." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt section */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClipboard,
|
||||
IconDoorExit,
|
||||
IconMessageForward,
|
||||
IconRobot,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -28,8 +29,9 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil
|
||||
const hasTransition = events?.on_turn_complete?.some((a) =>
|
||||
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
|
||||
) ?? false;
|
||||
const hasOnExit = (events?.on_exit?.length ?? 0) > 0;
|
||||
|
||||
if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition) {
|
||||
if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition && !hasOnExit) {
|
||||
return fallback ? <div className={className ?? 'flex items-center gap-1.5 text-muted-foreground'}>{fallback}</div> : null;
|
||||
}
|
||||
|
||||
@@ -75,6 +77,16 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasOnExit && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconDoorExit className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>On exit actions</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown } from '@tabler/icons-react';
|
||||
import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown, IconTerminal2 } from '@tabler/icons-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -294,6 +294,7 @@ type TaskFormInputsProps = {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
descriptionValueRef: React.RefObject<{ getValue: () => string } | null>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const TaskFormInputs = memo(function TaskFormInputs({
|
||||
@@ -304,6 +305,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
onKeyDown,
|
||||
descriptionValueRef,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: TaskFormInputsProps) {
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -345,7 +347,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...'}
|
||||
placeholder={placeholder ?? (isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...')}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -431,6 +433,13 @@ export function TaskCreateDialog({
|
||||
const agentProfilesLoading = open && !settingsData.agentsLoaded;
|
||||
const executorsLoading = open && !settingsData.executorsLoaded;
|
||||
|
||||
// Detect passthrough profile to disable prompt textarea
|
||||
const isPassthroughProfile = useMemo(() => {
|
||||
if (!agentProfileId) return false;
|
||||
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === agentProfileId);
|
||||
return profile?.cli_passthrough === true;
|
||||
}, [agentProfileId, agentProfiles]);
|
||||
|
||||
// Effective workflow/steps: use fetched steps when a different workflow is selected
|
||||
const effectiveWorkflowId = selectedWorkflowId ?? workflowId;
|
||||
const effectiveDefaultStepId = (selectedWorkflowId && selectedWorkflowId !== workflowId && fetchedSteps)
|
||||
@@ -680,6 +689,7 @@ export function TaskCreateDialog({
|
||||
const parts = profile.label.split(' • ');
|
||||
const agentLabel = parts[0] ?? profile.label;
|
||||
const profileLabel = parts[1] ?? '';
|
||||
const isPassthrough = profile.cli_passthrough === true;
|
||||
return {
|
||||
value: profile.id,
|
||||
label: profile.label,
|
||||
@@ -689,11 +699,14 @@ export function TaskCreateDialog({
|
||||
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
|
||||
<span>{agentLabel}</span>
|
||||
</span>
|
||||
{profileLabel ? (
|
||||
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
{profileLabel}
|
||||
</ScrollOnOverflow>
|
||||
) : null}
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{isPassthrough && <IconTerminal2 className="size-3.5 text-muted-foreground" title="Passthrough terminal" />}
|
||||
{profileLabel ? (
|
||||
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
{profileLabel}
|
||||
</ScrollOnOverflow>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
@@ -836,7 +849,8 @@ export function TaskCreateDialog({
|
||||
const handleSessionSubmit = useCallback(async () => {
|
||||
const description = descriptionInputRef.current?.getValue() ?? '';
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedDescription || !agentProfileId) return;
|
||||
if (!agentProfileId) return;
|
||||
if (!trimmedDescription && !isPassthroughProfile) return;
|
||||
|
||||
// Legacy mode: delegate to parent
|
||||
if (onCreateSession) {
|
||||
@@ -871,7 +885,7 @@ export function TaskCreateDialog({
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}, [agentProfileId, environmentId, executorId, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
|
||||
}, [agentProfileId, environmentId, executorId, isPassthroughProfile, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
|
||||
|
||||
const performTaskUpdate = useCallback(async () => {
|
||||
if (!editingTask) return null;
|
||||
@@ -958,7 +972,7 @@ export function TaskCreateDialog({
|
||||
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
if (trimmedDescription) {
|
||||
if (trimmedDescription || isPassthroughProfile) {
|
||||
// "Start" mode: create task + start agent in background, stay on kanban
|
||||
// Backend resolves the start step automatically
|
||||
const taskResponse = await createTask({
|
||||
@@ -975,7 +989,11 @@ export function TaskCreateDialog({
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, 'create', { taskSessionId: newSessionId });
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
if (isPassthroughProfile && newSessionId) {
|
||||
router.push(linkToSession(newSessionId));
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
// "Plan" mode: create task + session only (no agent), navigate to session
|
||||
// Backend resolves the start step automatically
|
||||
@@ -1019,7 +1037,7 @@ export function TaskCreateDialog({
|
||||
}
|
||||
}, [
|
||||
taskName, workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, agentProfileId, executorId,
|
||||
getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
|
||||
isPassthroughProfile, getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
|
||||
setActiveDocument, setPlanMode, router, toast,
|
||||
]);
|
||||
|
||||
@@ -1137,8 +1155,12 @@ export function TaskCreateDialog({
|
||||
onDescriptionChange={setHasDescription}
|
||||
onKeyDown={handleKeyDown}
|
||||
descriptionValueRef={descriptionInputRef}
|
||||
disabled={isTaskStarted}
|
||||
disabled={isTaskStarted || isPassthroughProfile}
|
||||
placeholder={isPassthroughProfile ? 'Sending a prompt is not supported in passthrough mode' : undefined}
|
||||
/>
|
||||
{isPassthroughProfile && hasDescription && (
|
||||
<p className="text-xs text-amber-500">Prompt will be ignored — passthrough sessions don't support sending a prompt on start.</p>
|
||||
)}
|
||||
{!isSessionMode && !isTaskStarted && (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<div>
|
||||
@@ -1268,7 +1290,7 @@ export function TaskCreateDialog({
|
||||
'Update'
|
||||
)}
|
||||
</Button>
|
||||
) : (isCreateMode && hasDescription) || (isEditMode && agentProfileId) ? (
|
||||
) : (isCreateMode && (hasDescription || isPassthroughProfile)) || (isEditMode && agentProfileId) ? (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1309,7 +1331,7 @@ export function TaskCreateDialog({
|
||||
isCreatingSession ||
|
||||
isCreatingTask ||
|
||||
(isSessionMode
|
||||
? !hasDescription || !agentProfileId
|
||||
? (!hasDescription && !isPassthroughProfile) || !agentProfileId
|
||||
: !hasTitle || (!repositoryId && !selectedLocalRepo) || !branch)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -54,6 +54,8 @@ import { ChangesPanel } from './changes-panel';
|
||||
import { FilesPanel } from './files-panel';
|
||||
import { TaskPlanPanel } from './task-plan-panel';
|
||||
import { FileEditorPanel } from './file-editor-panel';
|
||||
import { PassthroughTerminal } from './passthrough-terminal';
|
||||
import { PanelRoot, PanelBody } from './panel-primitives';
|
||||
import { TerminalPanel } from './terminal-panel';
|
||||
import { BrowserPanel } from './browser-panel';
|
||||
import { CommitDetailPanel } from './commit-detail-panel';
|
||||
@@ -116,6 +118,25 @@ function ChatPanel(props: IDockviewPanelProps) {
|
||||
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const { openFile } = useFileEditors();
|
||||
|
||||
const isPassthrough = useAppStore((state) => {
|
||||
if (!sessionId) return false;
|
||||
return state.taskSessions.items[sessionId]?.is_passthrough === true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
props.api.setTitle('Agent');
|
||||
}, [props.api]);
|
||||
|
||||
if (isPassthrough) {
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<PassthroughTerminal sessionId={sessionId} mode="agent" />
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskChatPanel
|
||||
sessionId={sessionId}
|
||||
@@ -237,6 +258,10 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
||||
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
|
||||
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const isPassthrough = useAppStore((state) => {
|
||||
if (!activeSessionId) return false;
|
||||
return state.taskSessions.items[activeSessionId]?.is_passthrough === true;
|
||||
});
|
||||
|
||||
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
|
||||
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
|
||||
@@ -284,10 +309,12 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
||||
<IconDeviceDesktop className="h-3.5 w-3.5 mr-1.5" />
|
||||
Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Plan
|
||||
</DropdownMenuItem>
|
||||
{!isPassthrough && (
|
||||
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Plan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!hasChanges && (
|
||||
<DropdownMenuItem onClick={() => addChangesPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconGitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
@@ -696,7 +723,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
id: 'chat',
|
||||
component: 'chat',
|
||||
tabComponent: 'permanentTab',
|
||||
title: 'Chat',
|
||||
title: 'Agent',
|
||||
position: sidebarPanel
|
||||
? { direction: 'right', referencePanel: 'sidebar' }
|
||||
: undefined,
|
||||
|
||||
@@ -101,7 +101,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
{activeTaskId ? (
|
||||
isPassthroughMode ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} terminalId="default" />
|
||||
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} mode="agent" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatPanel
|
||||
|
||||
@@ -13,11 +13,12 @@ import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import { getTerminalTheme } from '@/lib/terminal-theme';
|
||||
|
||||
type PassthroughTerminalProps = {
|
||||
type BaseProps = {
|
||||
sessionId?: string | null;
|
||||
terminalId?: string; // Terminal tab identifier (e.g., "shell-1", "shell-2")
|
||||
label?: string; // Label for plain shell terminals (sent to backend for persistence)
|
||||
};
|
||||
type AgentTerminalProps = BaseProps & { mode: 'agent'; label?: string };
|
||||
type ShellTerminalProps = BaseProps & { mode: 'shell'; terminalId: string; label?: string };
|
||||
type PassthroughTerminalProps = AgentTerminalProps | ShellTerminalProps;
|
||||
|
||||
// Debug flag - set to true to see detailed logs
|
||||
const DEBUG = false;
|
||||
@@ -33,15 +34,17 @@ const MIN_HEIGHT = 100;
|
||||
* PassthroughTerminal provides direct terminal interaction with an agent CLI.
|
||||
*
|
||||
* Design: Dedicated Binary WebSocket + AttachAddon
|
||||
* - Uses a dedicated WebSocket connection to /xterm.js/:sessionId
|
||||
* - Uses a dedicated WebSocket connection to /terminal/:sessionId
|
||||
* - Raw binary frames bypass JSON encoding/decoding latency
|
||||
* - AttachAddon (official xterm.js addon) handles the bridging
|
||||
* - Unicode11Addon enables proper unicode character support
|
||||
* - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}]
|
||||
*/
|
||||
export function PassthroughTerminal({ sessionId: propSessionId, terminalId, label }: PassthroughTerminalProps) {
|
||||
export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
const { sessionId: propSessionId, mode, label } = props;
|
||||
const terminalId = mode === 'shell' ? props.terminalId : undefined;
|
||||
// Debug: log props on every render
|
||||
log('Render - props:', { propSessionId, terminalId, label });
|
||||
log('Render - props:', { propSessionId, mode, terminalId, label });
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
@@ -313,7 +316,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
|
||||
// Connect WebSocket when ready
|
||||
useEffect(() => {
|
||||
log('WebSocket effect:', { taskId, sessionId, terminalId, canConnect, hasTerminal: !!xtermRef.current });
|
||||
log('WebSocket effect:', { taskId, sessionId, mode, terminalId, canConnect, hasTerminal: !!xtermRef.current });
|
||||
|
||||
if (!taskId || !sessionId || !canConnect) {
|
||||
log('WebSocket effect: early return - missing requirements', { taskId, sessionId, canConnect });
|
||||
@@ -343,18 +346,17 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
// Build WebSocket URL - terminalId is required for user shells
|
||||
// If terminalId is not provided, this is an error for user shell terminals
|
||||
if (!terminalId) {
|
||||
log('ERROR: terminalId is required but was not provided!', { sessionId, terminalId });
|
||||
return;
|
||||
// Build WebSocket URL based on mode
|
||||
let wsUrl: string;
|
||||
if (mode === 'agent') {
|
||||
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=agent`;
|
||||
} else {
|
||||
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
|
||||
}
|
||||
let wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}?terminalId=${encodeURIComponent(terminalId)}`;
|
||||
if (label) {
|
||||
// For plain shell terminals, send the label for persistence
|
||||
wsUrl += `&label=${encodeURIComponent(label)}`;
|
||||
}
|
||||
log('Connecting to', wsUrl, { terminalId, label });
|
||||
log('Connecting to', wsUrl, { mode, terminalId, label });
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
@@ -427,7 +429,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, terminalId, label]);
|
||||
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, mode, terminalId, label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -596,7 +596,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
{activeTaskId ? (
|
||||
isPassthroughMode ? (
|
||||
<div className="flex-1 min-h-0 h-full" style={{ minHeight: '150px' }}>
|
||||
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} terminalId="default" />
|
||||
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} mode="agent" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatPanel
|
||||
|
||||
@@ -137,6 +137,9 @@ export function TaskPageContent({
|
||||
const storeSessionState = useAppStore((state) =>
|
||||
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.state ?? null : null
|
||||
);
|
||||
const isSessionPassthrough = useAppStore((state) =>
|
||||
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.is_passthrough === true : false
|
||||
);
|
||||
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
|
||||
const previewOpen = useAppStore((state) =>
|
||||
effectiveSessionId ? state.previewPanel.openBySessionId[effectiveSessionId] ?? false : false
|
||||
@@ -247,6 +250,7 @@ export function TaskPageContent({
|
||||
baseBranch={task?.repositories?.[0]?.base_branch}
|
||||
isAgentRunning={isAgentWorking}
|
||||
hasWorktree={Boolean(worktreeBranch)}
|
||||
isPassthrough={isSessionPassthrough}
|
||||
/>
|
||||
{DEBUG_UI && showDebugOverlay && (
|
||||
<DebugOverlay
|
||||
|
||||
@@ -216,6 +216,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
|
||||
<PassthroughTerminal
|
||||
key={terminal.id}
|
||||
sessionId={sessionId ?? undefined}
|
||||
mode="shell"
|
||||
terminalId={terminal.id}
|
||||
label={terminal.type === 'shell' ? terminal.label : undefined}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TerminalPanel = memo(function TerminalPanel(
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<PassthroughTerminal
|
||||
sessionId={sessionId ?? undefined}
|
||||
mode="shell"
|
||||
terminalId={terminalId}
|
||||
/>
|
||||
</PanelBody>
|
||||
|
||||
Reference in New Issue
Block a user