feat: improved passthrough and workflow (#237)

This commit is contained in:
Carlos Florêncio
2026-02-17 12:32:02 +00:00
committed by GitHub
parent 82ea7578dc
commit 2b05ed0735
38 changed files with 829 additions and 382 deletions
+28 -21
View File
@@ -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 */}
+13 -1
View File
@@ -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>
);
}
+37 -15
View File
@@ -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&apos;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>