diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx
index 5d935469..3383146a 100644
--- a/frontend/src/__tests__/App.test.tsx
+++ b/frontend/src/__tests__/App.test.tsx
@@ -276,7 +276,7 @@ describe('App', () => {
expect(screen.getByText('Preparing feed')).toBeInTheDocument();
expect(
- screen.getByText('Creating the feed and loading its preview before showing the result.')
+ screen.getByText('Creating the feed now. The result appears first, then preview loading continues.')
).toBeInTheDocument();
});
@@ -497,6 +497,37 @@ describe('App', () => {
expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument();
});
+ it('does not treat non-token forbidden failures as token rejection or strategy-recovery UX', async () => {
+ mockUseAccessToken.mockReturnValue({
+ token: 'saved-token',
+ hasToken: true,
+ saveToken: mockSaveToken,
+ clearToken: mockClearToken,
+ isLoading: false,
+ error: null,
+ });
+ mockConvertFeed.mockRejectedValueOnce(
+ Object.assign(new Error('URL not allowed for this account'), {
+ manualRetryStrategy: 'browserless',
+ })
+ );
+
+ render();
+
+ fireEvent.input(screen.getByLabelText('Page URL'), {
+ target: { value: 'https://example.com/articles' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
+
+ await screen.findByText('URL not allowed for this account');
+ expect(mockClearToken).not.toHaveBeenCalled();
+ expect(screen.queryByText('Add access token')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Access token was rejected. Paste a valid token to continue.')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument();
+ });
+
it('shows the utility links in a user-focused order', () => {
window.history.replaceState({}, '', 'http://localhost:3000/#result');
render();
diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx
index 80e6a009..981da692 100644
--- a/frontend/src/components/App.tsx
+++ b/frontend/src/components/App.tsx
@@ -102,11 +102,36 @@ export function App() {
const isAccessTokenError = (message: string) => {
const normalized = message.toLowerCase();
+ const mentionsAuthToken =
+ normalized.includes('access token') ||
+ normalized.includes('token') ||
+ normalized.includes('authentication') ||
+ normalized.includes('bearer');
+
return (
+ normalized.includes('unauthorized') ||
+ normalized.includes('invalid token') ||
+ normalized.includes('token rejected') ||
+ normalized.includes('authentication') ||
+ (normalized.includes('forbidden') && mentionsAuthToken)
+ );
+ };
+
+ const isActionableStrategySwitch = (message: string, currentStrategy: string, retryStrategy: string) => {
+ if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false;
+
+ const normalized = message.toLowerCase();
+ return !(
normalized.includes('unauthorized') ||
normalized.includes('forbidden') ||
+ normalized.includes('not allowed') ||
+ normalized.includes('disabled') ||
normalized.includes('access token') ||
- normalized.includes('authentication')
+ normalized.includes('token') ||
+ normalized.includes('authentication') ||
+ normalized.includes('bad request') ||
+ normalized.includes('url') ||
+ normalized.includes('unsupported strategy')
);
};
@@ -150,7 +175,9 @@ export function App() {
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.';
const retryStrategy = (submitError as ConversionErrorWithMeta).manualRetryStrategy ?? '';
- setManualRetryStrategy(retryStrategy);
+ setManualRetryStrategy(
+ isActionableStrategySwitch(message, strategy, retryStrategy) ? retryStrategy : ''
+ );
if (feedCreation.access_token_required && isAccessTokenError(message)) {
clearToken();
diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx
index 08e1139c..dc8be1ea 100644
--- a/frontend/src/components/AppPanels.tsx
+++ b/frontend/src/components/AppPanels.tsx
@@ -251,7 +251,7 @@ export function CreateFeedPanel({
{isConverting && (
Preparing feed
-
Creating the feed and loading its preview before showing the result.
+
Creating the feed now. The result appears first, then preview loading continues.
)}