Harden terminal streaming and worktree setup flow
This commit is contained in:
@@ -611,7 +611,7 @@ export function AgentInputArea({
|
||||
]
|
||||
);
|
||||
|
||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
||||
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
@@ -755,6 +755,7 @@ export function AgentInputArea({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface MessageInputProps {
|
||||
isAgentRunning?: boolean;
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Optional handler used when submit button is in loading state. */
|
||||
onSubmitLoadingPress?: () => void;
|
||||
/** Intercept key press events before default handling. Return true to prevent default. */
|
||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
}
|
||||
@@ -132,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
},
|
||||
ref
|
||||
@@ -576,6 +579,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
const canPressLoadingButton =
|
||||
isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
disabled ||
|
||||
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||
const submitAccessibilityLabel = canPressLoadingButton
|
||||
? "Interrupt agent"
|
||||
: isAgentRunning
|
||||
? "Send and interrupt"
|
||||
: "Send message";
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="message-input-root">
|
||||
@@ -724,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{shouldShowSendButton && isAgentRunning && onQueue && (
|
||||
{hasSendableContent && isAgentRunning && onQueue && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleQueueMessage}
|
||||
@@ -748,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
)}
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleSendMessage}
|
||||
disabled={
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled
|
||||
<TooltipTrigger
|
||||
onPress={
|
||||
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
|
||||
}
|
||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
|
||||
@@ -1032,17 +1032,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
agentTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.85,
|
||||
opacity: 1,
|
||||
},
|
||||
agentTitleInactive: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.8,
|
||||
},
|
||||
agentTitleSelected: {
|
||||
opacity: 1,
|
||||
},
|
||||
relativeTimeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.78,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
shortcutBadge: {
|
||||
@@ -1084,7 +1086,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
agentMetaProjectName: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.78,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
@@ -1101,7 +1104,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
agentMetaBranchText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
color: theme.colors.foreground,
|
||||
opacity: 0.76,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
selectionBar: {
|
||||
|
||||
@@ -26,6 +26,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onOutputChunkConsumed,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
}: TerminalEmulatorProps) {
|
||||
@@ -108,19 +110,30 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputChunkSequence <= 0) {
|
||||
return;
|
||||
}
|
||||
if (outputChunkText.length === 0) {
|
||||
runtime.clear();
|
||||
|
||||
if (!runtime) {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
return;
|
||||
}
|
||||
runtime.write({ text: outputChunkText });
|
||||
}, [outputChunkSequence, outputChunkText]);
|
||||
|
||||
if (outputChunkText.length === 0) {
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
} from "@/terminal/runtime/terminal-output-pump";
|
||||
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
@@ -72,6 +73,22 @@ type TerminalOutputChunkState = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PendingTerminalInput =
|
||||
| {
|
||||
type: "data";
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
type: "key";
|
||||
input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -121,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
||||
|
||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||
@@ -142,16 +160,33 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
const outputDeliveryQueue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
setSelectedOutputChunk(chunk);
|
||||
},
|
||||
});
|
||||
outputDeliveryQueueRef.current = outputDeliveryQueue;
|
||||
|
||||
return () => {
|
||||
if (outputDeliveryQueueRef.current === outputDeliveryQueue) {
|
||||
outputDeliveryQueueRef.current = null;
|
||||
}
|
||||
outputDeliveryQueue.reset();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const outputPump = new TerminalOutputPump({
|
||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
||||
setSelectedOutputChunk(chunk);
|
||||
outputDeliveryQueueRef.current?.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
outputPumpRef.current = outputPump;
|
||||
@@ -405,6 +440,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}, [client, handleStreamControllerStatus, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
outputDeliveryQueueRef.current?.reset();
|
||||
pendingTerminalInputRef.current = [];
|
||||
setSelectedOutputChunk({ sequence: 0, text: "" });
|
||||
outputPumpRef.current?.setSelectedTerminal({
|
||||
terminalId: selectedTerminalId,
|
||||
});
|
||||
@@ -422,6 +460,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
activeStream && activeStream.terminalId === selectedTerminalId
|
||||
? activeStream.streamId
|
||||
: null;
|
||||
const getCurrentActiveStreamId = useCallback(() => {
|
||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||
}, []);
|
||||
|
||||
const selectedTerminal = useMemo(
|
||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||
@@ -431,6 +472,50 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
createTerminalMutation.mutate();
|
||||
}, [createTerminalMutation]);
|
||||
|
||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
queue.push(entry);
|
||||
if (queue.length > 512) {
|
||||
queue.splice(0, queue.length - 512);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushPendingTerminalInput = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
if (currentStreamId === null) {
|
||||
return;
|
||||
}
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
if (queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = queue.splice(0, queue.length);
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:queue:flush",
|
||||
details: {
|
||||
activeStreamId: currentStreamId,
|
||||
count: pending.length,
|
||||
},
|
||||
});
|
||||
|
||||
for (const entry of pending) {
|
||||
if (entry.type === "data") {
|
||||
client.sendTerminalStreamInput(currentStreamId, entry.data);
|
||||
continue;
|
||||
}
|
||||
client.sendTerminalStreamKey(currentStreamId, entry.input);
|
||||
}
|
||||
}, [client, getCurrentActiveStreamId]);
|
||||
|
||||
useEffect(() => {
|
||||
flushPendingTerminalInput();
|
||||
}, [activeStreamId, flushPendingTerminalInput]);
|
||||
|
||||
const handleCloseTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (
|
||||
@@ -462,19 +547,30 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
meta?: boolean;
|
||||
}
|
||||
): boolean => {
|
||||
if (!client || activeStreamId === null) {
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
if (!client || currentStreamId === null) {
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:key:drop-no-stream",
|
||||
event: "input:key:queue-no-stream",
|
||||
details: {
|
||||
key: input.key,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
activeStreamId,
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
input: {
|
||||
key: normalizeTerminalTransportKey(input.key),
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
||||
@@ -486,10 +582,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
alt: input.alt,
|
||||
activeStreamId,
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
client.sendTerminalStreamKey(activeStreamId, {
|
||||
client.sendTerminalStreamKey(currentStreamId, {
|
||||
key: normalizedKey,
|
||||
ctrl: input.ctrl,
|
||||
shift: input.shift,
|
||||
@@ -498,7 +594,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[client, activeStreamId]
|
||||
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -506,13 +602,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentStreamId = getCurrentActiveStreamId();
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:received",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId,
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -541,15 +638,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!client || activeStreamId === null) {
|
||||
if (!client || currentStreamId === null) {
|
||||
terminalDebugLog({
|
||||
scope: "terminal-pane",
|
||||
event: "input:data:drop-no-stream",
|
||||
event: "input:data:queue-no-stream",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
enqueuePendingTerminalInput({
|
||||
type: "data",
|
||||
data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
@@ -558,19 +659,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||
activeStreamId,
|
||||
activeStreamId: currentStreamId,
|
||||
},
|
||||
});
|
||||
client.sendTerminalStreamInput(activeStreamId, data);
|
||||
client.sendTerminalStreamInput(currentStreamId, data);
|
||||
},
|
||||
[
|
||||
activeStreamId,
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
getCurrentActiveStreamId,
|
||||
modifiers.alt,
|
||||
modifiers.ctrl,
|
||||
modifiers.shift,
|
||||
sendTerminalKey,
|
||||
enqueuePendingTerminalInput,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -626,6 +728,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
clearPendingModifiers();
|
||||
}, [clearPendingModifiers]);
|
||||
|
||||
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
||||
outputDeliveryQueueRef.current?.consume({ sequence });
|
||||
}, []);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof ModifierState) => {
|
||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||
@@ -808,6 +914,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
/>
|
||||
@@ -819,7 +926,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
)}
|
||||
|
||||
{isAttaching ? (
|
||||
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
||||
<View
|
||||
style={styles.attachOverlay}
|
||||
pointerEvents="none"
|
||||
testID="terminal-attach-loading"
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user