diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 33a98e734..a5de8a39f 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { useIsFocused, useNavigation } from "@react-navigation/native"; +import { useNavigation } from "@react-navigation/native"; import { useGlobalSearchParams, useLocalSearchParams, @@ -77,7 +77,6 @@ export default function HostWorkspaceLayout() { } function HostWorkspaceLayoutContent() { - const isFocused = useIsFocused(); const navigation = useNavigation(); const router = useRouter(); const rootNavigationState = useRootNavigationState(); @@ -101,9 +100,6 @@ function HostWorkspaceLayoutContent() { if (!openValue) { return; } - if (!isFocused) { - return; - } if (!rootNavigationState?.key) { return; } @@ -142,7 +138,7 @@ function HostWorkspaceLayoutContent() { }); setIntentConsumed(true); - }, [isFocused, navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]); + }, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]); if (openValue && !intentConsumed) { return null; diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 4badcb167..6fabd325b 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -67,6 +67,10 @@ interface ComposerProps { onSubmitMessage?: (payload: MessagePayload) => Promise; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ 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. */ isSubmitLoading?: boolean; /** When true, blurs the input immediately when submitting. */ @@ -105,6 +109,8 @@ export function Composer({ isInputActive, onSubmitMessage, hasExternalContent = false, + allowEmptySubmit = false, + submitButtonAccessibilityLabel, isSubmitLoading = false, blurOnSubmit = false, value, @@ -745,6 +751,8 @@ export function Composer({ onChangeText={setUserInput} onSubmit={handleSubmit} hasExternalContent={hasExternalContent} + allowEmptySubmit={allowEmptySubmit} + submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading} images={selectedImages} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c9a101bce..15a0c03ab 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -64,6 +64,10 @@ export interface MessageInputProps { onSubmit: (payload: MessagePayload) => void; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ 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; isSubmitLoading?: boolean; images?: ImageAttachment[]; @@ -201,6 +205,8 @@ export const MessageInput = forwardRef(funct onChangeText, onSubmit, hasExternalContent = false, + allowEmptySubmit = false, + submitButtonAccessibilityLabel, isSubmitDisabled = false, isSubmitLoading = false, images = [], @@ -571,7 +577,7 @@ export const MessageInput = forwardRef(funct const handleSendMessage = useCallback(() => { const trimmed = value.trim(); - if (!trimmed && images.length === 0 && !hasExternalContent) return; + if (!trimmed && images.length === 0 && !hasExternalContent && !allowEmptySubmit) return; const payload = { text: trimmed, images: images.length > 0 ? images : undefined, @@ -581,7 +587,15 @@ export const MessageInput = forwardRef(funct inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); - }, [value, images, onSubmit, isAgentRunning, onHeightChange, hasExternalContent]); + }, [ + allowEmptySubmit, + value, + images, + onSubmit, + isAgentRunning, + onHeightChange, + hasExternalContent, + ]); const handleQueueMessage = useCallback(() => { if (!onQueue) return; @@ -934,18 +948,20 @@ export const MessageInput = forwardRef(funct const hasImages = images.length > 0; const hasRealContent = value.trim().length > 0 || hasImages; const hasSendableContent = hasRealContent || hasExternalContent; - const shouldShowSendButton = hasSendableContent || isSubmitLoading; + const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; - const submitAccessibilityLabel = canPressLoadingButton + const defaultSubmitAccessibilityLabel = canPressLoadingButton ? "Interrupt agent" : defaultActionQueues ? "Queue message" : isAgentRunning ? "Send and interrupt" : "Send message"; + const submitAccessibilityLabel = + submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel; const handleInputChange = useCallback( (nextValue: string) => { @@ -1178,7 +1194,9 @@ export const MessageInput = forwardRef(funct - {defaultActionQueues ? "Queue" : "Send"} + + {submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")} + {sendKeys ? : null} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index de0fdf1a2..aff65e620 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -290,6 +290,8 @@ export function NewWorkspaceScreen({ isInputActive={true} onSubmitMessage={handleCreateChatAgent} hasExternalContent={selectedGithubItem !== null} + allowEmptySubmit={true} + submitButtonAccessibilityLabel="Create" isSubmitLoading={pendingAction === "chat"} blurOnSubmit={true} value={chatDraft.text} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index b22302403..a4e42f839 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -81,6 +81,8 @@ type WorkspaceDesktopTabsRowProps = { onCloseOtherTabs: (tabId: string) => Promise | void; onCreateDraftTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string }) => void; + disableCreateTerminal?: boolean; + isWaitingOnTerminalReadiness?: boolean; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onSplitRight: () => void; onSplitDown: () => void; @@ -382,6 +384,8 @@ export function WorkspaceDesktopTabsRow({ onCloseOtherTabs, onCreateDraftTab, onCreateTerminalTab, + disableCreateTerminal = false, + isWaitingOnTerminalReadiness = false, onReorderTabs, onSplitRight, onSplitDown, @@ -544,10 +548,15 @@ export function WorkspaceDesktopTabsRow({ onCreateTerminalTab({ paneId })} + disabled={disableCreateTerminal || isWaitingOnTerminalReadiness} accessibilityRole="button" - accessibilityLabel="New terminal tab" + accessibilityLabel={ + isWaitingOnTerminalReadiness ? "Preparing terminal tab" : "New terminal tab" + } style={({ hovered, pressed }) => [ styles.newTabActionButton, + (disableCreateTerminal || isWaitingOnTerminalReadiness) && + styles.newTabActionButtonDisabled, (hovered || pressed) && styles.newTabActionButtonHovered, ]} > @@ -555,7 +564,9 @@ export function WorkspaceDesktopTabsRow({ - New terminal tab + + {isWaitingOnTerminalReadiness ? "Preparing terminal..." : "New terminal tab"} + {newTerminalKeys ? ( ) : null} @@ -863,6 +874,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + newTabActionButtonDisabled: { + opacity: 0.5, + }, newTabActionButtonHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9244959db..bda1b2352 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -645,6 +645,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ); const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null; const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority); + const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{ + paneId?: string; + } | null>(null); + const canCreateTerminalNow = Boolean(client && isConnected && workspaceDirectory); const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, @@ -780,6 +784,30 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const hasHydratedAgents = useSessionStore( (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 ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) : null; @@ -1216,16 +1244,31 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const handleCreateTerminal = useCallback( (input?: { paneId?: string }) => { - if (createTerminalMutation.isPending) { - return; - } - if (!workspaceDirectory) { + if (createTerminalMutation.isPending || pendingTerminalCreateInput) { 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( @@ -2087,7 +2130,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) leading={ } - disabled={createTerminalMutation.isPending} + disabled={ + createTerminalMutation.isPending || pendingTerminalCreateInput !== null + } onSelect={handleCreateTerminal} > New terminal @@ -2294,6 +2339,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseOtherTabs={handleCloseOtherTabs} onCreateDraftTab={handleCreateDraftTab} onCreateTerminalTab={handleCreateTerminal} + disableCreateTerminal={createTerminalMutation.isPending} + isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null} onReorderTabs={handleReorderTabsInFocusedPane} onSplitRight={() => {}} onSplitDown={() => {}}