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:
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={() => {}}
|
||||||
|
|||||||
Reference in New Issue
Block a user