Desktop/remove hard requirement auth wall on desktop (#5956)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
EthanHealy01
2026-03-23 19:36:48 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 081b1ec49e
commit 2e2b55e87d
87 changed files with 1522 additions and 339 deletions
@@ -78,7 +78,8 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
id: 'welcome',
type: 'modal-slide',
slideId: 'welcome',
condition: () => true,
// Desktop has its own onboarding modal (DesktopOnboardingModal)
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'admin-overview',
@@ -107,7 +108,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
id: 'tour-overview',
type: 'modal-slide',
slideId: 'tour-overview',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp,
},
{
id: 'server-license',
@@ -12,6 +12,7 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/ToolPickerFooterExtensions";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -150,6 +151,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</>
)}
</Box>
<ToolPickerFooterExtensions />
</Box>
);
};
@@ -3,11 +3,14 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { CloudBadge } from '@app/components/shared/CloudBadge';
import type { ExecuteDisabledReason } from '@app/hooks/tools/shared/toolOperationTypes';
import { useToolActions } from '@app/contexts/ToolActionsContext';
export interface OperationButtonProps {
onClick?: () => void;
isLoading?: boolean;
disabled?: boolean;
disabledReason?: ExecuteDisabledReason;
loadingText?: string;
submitText?: string;
variant?: 'filled' | 'outline' | 'subtle';
@@ -24,6 +27,7 @@ const OperationButton = ({
onClick,
isLoading = false,
disabled = false,
disabledReason,
loadingText,
submitText,
variant = 'filled',
@@ -37,20 +41,32 @@ const OperationButton = ({
}: OperationButtonProps) => {
const { t } = useTranslation();
const { isOnline, message: backendMessage } = useBackendHealth();
const { onEndpointUnavailableClick } = useToolActions();
const blockedByBackend = !isOnline;
const combinedDisabled = disabled || blockedByBackend;
const effectiveDisabled = disabled || disabledReason !== null && disabledReason !== undefined;
const combinedDisabled = effectiveDisabled || blockedByBackend;
const reasonTooltip: Record<NonNullable<ExecuteDisabledReason>, string> = {
endpointUnavailable: onEndpointUnavailableClick
? t('tool.endpointUnavailableClickable', "Not available in this mode. Click to sign in.")
: t('tool.endpointUnavailable', 'This tool is unavailable on your server.'),
noFiles: t('tool.noFiles', 'Add a file to get started.'),
invalidParams: t('tool.invalidParams', 'Fill in the required settings.'),
};
const tooltipLabel = blockedByBackend
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))
: null;
: (disabledReason ? (reasonTooltip[disabledReason] ?? null) : null);
const button = (
<Button
type={type}
onClick={onClick}
fullWidth={fullWidth}
mr='md'
ml='md'
mt={mt}
fullWidth={fullWidth || !!tooltipLabel}
mr={tooltipLabel ? 0 : 'md'}
ml={tooltipLabel ? 0 : 'md'}
mt={tooltipLabel ? 0 : mt}
loading={isLoading}
disabled={combinedDisabled}
variant={variant}
@@ -72,9 +88,22 @@ const OperationButton = ({
);
if (tooltipLabel) {
// Disabled buttons suppress pointer events at the browser level, so the Tooltip's
// cloneElement handlers would never fire. Wrap in a Box to capture them instead.
// When endpointUnavailable and a click handler is provided (desktop), the Box
// also acts as the click target to open the sign-in / connect modal.
const boxClickHandler = disabledReason === 'endpointUnavailable' ? onEndpointUnavailableClick : undefined;
return (
<Tooltip content={tooltipLabel} position="top" arrow>
{button}
<Box
mr="md"
ml="md"
mt={mt}
style={{ display: 'block', cursor: boxClickHandler ? 'pointer' : undefined }}
onClick={boxClickHandler}
>
{button}
</Box>
</Tooltip>
);
}
@@ -6,6 +6,7 @@ import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
import { StirlingFile } from '@app/types/fileContext';
import type { TooltipTip } from '@app/types/tips';
import type { ExecuteDisabledReason } from '@app/hooks/tools/shared/toolOperationTypes';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -36,6 +37,23 @@ export interface ExecuteButtonConfig {
loadingText: string;
onClick: () => Promise<void>;
isVisible?: boolean;
/**
* Pass the raw endpoint-enabled flag from useEndpointEnabled / useBaseTool.
* createToolFlow derives the correct disabled reason automatically.
* Priority: endpointUnavailable > noFiles > invalidParams
*/
endpointEnabled?: boolean | null;
/**
* Pass the result of params.validateParameters().
* createToolFlow uses this to show the 'invalidParams' disabled reason.
*/
paramsValid?: boolean;
/**
* Explicit disabled reason override — use when the automatic computation
* from endpointEnabled + paramsValid is insufficient.
*/
disabledReason?: ExecuteDisabledReason;
/** Raw override for tools with fully custom disable logic (e.g. Compare, ShowJS). */
disabled?: boolean;
testId?: string;
showCloudBadge?: boolean;
@@ -99,18 +117,30 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
{!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton
onClick={config.executeButton.onClick}
isLoading={config.review.operation.isLoading}
disabled={config.executeButton.disabled}
loadingText={config.executeButton.loadingText}
submitText={config.executeButton.text}
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
data-testid={config.executeButton.testId}
data-tour="run-button"
/>
)}
{config.executeButton && config.executeButton.isVisible !== false && (() => {
const eb = config.executeButton;
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
const effectiveDisabledReason: ExecuteDisabledReason =
eb.disabledReason !== undefined ? eb.disabledReason
: eb.endpointEnabled === false ? 'endpointUnavailable'
: !hasFiles ? 'noFiles'
: eb.paramsValid === false ? 'invalidParams'
: null;
return (
<OperationButton
onClick={eb.onClick}
isLoading={config.review.operation.isLoading}
disabled={eb.disabled}
disabledReason={effectiveDisabledReason}
loadingText={eb.loadingText}
submitText={eb.text}
showCloudBadge={eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
data-testid={eb.testId}
data-tour="run-button"
/>
);
})()}
{/* Review Step */}
{steps.createReviewStep({
@@ -11,7 +11,7 @@ import { useHotkeys } from "@app/contexts/HotkeyContext";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import type { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { CloudBadge } from "@app/components/shared/CloudBadge";
@@ -26,15 +26,21 @@ interface ToolButtonProps {
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
onUnavailableClick?: () => void;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false, onUnavailableClick }) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled);
const isUnavailable = disabledReason !== null;
// If onUnavailableClick is provided for a non-comingSoon tool, render as "cloud-available":
// full opacity, cloud badge, normal tooltip — clicking still fires onUnavailableClick (e.g. sign-in).
const showAsCloudAvailable = isUnavailable && !!onUnavailableClick && disabledReason !== 'comingSoon' && disabledReason !== 'selfHostedOffline';
const visuallyUnavailable = isUnavailable && !showAsCloudAvailable;
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
@@ -46,7 +52,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const usesCloud = useWillUseCloud(endpointString);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
if (isUnavailable) {
onUnavailableClick?.();
return;
}
if (tool.link) {
// Open external link in new tab
window.open(tool.link, '_blank', 'noopener,noreferrer');
@@ -62,7 +71,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = isUnavailable
const tooltipContent = visuallyUnavailable
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
@@ -84,7 +93,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
<>
<ToolIcon
icon={tool.icon}
opacity={isUnavailable ? 0.25 : 1}
opacity={visuallyUnavailable ? 0.25 : 1}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
@@ -93,19 +102,19 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
style={{ display: 'inline-block', maxWidth: '100%', opacity: visuallyUnavailable ? 0.25 : 1 }}
/>
{tool.versionStatus === 'alpha' && (
<Badge
size="xs"
variant="light"
color="orange"
style={{ flexShrink: 0, opacity: isUnavailable ? 0.25 : 1 }}
style={{ flexShrink: 0, opacity: visuallyUnavailable ? 0.25 : 1 }}
>
{t('toolPanel.alpha', 'Alpha')}
</Badge>
)}
{usesCloud && !isUnavailable && (
{(usesCloud && !visuallyUnavailable) && (
<CloudBadge />
)}
</div>
@@ -113,7 +122,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
<span style={{
fontSize: '0.75rem',
color: 'var(--mantine-color-dimmed)',
opacity: isUnavailable ? 0.25 : 1,
opacity: visuallyUnavailable ? 0.25 : 1,
marginTop: '1px',
overflow: 'visible',
whiteSpace: 'nowrap'
@@ -195,7 +204,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
cursor: isUnavailable ? 'not-allowed' : undefined,
cursor: visuallyUnavailable ? 'not-allowed' : undefined,
overflow: 'visible'
},
label: { overflow: 'visible' }
@@ -205,7 +214,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
</Button>
);
const star = hasStars && !isUnavailable ? (
const star = hasStars && !visuallyUnavailable ? (
<FavoriteStar
isFavorite={fav}
onToggle={() => toggleFavorite(id as ToolId)}
@@ -0,0 +1,7 @@
/**
* Stub — returns null in core/web builds.
* Desktop build shadows this with a sign-in prompt for local mode.
*/
export function ToolPickerFooterExtensions() {
return null;
}