fix: resolve e2e test failures for terminal creation and new workspace

Terminal tests (launcher-tab, terminal-performance):
- Terminal creation no longer silently no-ops when workspace metadata
  hasn't hydrated. Early clicks are queued and flushed once the workspace
  directory is ready, with toast feedback.
- Terminal button is disabled while waiting on workspace readiness.
- Removed isFocused gate from ?open= intent consumption in workspace
  layout so deep-linked terminals open reliably on cold startup.

New workspace test:
- New workspace screen now shows a visible Create button even when the
  composer is empty, via new allowEmptySubmit and
  submitButtonAccessibilityLabel props threaded through Composer and
  MessageInput.
This commit is contained in:
Mohamed Boudra
2026-04-15 11:42:18 +07:00
parent 751a1df682
commit 43bec954ea
6 changed files with 105 additions and 20 deletions
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useIsFocused, useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { import {
useGlobalSearchParams, useGlobalSearchParams,
useLocalSearchParams, useLocalSearchParams,
@@ -77,7 +77,6 @@ export default function HostWorkspaceLayout() {
} }
function HostWorkspaceLayoutContent() { function HostWorkspaceLayoutContent() {
const isFocused = useIsFocused();
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const rootNavigationState = useRootNavigationState(); const rootNavigationState = useRootNavigationState();
@@ -101,9 +100,6 @@ function HostWorkspaceLayoutContent() {
if (!openValue) { if (!openValue) {
return; return;
} }
if (!isFocused) {
return;
}
if (!rootNavigationState?.key) { if (!rootNavigationState?.key) {
return; return;
} }
@@ -142,7 +138,7 @@ function HostWorkspaceLayoutContent() {
}); });
setIntentConsumed(true); setIntentConsumed(true);
}, [isFocused, navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]); }, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
if (openValue && !intentConsumed) { if (openValue && !intentConsumed) {
return null; return null;
+8
View File
@@ -67,6 +67,10 @@ interface ComposerProps {
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean; hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
/** When true, blurs the input immediately when submitting. */ /** When true, blurs the input immediately when submitting. */
@@ -105,6 +109,8 @@ export function Composer({
isInputActive, isInputActive,
onSubmitMessage, onSubmitMessage,
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitLoading = false, isSubmitLoading = false,
blurOnSubmit = false, blurOnSubmit = false,
value, value,
@@ -745,6 +751,8 @@ export function Composer({
onChangeText={setUserInput} onChangeText={setUserInput}
onSubmit={handleSubmit} onSubmit={handleSubmit}
hasExternalContent={hasExternalContent} hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages} images={selectedImages}
+23 -5
View File
@@ -64,6 +64,10 @@ export interface MessageInputProps {
onSubmit: (payload: MessagePayload) => void; onSubmit: (payload: MessagePayload) => void;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean; hasExternalContent?: boolean;
/** When true, the submit button stays visible and can submit even with no content. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
isSubmitDisabled?: boolean; isSubmitDisabled?: boolean;
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
images?: ImageAttachment[]; images?: ImageAttachment[];
@@ -201,6 +205,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText, onChangeText,
onSubmit, onSubmit,
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitDisabled = false, isSubmitDisabled = false,
isSubmitLoading = false, isSubmitLoading = false,
images = [], images = [],
@@ -571,7 +577,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const handleSendMessage = useCallback(() => { const handleSendMessage = useCallback(() => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed && images.length === 0 && !hasExternalContent) return; if (!trimmed && images.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
const payload = { const payload = {
text: trimmed, text: trimmed,
images: images.length > 0 ? images : undefined, images: images.length > 0 ? images : undefined,
@@ -581,7 +587,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
inputHeightRef.current = MIN_INPUT_HEIGHT; inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT); setInputHeight(MIN_INPUT_HEIGHT);
onHeightChange?.(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onSubmit, isAgentRunning, onHeightChange, hasExternalContent]); }, [
allowEmptySubmit,
value,
images,
onSubmit,
isAgentRunning,
onHeightChange,
hasExternalContent,
]);
const handleQueueMessage = useCallback(() => { const handleQueueMessage = useCallback(() => {
if (!onQueue) return; if (!onQueue) return;
@@ -934,18 +948,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const hasImages = images.length > 0; const hasImages = images.length > 0;
const hasRealContent = value.trim().length > 0 || hasImages; const hasRealContent = value.trim().length > 0 || hasImages;
const hasSendableContent = hasRealContent || hasExternalContent; const hasSendableContent = hasRealContent || hasExternalContent;
const shouldShowSendButton = hasSendableContent || isSubmitLoading; const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled = const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton const defaultSubmitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent" ? "Interrupt agent"
: defaultActionQueues : defaultActionQueues
? "Queue message" ? "Queue message"
: isAgentRunning : isAgentRunning
? "Send and interrupt" ? "Send and interrupt"
: "Send message"; : "Send message";
const submitAccessibilityLabel =
submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel;
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
@@ -1178,7 +1194,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text> <Text style={styles.tooltipText}>
{submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")}
</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null} {sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View> </View>
</TooltipContent> </TooltipContent>
@@ -290,6 +290,8 @@ export function NewWorkspaceScreen({
isInputActive={true} isInputActive={true}
onSubmitMessage={handleCreateChatAgent} onSubmitMessage={handleCreateChatAgent}
hasExternalContent={selectedGithubItem !== null} hasExternalContent={selectedGithubItem !== null}
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
isSubmitLoading={pendingAction === "chat"} isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true} blurOnSubmit={true}
value={chatDraft.text} value={chatDraft.text}
@@ -81,6 +81,8 @@ type WorkspaceDesktopTabsRowProps = {
onCloseOtherTabs: (tabId: string) => Promise<void> | void; onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void; onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void;
disableCreateTerminal?: boolean;
isWaitingOnTerminalReadiness?: boolean;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
onSplitRight: () => void; onSplitRight: () => void;
onSplitDown: () => void; onSplitDown: () => void;
@@ -382,6 +384,8 @@ export function WorkspaceDesktopTabsRow({
onCloseOtherTabs, onCloseOtherTabs,
onCreateDraftTab, onCreateDraftTab,
onCreateTerminalTab, onCreateTerminalTab,
disableCreateTerminal = false,
isWaitingOnTerminalReadiness = false,
onReorderTabs, onReorderTabs,
onSplitRight, onSplitRight,
onSplitDown, onSplitDown,
@@ -544,10 +548,15 @@ export function WorkspaceDesktopTabsRow({
<TooltipTrigger <TooltipTrigger
testID="workspace-new-terminal" testID="workspace-new-terminal"
onPress={() => onCreateTerminalTab({ paneId })} onPress={() => onCreateTerminalTab({ paneId })}
disabled={disableCreateTerminal || isWaitingOnTerminalReadiness}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="New terminal tab" accessibilityLabel={
isWaitingOnTerminalReadiness ? "Preparing terminal tab" : "New terminal tab"
}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.newTabActionButton, styles.newTabActionButton,
(disableCreateTerminal || isWaitingOnTerminalReadiness) &&
styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered, (hovered || pressed) && styles.newTabActionButtonHovered,
]} ]}
> >
@@ -555,7 +564,9 @@ export function WorkspaceDesktopTabsRow({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}> <TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}> <View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text> <Text style={styles.newTabTooltipText}>
{isWaitingOnTerminalReadiness ? "Preparing terminal..." : "New terminal tab"}
</Text>
{newTerminalKeys ? ( {newTerminalKeys ? (
<Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} /> <Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} />
) : null} ) : null}
@@ -863,6 +874,9 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
newTabActionButtonDisabled: {
opacity: 0.5,
},
newTabActionButtonHovered: { newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
@@ -645,6 +645,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
); );
const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null; const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null;
const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority); const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority);
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
paneId?: string;
} | null>(null);
const canCreateTerminalNow = Boolean(client && isConnected && workspaceDirectory);
const workspaceAgentVisibility = useStoreWithEqualityFn( const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
@@ -780,6 +784,30 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const hasHydratedAgents = useSessionStore( const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
); );
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
}
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
const pendingInput = pendingTerminalCreateInput;
setPendingTerminalCreateInput(null);
createTerminalMutation.mutate(pendingInput);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
setPendingTerminalCreateInput(null);
toast.error("Workspace path is not available yet");
}
}, [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
]);
const workspaceHeader = workspaceDescriptor const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) ? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null; : null;
@@ -1216,16 +1244,31 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const handleCreateTerminal = useCallback( const handleCreateTerminal = useCallback(
(input?: { paneId?: string }) => { (input?: { paneId?: string }) => {
if (createTerminalMutation.isPending) { if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
return;
}
if (!workspaceDirectory) {
return; return;
} }
createTerminalMutation.mutate(input); if (canCreateTerminalNow) {
createTerminalMutation.mutate(input);
return;
}
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
toast.error("Workspace path is not available yet");
return;
}
setPendingTerminalCreateInput(input ?? {});
toast.show("Preparing workspace, opening terminal when ready...");
}, },
[createTerminalMutation, workspaceDirectory], [
canCreateTerminalNow,
createTerminalMutation,
hasHydratedWorkspaces,
isMissingWorkspaceExecutionAuthority,
pendingTerminalCreateInput,
toast,
],
); );
const handleSelectSwitcherTab = useCallback( const handleSelectSwitcherTab = useCallback(
@@ -2087,7 +2130,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
leading={ leading={
<SquareTerminal size={16} color={theme.colors.foregroundMuted} /> <SquareTerminal size={16} color={theme.colors.foregroundMuted} />
} }
disabled={createTerminalMutation.isPending} disabled={
createTerminalMutation.isPending || pendingTerminalCreateInput !== null
}
onSelect={handleCreateTerminal} onSelect={handleCreateTerminal}
> >
New terminal New terminal
@@ -2294,6 +2339,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseOtherTabs={handleCloseOtherTabs} onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal} onCreateTerminalTab={handleCreateTerminal}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane} onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={() => {}} onSplitRight={() => {}}
onSplitDown={() => {}} onSplitDown={() => {}}